From db1602c6e65dde7f49bc41a6dbb5c5f62c79fbaf Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Tue, 10 Nov 2026 01:29:46 +0200 Subject: [PATCH] Leak report: stay quiet when a patcher dies whose entry holds no model ComfyUI builds a lookup entry per patcher on every model load and drops it when the model is already loaded. Its hand-over still fires when the patcher is freed, and was logged as "a dead patcher died; its parent is gone" although nothing can be lost there. Co-Authored-By: Claude Fable 5.2 --- core/leak_report.py | 4 +++- core/tests/test_leak_report.py | 27 +++++++++++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/core/leak_report.py b/core/leak_report.py index ad548106..e9243bf0 100644 --- a/core/leak_report.py +++ b/core/leak_report.py @@ -130,12 +130,14 @@ def watch_switch(mm) -> bool: try: parent = self._parent_model() real = self.real_model() if callable(self.real_model) else None + if real is None: # a lookup entry ComfyUI built and dropped, or one whose model is freed: nothing to lose + return original(self) _seen[0] += 1 if parent is None or _seen[0] <= 40: # a hand-over with no parent always shows; the others for the first 40 of the session depth, p = 0, parent while p is not None and getattr(p, "parent", None) is not None and depth < 50: p, depth = p.parent, depth + 1 - log.info("FunUI Leak", f"hand-over {_seen[0]}: a {type(real).__name__ if real is not None else 'dead'} patcher died; its parent is " + log.info("FunUI Leak", f"hand-over {_seen[0]}: a {type(real).__name__} patcher died; its parent is " f"{'gone' if parent is None else type(parent).__name__ + f' with {depth} more above it'}; Python's gc running: {_in_gc[0]}") except Exception as exc: # noqa: BLE001 -- a diagnostic must never stop a run log.failed("leak report", exc) diff --git a/core/tests/test_leak_report.py b/core/tests/test_leak_report.py index cc06c623..65182902 100644 --- a/core/tests/test_leak_report.py +++ b/core/tests/test_leak_report.py @@ -148,7 +148,7 @@ def test_a_hand_over_with_no_parent_is_logged_after_the_first_forty(monkeypatch) mm = type("MM", (), {})() class LM: - def __init__(self): self._parent_model = lambda: None; self.real_model = lambda: None + def __init__(self): self._parent_model = lambda: None; self.real_model = lambda: object() def _switch_parent(self): pass mm.LoadedModel = LM leak_report.watch_switch(mm) @@ -175,10 +175,33 @@ def test_the_first_forty_hand_overs_with_a_parent_are_logged_and_the_forty_first mm = type("MM", (), {})() class LM: - def __init__(self): self._parent_model = lambda: object(); self.real_model = lambda: None + def __init__(self): self._parent_model = lambda: object(); self.real_model = lambda: object() def _switch_parent(self): pass mm.LoadedModel = LM leak_report.watch_switch(mm) for _ in range(45): LM()._switch_parent() assert len(lines) == 40 + + +def test_a_throwaway_entry_that_holds_no_model_is_not_reported(comfyui, capsys): + """Each time models are loaded ComfyUI builds a fresh entry per patcher to look it up, and drops it when the model is already + loaded. Such an entry holds no model, yet its hand-over still fires when the patcher dies: nothing can be lost there.""" + import comfy.model_management as mm + import comfy.model_patcher as mp + original = mm.LoadedModel._switch_parent + try: + leak_report.watch_switch(mm) + base = torch.nn.Linear(2, 2) + base.model_config = type("C", (), {})() + parent = mp.ModelPatcher(base, load_device=torch.device("cpu"), offload_device=torch.device("cpu")) + child = parent.clone() + child.myself = child # a cycle, so only Python's gc can free the pair: it clears the link to the parent first + mm.LoadedModel(child) # the lookup entry, never loaded and never tracked + del parent, child + gc.collect() + assert "hand-over" not in capsys.readouterr().err + finally: + mm.LoadedModel._switch_parent = original + if leak_report._gc_flag in gc.callbacks: + gc.callbacks.remove(leak_report._gc_flag)