Fix file list cache invalidation on exFAT filesystems

On exFAT volumes, directory mtime does not update when files are
added or removed, causing ComfyUI's mtime-based cache invalidation
in folder_paths to never detect changes. This means newly added
model files do not appear in node dropdowns until the server is
restarted.

Fix: detect exFAT volumes via Win32 API and use directory entry
count as an additional cache invalidation signal on such
filesystems. NTFS and other platforms are unaffected.
This commit is contained in:
Wenaka2004 2026-06-10 23:25:01 +08:00
parent 039ed38ed1
commit b1fa964486

View File

@ -1,4 +1,5 @@
import os import os
import sys
import time import time
import mimetypes import mimetypes
import logging import logging
@ -7,6 +8,34 @@ from collections.abc import Collection
from comfy.cli_args import args from comfy.cli_args import args
# exFAT does not update directory mtime when files are added/removed,
# so the cache invalidation needs an extra check on such filesystems.
_exfat_checked = False
_is_exfat_cache: dict[str, bool] = {}
def _is_exfat(path: str) -> bool:
"""Check if the given path resides on an exFAT volume (Windows only)."""
global _exfat_checked, _is_exfat_cache
if sys.platform != "win32":
return False
# Cache per drive letter
drive = os.path.splitdrive(path)[0]
if drive in _is_exfat_cache:
return _is_exfat_cache[drive]
try:
import ctypes
kernel32 = ctypes.windll.kernel32
# Get volume information
fs_name = ctypes.create_unicode_buffer(256)
if kernel32.GetVolumeInformationW(drive + "\\", None, 0, None, None, None, fs_name, 256):
result = fs_name.value.upper() == "EXFAT"
else:
result = False
except Exception:
result = False
_is_exfat_cache[drive] = result
return result
supported_pt_extensions: set[str] = {'.ckpt', '.pt', '.pt2', '.bin', '.pth', '.safetensors', '.pkl', '.sft'} supported_pt_extensions: set[str] = {'.ckpt', '.pt', '.pt2', '.bin', '.pth', '.safetensors', '.pkl', '.sft'}
folder_names_and_paths: dict[str, tuple[list[str], set[str]]] = {} folder_names_and_paths: dict[str, tuple[list[str], set[str]]] = {}
@ -308,19 +337,22 @@ def get_folder_paths(folder_name: str) -> list[str]:
folder_name = map_legacy(folder_name) folder_name = map_legacy(folder_name)
return folder_names_and_paths[folder_name][0][:] return folder_names_and_paths[folder_name][0][:]
def recursive_search(directory: str, excluded_dir_names: list[str] | None=None) -> tuple[list[str], dict[str, float]]: def recursive_search(directory: str, excluded_dir_names: list[str] | None=None) -> tuple[list[str], dict[str, float], dict[str, int]]:
if not os.path.isdir(directory): if not os.path.isdir(directory):
return [], {} return [], {}, {}
if excluded_dir_names is None: if excluded_dir_names is None:
excluded_dir_names = [] excluded_dir_names = []
result = [] result = []
dirs = {} dirs = {}
entry_counts = {}
# Attempt to add the initial directory to dirs with error handling # Attempt to add the initial directory to dirs with error handling
try: try:
dirs[directory] = os.path.getmtime(directory) dirs[directory] = os.path.getmtime(directory)
if _is_exfat(directory):
entry_counts[directory] = len(os.listdir(directory))
except FileNotFoundError: except FileNotFoundError:
logging.warning(f"Warning: Unable to access {directory}. Skipping this path.") logging.warning(f"Warning: Unable to access {directory}. Skipping this path.")
@ -343,11 +375,13 @@ def recursive_search(directory: str, excluded_dir_names: list[str] | None=None)
path: str = os.path.join(dirpath, d) path: str = os.path.join(dirpath, d)
try: try:
dirs[path] = os.path.getmtime(path) dirs[path] = os.path.getmtime(path)
if _is_exfat(path):
entry_counts[path] = len(os.listdir(path))
except FileNotFoundError: except FileNotFoundError:
logging.warning(f"Warning: Unable to access {path}. Skipping this path.") logging.warning(f"Warning: Unable to access {path}. Skipping this path.")
continue continue
logging.debug("found {} files".format(len(result))) logging.debug("found {} files".format(len(result)))
return result, dirs return result, dirs, entry_counts
def filter_files_extensions(files: Collection[str], extensions: Collection[str]) -> list[str]: def filter_files_extensions(files: Collection[str], extensions: Collection[str]) -> list[str]:
return sorted(list(filter(lambda a: os.path.splitext(a)[-1].lower() in extensions or len(extensions) == 0, files))) return sorted(list(filter(lambda a: os.path.splitext(a)[-1].lower() in extensions or len(extensions) == 0, files)))
@ -390,11 +424,16 @@ def get_filename_list_(folder_name: str) -> tuple[list[str], dict[str, float], f
output_list = set() output_list = set()
folders = folder_names_and_paths[folder_name] folders = folder_names_and_paths[folder_name]
output_folders = {} output_folders = {}
all_entry_counts = {}
for x in folders[0]: for x in folders[0]:
files, folders_all = recursive_search(x, excluded_dir_names=[".git"]) files, folders_all, entry_counts = recursive_search(x, excluded_dir_names=[".git"])
output_list.update(filter_files_extensions(files, folders[1])) output_list.update(filter_files_extensions(files, folders[1]))
output_folders = {**output_folders, **folders_all} output_folders = {**output_folders, **folders_all}
# Store entry counts with a key suffix to avoid colliding with mtime keys
for k, v in entry_counts.items():
all_entry_counts[k + "::entry_count"] = v
output_folders.update(all_entry_counts)
return sorted(list(output_list)), output_folders, time.perf_counter() return sorted(list(output_list)), output_folders, time.perf_counter()
def cached_filename_list_(folder_name: str) -> tuple[list[str], dict[str, float], float] | None: def cached_filename_list_(folder_name: str) -> tuple[list[str], dict[str, float], float] | None:
@ -410,9 +449,24 @@ def cached_filename_list_(folder_name: str) -> tuple[list[str], dict[str, float]
out = filename_list_cache[folder_name] out = filename_list_cache[folder_name]
for x in out[1]: for x in out[1]:
# Skip entry count keys used for exFAT cache invalidation
if x.endswith("::entry_count"):
continue
time_modified = out[1][x] time_modified = out[1][x]
folder = x folder = x
if os.path.getmtime(folder) != time_modified: try:
current_mtime = os.path.getmtime(folder)
except FileNotFoundError:
return None
if current_mtime != time_modified:
return None
# exFAT does not update directory mtime on file changes;
# use entry count as a fallback invalidation signal.
if _is_exfat(folder):
try:
if len(os.listdir(folder)) != out[1][folder + "::entry_count"]:
return None
except (KeyError, FileNotFoundError):
return None return None
folders = folder_names_and_paths[folder_name] folders = folder_names_and_paths[folder_name]