fix(config): keep unavailable model roots in the saved library paths

Starting ComfyUI with an external drive switched off erased that drive's path
from `settings.json`. `Config._dedupe_existing_paths()` drops paths that do not
exist at the moment the root list is built, and `save_folder_paths_to_settings()`
(plugin mode, called from `Config.__init__`) persisted `list(self.loras_roots)`
through `upsert_library(folder_paths=...)`, which REPLACES the library's paths.
One boot with the drive off was therefore enough to lose the configuration, not
just to hide the root — the same applied to checkpoints/unet/embeddings and the
other-model keys.

* `Config` now records the *configured* (existence-unfiltered) primary paths per
  model type while building the live lists, and the startup sync persists those.
  A path the host (plugin mode) or the library (standalone mode) no longer
  configures is still dropped, so removing a path keeps working.
* In standalone mode the host mock (`MockFolderPaths.get_folder_paths`) already
  filters non-existent paths out of settings.json, so the active library snapshot
  is the only record of what the user configured; `_remember_library_configured_paths()`
  reads it there and leaves plugin mode to ComfyUI's own unfiltered list.
* `_resolve_valid_default_root()` receives the configured paths in
  `allowed_paths`, so a `default_*_root` on a switched-off drive is no longer
  "repaired" onto another root.
* `_append_new_paths()` is the shared append-only union (order preserved, case
  insensitive) used by the sync.

Verified: a new config test builds the plugin-mode sync with one switched-off
drive, one readable root and one path the host dropped — the switched-off path
and the default root that points at it survive, the dropped path does not, and
the live `loras_roots` still holds only the readable root. tests/config 64 passed.
This commit is contained in:
Will Miao
2026-10-07 16:35:29 +08:00
parent 0941f99226
commit 08a8e54d87
2 changed files with 244 additions and 7 deletions
+181 -7
View File
@@ -44,6 +44,26 @@ def _normalize_root_identity(path: str) -> str:
return normalized
def _append_new_paths(current: List[str], candidates: Iterable[str]) -> List[str]:
"""Return ``current`` plus any candidate it does not already hold.
Order is preserved and nothing is ever removed: ``*_roots[0]`` derives the
recipes directory and the usage-stats file location, so a mid-session change
to the root *order* would move user data.
"""
merged = list(current)
seen = {_normalize_root_identity(path) for path in merged}
for path in candidates:
if not isinstance(path, str) or not path.strip():
continue
identity = _normalize_root_identity(path)
if identity in seen:
continue
seen.add(identity)
merged.append(path)
return merged
def _resolve_valid_default_root(
current: str, primary_paths: List[str], allowed_paths: List[str], name: str
) -> str:
@@ -169,6 +189,12 @@ class Config:
self._preview_root_paths: Set[Path] = set()
# Fingerprint of the symlink layout from the last successful scan
self._cached_fingerprint: Optional[Dict[str, object]] = None
# Configured (existence-unfiltered) primary roots per model type. The live
# lists below drop paths whose directory is missing right now (a drive
# that is switched off), which must not be mistaken for "the user
# removed this path": these are what `save_folder_paths_to_settings()`
# persists and what `/roots` reports as unavailable.
self._configured_root_paths: Dict[str, List[str]] = {}
self.loras_roots = self._init_lora_paths()
self.checkpoints_roots = None
self.unet_roots = None
@@ -228,6 +254,8 @@ class Config:
if isinstance(recipes_path, str) and recipes_path:
self.recipes_path = recipes_path
self._remember_library_configured_paths(library_config)
extra_folder_paths = library_config.get("extra_folder_paths")
if not isinstance(extra_folder_paths, dict):
return
@@ -340,16 +368,40 @@ class Config:
comfy_library = libraries.get("comfyui", {})
default_library = libraries.get("default", {})
# Persist what is *configured*, not what is readable right now:
# `upsert_library(folder_paths=...)` replaces the library's paths, so
# writing the existence-filtered live lists would erase the path of a
# drive that happened to be switched off when ComfyUI started.
target_folder_paths = {
"loras": list(self.loras_roots),
"checkpoints": list(self.checkpoints_roots or []),
"unet": list(self.unet_roots or []),
"embeddings": list(self.embeddings_roots or []),
"loras": _append_new_paths(
self.configured_roots_for("lora"), self.loras_roots or []
),
"checkpoints": _append_new_paths(
self.configured_roots_for("checkpoint"),
self.checkpoints_roots or [],
),
"unet": _append_new_paths(
self.configured_roots_for("unet"), self.unet_roots or []
),
"embeddings": _append_new_paths(
self.configured_roots_for("embedding"),
self.embeddings_roots or [],
),
}
# Persist the other-model roots under their original folder_paths
# keys so library switching round-trips them.
for key, roots in (self.other_folder_roots or {}).items():
target_folder_paths[key] = list(roots)
# ...and keep an other-model root that is configured but unavailable.
configured_other = self.configured_roots_for("other")
if configured_other:
for key in self._get_enabled_other_folder_keys():
configured_key_roots = self._configured_other_paths_for_key(key)
if not configured_key_roots:
continue
target_folder_paths[key] = _append_new_paths(
configured_key_roots, target_folder_paths.get(key, [])
)
normalized_target_paths = _normalize_folder_paths_for_comparison(
target_folder_paths
@@ -420,7 +472,14 @@ class Config:
default_lora_root = _resolve_valid_default_root(
comfy_library.get("default_lora_root", ""),
list(self.loras_roots or []),
# A configured-but-unavailable root is still a valid choice: it
# must not be "repaired" away just because its drive is off.
_append_new_paths(
self.configured_roots_for(
"lora"
),
self.loras_roots or [],
),
list(self.loras_roots or [])
+ list(comfy_library.get("extra_folder_paths", {}).get("loras", []) or []),
"default_lora_root",
@@ -428,7 +487,14 @@ class Config:
default_checkpoint_root = _resolve_valid_default_root(
comfy_library.get("default_checkpoint_root", ""),
list(self.checkpoints_roots or []),
# A configured-but-unavailable root is still a valid choice: it
# must not be "repaired" away just because its drive is off.
_append_new_paths(
self.configured_roots_for(
"checkpoint"
),
self.checkpoints_roots or [],
),
list(self.checkpoints_roots or [])
+ list(comfy_library.get("extra_folder_paths", {}).get("checkpoints", []) or []),
"default_checkpoint_root",
@@ -436,7 +502,14 @@ class Config:
default_embedding_root = _resolve_valid_default_root(
comfy_library.get("default_embedding_root", ""),
list(self.embeddings_roots or []),
# A configured-but-unavailable root is still a valid choice: it
# must not be "repaired" away just because its drive is off.
_append_new_paths(
self.configured_roots_for(
"embedding"
),
self.embeddings_roots or [],
),
list(self.embeddings_roots or [])
+ list(comfy_library.get("extra_folder_paths", {}).get("embeddings", []) or []),
"default_embedding_root",
@@ -1331,6 +1404,14 @@ class Config:
unet_paths = folder_paths.get("unet", []) or []
embedding_paths = folder_paths.get("embeddings", []) or []
# The snapshot is the authoritative configured set: unlike the live lists
# below it keeps paths whose directory is missing right now, and a path
# the user removed from the library is forgotten here.
self._remember_configured_paths("lora", lora_paths)
self._remember_configured_paths("checkpoint", checkpoint_paths)
self._remember_configured_paths("unet", unet_paths)
self._remember_configured_paths("embedding", embedding_paths)
self.loras_roots = self._prepare_lora_paths(lora_paths)
(
self.base_models_roots,
@@ -1343,6 +1424,10 @@ class Config:
key: folder_paths.get(key, []) or []
for key in self._get_enabled_other_folder_keys()
}
self._remember_configured_paths(
"other",
[path for paths in other_path_map.values() for path in paths],
)
(
self.other_roots,
self.other_root_subtypes,
@@ -1398,10 +1483,60 @@ class Config:
self._initialize_symlink_mappings()
def _remember_library_configured_paths(
self, library_config: Mapping[str, Any]
) -> None:
"""Remember the paths the library configures, missing directories included.
In standalone mode the host mock already filters non-existent paths out of
``folder_paths`` (see ``standalone.MockFolderPaths.get_folder_paths``), so
the library snapshot is the only record of what the user configured and it
wins there. In plugin mode ComfyUI's own list is unfiltered and therefore
authoritative — a path removed from the host config must be forgotten, not
resurrected from our mirror of it.
"""
if not standalone_mode:
return
from .services.settings_manager import get_settings_manager
folder_paths_map = (
library_config.get("folder_paths")
if isinstance(library_config, Mapping)
else None
)
if not isinstance(folder_paths_map, Mapping):
folder_paths_map = getattr(
get_settings_manager(), "settings", {}
).get("folder_paths", {})
if not isinstance(folder_paths_map, Mapping):
return
for key, model_type in (
("loras", "lora"),
("checkpoints", "checkpoint"),
("unet", "unet"),
("embeddings", "embedding"),
):
paths = folder_paths_map.get(key)
if isinstance(paths, (list, tuple)):
self._remember_configured_paths(model_type, paths)
core_keys = {"loras", "checkpoints", "unet", "embeddings"}
other_paths = [
path
for key, paths in folder_paths_map.items()
if key not in core_keys and isinstance(paths, (list, tuple))
for path in paths
]
if other_paths:
self._remember_configured_paths("other", other_paths)
def _init_lora_paths(self) -> List[str]:
"""Initialize and validate LoRA paths from ComfyUI settings"""
try:
raw_paths = folder_paths.get_folder_paths("loras")
self._remember_configured_paths("lora", raw_paths)
unique_paths = self._prepare_lora_paths(raw_paths)
logger.info(
"Found LoRA roots:"
@@ -1422,6 +1557,8 @@ class Config:
try:
raw_checkpoint_paths = folder_paths.get_folder_paths("checkpoints")
raw_unet_paths = folder_paths.get_folder_paths("unet")
self._remember_configured_paths("checkpoint", raw_checkpoint_paths)
self._remember_configured_paths("unet", raw_unet_paths)
(
unique_paths,
self.checkpoints_roots,
@@ -1448,6 +1585,7 @@ class Config:
"""Initialize and validate embedding paths from ComfyUI settings"""
try:
raw_paths = folder_paths.get_folder_paths("embeddings")
self._remember_configured_paths("embedding", raw_paths)
unique_paths = self._prepare_embedding_paths(raw_paths)
logger.info(
"Found embedding roots:"
@@ -1485,6 +1623,10 @@ class Config:
except Exception as exc:
logger.debug("Error reading folder paths for '%s': %s", key, exc)
self._remember_configured_paths(
"other", [path for paths in folder_path_map.values() for path in paths]
)
(
unique_paths,
self.other_root_subtypes,
@@ -1505,6 +1647,38 @@ class Config:
logger.warning(f"Error initializing other model paths: {e}")
return []
def _remember_configured_paths(
self, model_type: str, paths: Iterable[str]
) -> None:
"""Record the configured roots of a model type (they may not exist yet).
Replaces the previous value rather than merging: every caller knows the
complete configured set for its type, so a path the user removed must be
forgotten here too.
"""
configured = [
path.strip()
for path in paths or []
if isinstance(path, str) and path.strip()
]
# Tolerate partially constructed instances (`Config.__new__`), which the
# path-resolution tests build to exercise a single initializer.
configured_map = getattr(self, "_configured_root_paths", None)
if configured_map is None:
configured_map = {}
self._configured_root_paths = configured_map
configured_map[model_type] = configured
def configured_roots_for(self, model_type: str) -> List[str]:
"""Configured roots of a model type, including ones that are unavailable.
``get_model_roots()`` on the scanners answers "what can be walked right
now"; this answers "what did the user (or the host) configure", which is
the set that must survive a switched-off drive in the configuration and
the set the refresh menu reports as offline.
"""
return list((getattr(self, "_configured_root_paths", None) or {}).get(model_type, []))
def refresh_other_roots(self) -> None:
"""Rebuild other-model roots after the management toggles changed.
+63
View File
@@ -905,3 +905,66 @@ def test_save_paths_removes_stale_empty_default_when_comfyui_exists(
assert name == "comfyui"
assert payload["activate"] is True
assert fake_settings.active_library == "comfyui"
def test_save_paths_keeps_configured_root_of_a_switched_off_drive(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
"""A root whose directory is missing right now must stay in the library.
`upsert_library(folder_paths=...)` replaces the stored paths, so persisting
the existence-filtered live list would erase a drive that happened to be
switched off when ComfyUI started. A path the host no longer configures must
still be dropped.
"""
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
readable = folder_paths["loras"][0]
switched_off = str(tmp_path / "drive-Z" / "loras")
removed_by_user = str(tmp_path / "old-location" / "loras")
# The host still configures the switched-off drive; it no longer configures
# the old location (that one only survives in the stored library paths).
folder_paths["loras"] = [switched_off, readable]
class FakeSettingsService:
active_library = "comfyui"
name: str = ""
payload: Dict[str, Any] = {}
def get_libraries(self):
return {
"comfyui": {
"folder_paths": {
"loras": [removed_by_user, readable],
"checkpoints": [],
"unet": [],
"embeddings": [],
},
"default_lora_root": switched_off,
}
}
def rename_library(self, *_):
raise AssertionError("rename_library should not be invoked")
def get_active_library_name(self):
return self.active_library
def upsert_library(self, name: str, **payload):
self.name = name
self.payload = payload
fake_settings = FakeSettingsService()
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
config_instance = config_module.Config()
persisted = fake_settings.payload["folder_paths"]["loras"]
# The switched-off drive keeps its configured path...
assert switched_off.replace("\\", "/") in persisted
assert readable.replace("\\", "/") in persisted
# ...the path the host really dropped is gone...
assert removed_by_user.replace("\\", "/") not in persisted
# ...and the live list still only holds what is readable right now.
assert config_instance.loras_roots == [readable.replace("\\", "/")]
# The user's default root survives because it is merely unavailable.
assert fake_settings.payload["default_lora_root"] == switched_off.replace("\\", "/")