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 <noreply@anthropic.com>
208 lines
8.5 KiB
Python
208 lines
8.5 KiB
Python
"""The leak report names the holder of a model whose patcher is gone."""
|
|
import gc
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from core import leak_report
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _unwrapped():
|
|
"""Another test may already have installed the report on the shared ComfyUI module; each test here starts from the plain functions."""
|
|
try:
|
|
import comfy.model_management as mm
|
|
except Exception: # noqa: BLE001 -- ComfyUI absent: the tests that need it skip themselves
|
|
yield
|
|
return
|
|
cleanup, switch = mm.cleanup_models_gc, mm.LoadedModel._switch_parent
|
|
mm.cleanup_models_gc = lambda: None if getattr(cleanup, leak_report.TAG, False) else cleanup()
|
|
mm.LoadedModel._switch_parent = (lambda self: None) if getattr(switch, leak_report.TAG, False) else switch
|
|
yield
|
|
mm.cleanup_models_gc, mm.LoadedModel._switch_parent = cleanup, switch
|
|
|
|
|
|
class Holder:
|
|
"""Stands for the closure or cache that keeps a model alive."""
|
|
def __init__(self, model):
|
|
self.keep = model
|
|
|
|
|
|
def test_a_model_kept_by_something_else_is_named_before_and_after_gc(comfyui, capsys):
|
|
import comfy.model_management as mm
|
|
import comfy.model_patcher as mp
|
|
base = torch.nn.Linear(2, 2)
|
|
base.model_config = type("C", (), {})()
|
|
patcher = mp.ModelPatcher(base, load_device=torch.device("cpu"), offload_device=torch.device("cpu"))
|
|
loaded = mm.LoadedModel(patcher)
|
|
import weakref
|
|
loaded.real_model = weakref.ref(base)
|
|
holder = Holder(base)
|
|
mm.current_loaded_models.append(loaded)
|
|
try:
|
|
del patcher
|
|
gc.collect()
|
|
assert loaded.is_dead()
|
|
assert leak_report.report(mm, "test") == 1
|
|
shown = capsys.readouterr().err
|
|
assert "Linear outlived its patcher" in shown and "Holder" in shown
|
|
finally:
|
|
mm.current_loaded_models.remove(loaded)
|
|
del holder
|
|
|
|
|
|
def test_install_wraps_once_and_a_clean_state_logs_nothing(comfyui, capsys):
|
|
import comfy.model_management as mm
|
|
original, switch = mm.cleanup_models_gc, mm.LoadedModel._switch_parent
|
|
try:
|
|
assert leak_report.install(mm) and not leak_report.install(mm)
|
|
mm.cleanup_models_gc()
|
|
assert "FunUI Leak" not in capsys.readouterr().err
|
|
finally:
|
|
mm.cleanup_models_gc, mm.LoadedModel._switch_parent = original, switch
|
|
if leak_report._gc_flag in gc.callbacks:
|
|
gc.callbacks.remove(leak_report._gc_flag)
|
|
|
|
|
|
def test_a_hand_over_that_finds_no_parent_is_said(comfyui, capsys):
|
|
import weakref
|
|
import comfy.model_management as mm
|
|
import comfy.model_patcher as mp
|
|
original = mm.LoadedModel._switch_parent
|
|
try:
|
|
assert leak_report.watch_switch(mm) and not 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()
|
|
loaded = mm.LoadedModel(child)
|
|
loaded.real_model = weakref.ref(base)
|
|
keep = Holder(base)
|
|
del parent
|
|
gc.collect() # the parent is held by the child: still there
|
|
assert loaded._parent_model() is not None
|
|
loaded._parent_model = lambda: None # what a gc that cleared the weak link leaves behind
|
|
loaded._switch_parent()
|
|
shown = capsys.readouterr().err
|
|
assert "its parent is gone" in shown and "Python's gc running: False" in shown
|
|
del keep
|
|
finally:
|
|
mm.LoadedModel._switch_parent = original
|
|
if leak_report._gc_flag in gc.callbacks:
|
|
gc.callbacks.remove(leak_report._gc_flag)
|
|
|
|
|
|
def test_an_entry_whose_patcher_died_without_a_hand_over_is_tracked_again_through_a_live_one(comfyui, capsys):
|
|
"""The state the rental showed: the cache keeps the loader, LoRA and modifier patchers (one chain), the sampling clone died and
|
|
ComfyUI could not hand over to its parent. The entry must be tracked again, and ComfyUI's own check must stay quiet."""
|
|
import weakref
|
|
import comfy.model_management as mm
|
|
import comfy.model_patcher as mp
|
|
base = torch.nn.Linear(2, 2)
|
|
base.model_config = type("C", (), {})()
|
|
root = mp.ModelPatcher(base, load_device=torch.device("cpu"), offload_device=torch.device("cpu"))
|
|
chain = [root]
|
|
for _ in range(3):
|
|
chain.append(chain[-1].clone())
|
|
sampling = chain[-1].clone()
|
|
loaded = mm.LoadedModel(sampling)
|
|
loaded.real_model = weakref.ref(base)
|
|
mm.current_loaded_models.append(loaded)
|
|
try:
|
|
loaded._parent_model = lambda: None # the hand-over finds no parent
|
|
del sampling
|
|
gc.collect()
|
|
assert loaded.is_dead()
|
|
assert leak_report.relink(mm) == 1 and not loaded.is_dead() and loaded.model is chain[-1]
|
|
assert "tracks it again" in capsys.readouterr().err
|
|
assert leak_report.relink(mm) == 0
|
|
finally:
|
|
mm.current_loaded_models.remove(loaded)
|
|
|
|
|
|
def test_an_entry_with_no_live_patcher_is_left_for_the_report(comfyui):
|
|
import weakref
|
|
import comfy.model_management as mm
|
|
import comfy.model_patcher as mp
|
|
base = torch.nn.Linear(2, 2)
|
|
base.model_config = type("C", (), {})()
|
|
patcher = mp.ModelPatcher(base, load_device=torch.device("cpu"), offload_device=torch.device("cpu"))
|
|
loaded = mm.LoadedModel(patcher)
|
|
loaded.real_model = weakref.ref(base)
|
|
keep = Holder(base)
|
|
mm.current_loaded_models.append(loaded)
|
|
try:
|
|
del patcher
|
|
gc.collect()
|
|
assert loaded.is_dead() and leak_report.relink(mm) == 0 and loaded.is_dead()
|
|
del keep
|
|
finally:
|
|
mm.current_loaded_models.remove(loaded)
|
|
|
|
|
|
def test_a_hand_over_with_no_parent_is_logged_after_the_first_forty(monkeypatch):
|
|
from core import leak_report
|
|
monkeypatch.setattr(leak_report, "_seen", [500])
|
|
lines = []
|
|
monkeypatch.setattr(leak_report.log, "info", lambda *a, **k: lines.append(a))
|
|
mm = type("MM", (), {})()
|
|
|
|
class LM:
|
|
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)
|
|
LM()._switch_parent()
|
|
assert lines and "hand-over" in lines[0][1] and "gone" in lines[0][1]
|
|
|
|
|
|
def test_install_runs_relink_before_comfyuis_own_cleanup(monkeypatch):
|
|
order = []
|
|
mm = type("MM", (), {})()
|
|
mm.cleanup_models_gc = lambda: order.append("comfy")
|
|
monkeypatch.setattr(leak_report, "watch_switch", lambda m: True)
|
|
monkeypatch.setattr(leak_report, "relink", lambda m: order.append("relink") or 0)
|
|
monkeypatch.setattr(leak_report, "report", lambda m, when: order.append(when) or 0)
|
|
assert leak_report.install(mm)
|
|
mm.cleanup_models_gc()
|
|
assert order == ["relink", "before ComfyUI's gc", "comfy", "after ComfyUI's gc"]
|
|
|
|
|
|
def test_the_first_forty_hand_overs_with_a_parent_are_logged_and_the_forty_first_is_not(monkeypatch):
|
|
monkeypatch.setattr(leak_report, "_seen", [0])
|
|
lines = []
|
|
monkeypatch.setattr(leak_report.log, "info", lambda *a, **k: lines.append(a))
|
|
mm = type("MM", (), {})()
|
|
|
|
class LM:
|
|
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)
|