* fix(snapshot): prevent drift corruption from duplicate installs + boot-time mismatch (#161, #162) Closes #162 and #161 as the paired fix the issues describe — both touch the same data-loss surface and verify against the same dev-sync rig. installs at import time, log critical, register nothing in the alphabetical loser; client-side `window` sentinel as belt-and-suspenders; align .env.example / dev-iteration-loop / sync_to_dev / README at the Registry-derived `koolook/` path. compare against live /userdata + picks, set a drift flag on mismatch; gate periodic + pre-load autosave routing to `_unsaved_autosave/` while drifted; promote status pill to "drifted" precedence over "saved" with a recovery tooltip; auto-clear on the next markStateSaved (Save / Load). 26 new tests (14 install guard, 4 sidebar sentinel, 8 boot drift) — 316 total passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(snapshot): apply review fixes — canonical fingerprint + early loser exit + drift timestamp Review-driven follow-ups on PR #186: 1. **Symmetrize file-side fingerprint (MEDIUM).** `detectBootDrift` now uses a new `_canonicalStateFingerprint()` that normalizes the workflows tree through `normalizeWorkflowsStore` AND deep-sorts keys via `sortJsonValue`. Without this, hand-edited / cross-machine / pre-v0.3 files would false-positive as drifted on first boot (e.g. `archived: "false"` string vs `false` bool, missing `directories: {}`, key insertion-order asymmetry from the `{...wf, archived, module, tags}` spread). `_computeFingerprint` stays unchanged so the localStorage save baseline contract holds for existing users. New regression test verifies a shape-only diff does NOT trigger drift. 2. **Gate fork imports on `_is_winning_install` (LOW).** Non-winning duplicate installs now short-circuit before the heavy torch / PIL / diffusers / aiohttp imports, exiting in ~5 LoC instead of paying full import cost just to discard the mappings. 3. **Drift timestamp uses `driftDetectedAt`, not stale `lastNamedSaveAt` (LOW).** The localStorage save baseline could be from a prior session whose live state was already corrupt — surfacing it as "Last named save" in the drift tooltip would mislead. Replaced with the session-noticed-at timestamp from `_bootDriftDiagnostics`. 352 tests passing (316 prior + 26 from PR #186 + 1 new normalize- symmetry case). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(install-guard): fail safe on unreadable / non-UTF-8 siblings (#162) Re-review follow-up. The import-time duplicate-install scan could crash the entire plugin load (fail-silent — the alphabetical winner vanished too, not just the loser) on two unguarded paths: - detect_duplicate_koolook_installs: the per-entry is_dir()/is_file() over custom_nodes/ siblings was unguarded, so a non-traversable sibling (restrictive perms, broken mount, a special dir) raised PermissionError up into __init__.py. No duplicate even required. Now caught per-entry and skipped. - read_pyproject_version: read_text(encoding="utf-8") raises UnicodeDecodeError (a ValueError, NOT OSError) on a non-UTF-8 / binary pyproject.toml, escaping the `except OSError`. Now degrades to "?" via `except (OSError, ValueError)`. - __init__.py: wrapped the guard call site as a final backstop — any unforeseen guard failure now degrades to register-normally rather than aborting the import. Both paths reproduced before the fix; the first surfaced while running the suite from a /tmp checkout whose parent held a root-owned dir. 3 regression tests added (non-traversable sibling skipped; non-UTF-8 pyproject -> "?"; report survives a non-UTF-8 sibling). Full suite: 452 passed (449 + 3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: kflame99 <261674183+kflame99@users.noreply.github.com>
147 lines
6.5 KiB
Python
147 lines
6.5 KiB
Python
"""Duplicate-install detection (#162).
|
|
|
|
The Comfy Registry / ComfyUI-Manager install path creates
|
|
``custom_nodes/koolook/`` (derived from ``[project].name`` in our
|
|
``pyproject.toml``), while a ``git clone`` checkout typically lands as
|
|
``custom_nodes/ComfyUI-Koolook/``. A user who has both — common when a
|
|
Manager install gets shadowed by a dev clone — boots ComfyUI with two
|
|
parallel Koolook plugins. Both register the same ``/koolook/presets/*``
|
|
server routes, the same Kforge Labs sidebar tab, and write to the same
|
|
``/userdata/koolook_workflows.json`` file. The late-loaded plugin
|
|
silently overwrites the early loader's state and the user's workflow
|
|
store corrupts invisibly.
|
|
|
|
This module is the detection + resolution layer. It is intentionally
|
|
free of ComfyUI / aiohttp imports so it can be unit-tested with just
|
|
the standard library — see ``tests/test_install_guard.py``.
|
|
|
|
Resolution strategy: pick the alphabetically-first folder name as the
|
|
winner. Deterministic across all installs (so both copies agree on the
|
|
outcome without needing to coordinate at runtime), independent of
|
|
ComfyUI's load order. The non-winning install registers nothing — no
|
|
nodes, no routes, no sidebar — and prints a critical message naming
|
|
both paths so the user can resolve the duplicate manually.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
def detect_duplicate_koolook_installs(here: Path) -> list[Path]:
|
|
"""Return every sibling directory under ``here.parent`` that also
|
|
contains a ``koolook_routes.py`` marker file (and is not ``here``).
|
|
|
|
The marker file is unique to Koolook installs; a sibling Comfy
|
|
custom node that happens to share a folder name won't match. The
|
|
list is sorted by folder name (case-insensitive) so the order is
|
|
stable for log output and for ``pick_winning_install``.
|
|
"""
|
|
parent = here.parent
|
|
siblings: list[Path] = []
|
|
try:
|
|
entries = list(parent.iterdir())
|
|
except OSError:
|
|
# ``custom_nodes/`` unreadable — would also break the rest of
|
|
# ComfyUI. Fall through to "no siblings detected"; the duplicate
|
|
# symptom this guard catches only manifests when sibling
|
|
# iteration works in the first place.
|
|
return siblings
|
|
for entry in entries:
|
|
try:
|
|
if entry == here or not entry.is_dir():
|
|
continue
|
|
if (entry / "koolook_routes.py").is_file():
|
|
siblings.append(entry)
|
|
except OSError:
|
|
# A sibling we can't stat into (restrictive perms, a broken
|
|
# mount, a special dir) is not our concern — skip it rather
|
|
# than let one unreadable neighbour raise PermissionError up
|
|
# into ``__init__.py`` and abort the whole plugin import. A
|
|
# real duplicate always carries a readable ``koolook_routes.py``.
|
|
continue
|
|
siblings.sort(key=lambda p: p.name.lower())
|
|
return siblings
|
|
|
|
|
|
def read_pyproject_version(install_dir: Path) -> str:
|
|
"""Best-effort extract of the ``version = "..."`` line from a
|
|
sibling's ``pyproject.toml``. Returns ``"?"`` when the file is
|
|
missing or unparseable — the critical log is still useful with
|
|
just the paths.
|
|
|
|
Deliberately a manual scan instead of ``tomllib`` to avoid pulling
|
|
a Python 3.11+ requirement into the install-time guard. The format
|
|
is regular enough that a one-line ``startswith("version")`` parser
|
|
is robust to the few real-world variations (single vs double quotes,
|
|
spaces around ``=``).
|
|
"""
|
|
pyproj = install_dir / "pyproject.toml"
|
|
if not pyproj.is_file():
|
|
return "?"
|
|
try:
|
|
for line in pyproj.read_text(encoding="utf-8").splitlines():
|
|
stripped = line.strip()
|
|
# Match ``version = "..."`` and ``version="..."`` but not
|
|
# ``versioning = "..."`` or other near-collisions.
|
|
if not stripped.startswith("version"):
|
|
continue
|
|
after_keyword = stripped[len("version"):].lstrip()
|
|
if not after_keyword.startswith("="):
|
|
continue
|
|
value = after_keyword[1:].strip()
|
|
# Strip a trailing inline comment (``# ...``) before the
|
|
# quote-stripping in case a pyproject.toml gets clever.
|
|
if " #" in value:
|
|
value = value.split(" #", 1)[0].rstrip()
|
|
return value.strip('"').strip("'")
|
|
except (OSError, ValueError):
|
|
# OSError: unreadable file. ValueError (incl. UnicodeDecodeError):
|
|
# a non-UTF-8 / binary pyproject.toml. Either way the version is
|
|
# simply unknown — degrade to "?", never raise into the import.
|
|
pass
|
|
return "?"
|
|
|
|
|
|
def pick_winning_install(here: Path, siblings: list[Path]) -> Path:
|
|
"""Alphabetical-by-folder-name resolution. Stable across both
|
|
installs so neither needs to coordinate at runtime; the loser
|
|
figures out it's the loser by comparing this result against its
|
|
own ``__file__``."""
|
|
return sorted([here, *siblings], key=lambda p: p.name.lower())[0]
|
|
|
|
|
|
def build_duplicate_report(here: Path, siblings: list[Path]) -> tuple[bool, str]:
|
|
"""Produce the critical-log message for a duplicate-install
|
|
situation. Returns ``(is_winning, message)`` where ``is_winning``
|
|
is ``True`` when ``here`` was the alphabetically-first folder.
|
|
|
|
Pure function — no I/O. Caller decides where to surface the message
|
|
(``print()`` in ``__init__.py``, a logger in a test, etc.).
|
|
"""
|
|
winner = pick_winning_install(here, siblings)
|
|
is_winning = winner == here
|
|
here_version = read_pyproject_version(here)
|
|
sibling_lines = "\n".join(
|
|
f" - {s} (version: {read_pyproject_version(s)})"
|
|
for s in siblings
|
|
)
|
|
header = (
|
|
"[Koolook] CRITICAL: duplicate ComfyUI-Koolook installations detected.\n"
|
|
f" This install: {here} (version: {here_version})\n"
|
|
f" Other install(s):\n{sibling_lines}\n"
|
|
f" Active install (alphabetical winner): {winner}\n"
|
|
" Both copies register the same /koolook/presets/* routes, the same\n"
|
|
" Kforge Labs sidebar tab, and write to the same\n"
|
|
" /userdata/koolook_workflows.json file. Running both silently\n"
|
|
" corrupts your workflow store on every restart. Remove one of\n"
|
|
" the directories above and restart ComfyUI."
|
|
)
|
|
if is_winning:
|
|
return True, header
|
|
return False, (
|
|
header + "\n"
|
|
f"[Koolook] this install ({here.name}) is the non-winning duplicate; "
|
|
"skipping node + route registration. Only the winning install above "
|
|
"will serve Kforge Labs this session."
|
|
)
|