Files
olivv-cs-ComfyUI-FunPack/core/patching.py
T
digital-garbageandClaude Opus 5 0f31547e1e foundation: two the whole-tree pass found, both against the real engine
The run id froze after the first generation. Runs were marked by the
modifiers node, and ComfyUI CACHES a node whose inputs have not changed --
only the seed changes between most generations, so that node executed once
and every later run reported under the first one's id. Per-run log dedup
degraded to per-process with it, which is precisely the v4 fault log.py's
own comment describes, reintroduced through a caching interaction the fix
did not account for.

The sampler marks the run now. It is what generates, and it re-executes
whenever anything about the generation differs. Verified through the real
PromptExecutor with caching on: three seeds, three run ids. The
dropped-modifier record is also emptied there, because it travels on the
model and ComfyUI may hand the same model back -- a modifier that failed
last time deserves this time.

And a bad settings payload was only caught during execution, after the
checkpoint had already loaded. On a rented GPU that is the cost paid before
the guard fires, one layer up from the sampling case this project already
guards. ComfyUI calls validate_inputs inside validate_prompt, so the payload
is now understood before the queue accepts it: a typo'd module id is refused
with nothing loaded. Both doors check the same things, and a test pins that
they agree.

There was no test file for runs at all, which is why the first fault could
be introduced by a fix and survive a green suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 18:14:41 +03:00

226 lines
8.8 KiB
Python

"""Tagging what we install, so it can be removed again.
ComfyUI offers two shapes of hook. Some are keyed -- `add_wrapper_with_key` takes
a name and removing it is exact. Others are plain lists on `model_options`, where
`set_model_sampler_pre_cfg_function` appends an anonymous callable and nothing
records who put it there.
For the second kind, install-then-forget means a model run through the loader
twice carries two copies and applies the effect at double strength, reporting
once. That is the accumulation this project has already paid for: v4 registered
hooks on shared blocks and only a restart cleared them.
So everything we install is tagged, and everything tagged is stripped before the
next install. The walk below is generic -- it looks for our tag rather than for a
list of known hook names, because a list of hook names is a thing to keep current
and the day it falls behind is the day a hook stops being removable.
"""
from typing import Any
TAG = "_funpack_key"
def tag(fn, key: str):
"""Mark a callable as ours, so `strip` can find it later."""
try:
setattr(fn, TAG, key)
except AttributeError:
# A builtin or a slotted object cannot carry the mark, and something
# unremovable must not be installed silently.
raise TypeError(
f"{fn!r} cannot be tagged, so it could never be removed again. "
f"Install a plain function or a closure.")
return fn
def _ours(value: Any, prefix: str) -> bool:
key = getattr(value, TAG, None)
return isinstance(key, str) and key.startswith(prefix)
def strip(patcher, prefix: str) -> int:
"""Remove every tagged hook under `prefix`, and nothing else.
Covers both shapes: keyed wrappers and callbacks, and the anonymous function
lists on model_options. Returns how many were removed, so a caller can say
that it replaced something rather than leaving it to be inferred.
"""
removed = 0
for holder in (getattr(patcher, "wrappers", None), getattr(patcher, "callbacks", None)):
if not isinstance(holder, dict):
continue
for by_key in holder.values():
if not isinstance(by_key, dict):
continue
for key in [k for k in by_key if isinstance(k, str) and k.startswith(prefix)]:
by_key.pop(key, None)
removed += 1
options = getattr(patcher, "model_options", None)
if isinstance(options, dict):
removed += _strip_options(options, prefix)
return removed
def _strip_options(options: dict, prefix: str) -> int:
"""Walk model_options for tagged callables, in lists or on their own."""
removed = 0
for name, value in list(options.items()):
if isinstance(value, list):
keep = [item for item in value if not _ours(item, prefix)]
if len(keep) != len(value):
removed += len(value) - len(keep)
options[name] = keep
elif callable(value) and _ours(value, prefix):
options.pop(name, None)
removed += 1
elif isinstance(value, dict):
removed += _strip_options(value, prefix)
return removed
# --- keeping a module from killing a run ----------------------------------
#
# Catching a module that fails to INSTALL is easy and was already done. The one
# that matters is a module that installs fine and raises on step 3 of 30: the
# generation dies after the GPU time is spent, and on a rental that is the whole
# cost of the run. A modifier is an opinion about the picture. It is never worth
# the picture.
#
# So a failing hook is dropped for the rest of the run and the run continues
# without it. Loudly -- a dropped modifier is the difference between "I asked for
# this" and "this happened", so it is said once, with the traceback, and carried
# in the run's status.
class Dropped:
"""What was disabled during a run, and why. One per run, not per process."""
def __init__(self):
self.reasons: dict = {}
def clear(self) -> None:
"""Forget what was dropped. Called when a generation starts, because a
modifier that failed on the last one deserves its chance at this one --
and because ComfyUI's cache hands the same object to both."""
self.reasons.clear()
def record(self, key: str, exc: BaseException) -> bool:
"""True the first time this key fails, so callers report once."""
if key in self.reasons:
return False
self.reasons[key] = f"{type(exc).__name__}: {exc}"
return True
def __contains__(self, key) -> bool:
return key in self.reasons
def __bool__(self) -> bool:
return bool(self.reasons)
def items(self):
return self.reasons.items()
def guard(fn, key: str, neutral, dropped: Dropped):
"""`fn`, but unable to end a run.
`neutral(*args, **kwargs)` produces the result the caller would have got had
the hook never been installed -- it differs per hook shape, which is why it
is supplied rather than guessed. After the first failure the hook is skipped
outright: a modifier that raises once will raise every step, and thirty
tracebacks say nothing the first one did not.
"""
import traceback
def guarded(*args, **kwargs):
if key in dropped:
return neutral(*args, **kwargs)
try:
return fn(*args, **kwargs)
except Exception as exc: # noqa: BLE001 -- the whole point
if dropped.record(key, exc):
from . import log
log.warning(
key,
"failed during sampling and is now OFF for the rest of this run; "
"the run continues without it\n"
+ "".join(traceback.format_exception(exc)).rstrip())
return neutral(*args, **kwargs)
# Carried over so a guarded hook is still ours to strip.
marker = getattr(fn, TAG, None)
if marker is not None:
guarded.__dict__[TAG] = marker
return guarded
# The neutral result for each hook ComfyUI offers, by the method that installs
# it. Core knows ComfyUI's own shapes here -- not FunPack's features -- because
# "what this hook returns when it does nothing" is a fact about ComfyUI.
def _neutral_wrapper(executor, *args, **kwargs):
return executor(*args, **kwargs)
NEUTRAL = {
"add_wrapper_with_key": _neutral_wrapper,
"add_callback_with_key": lambda *a, **k: None,
"set_model_sampler_pre_cfg_function": lambda args: args["conds_out"],
"set_model_sampler_post_cfg_function": lambda args: args["denoised"],
"set_model_sampler_cfg_function": lambda args: args["input"] - (
args["uncond_denoised"] + (args["cond_denoised"] - args["uncond_denoised"]) * args["cond_scale"]),
"set_model_denoise_mask_function": lambda sigma, denoise_mask, extra_options=None: denoise_mask,
}
# Which positional argument holds the callable, for methods that take more than one.
CALLABLE_AT = {"add_wrapper_with_key": 2, "add_callback_with_key": 2}
class GuardedPatcher:
"""A ModelPatcher that guards every hook installed through it.
A module is handed one of these instead of the real patcher, so it does not
opt in to being safe -- a module that could forget to is a module that can
break the foundation. Everything else forwards untouched.
"""
def __init__(self, patcher, key: str, dropped: Dropped, guarding: bool = True):
object.__setattr__(self, "_patcher", patcher)
object.__setattr__(self, "_key", key)
object.__setattr__(self, "_dropped", dropped)
object.__setattr__(self, "_guarding", guarding)
object.__setattr__(self, "unguarded", [])
def __getattr__(self, name):
target = getattr(self._patcher, name)
if not callable(target) or not name.startswith(("set_model_", "add_wrapper", "add_callback")):
return target
if not self._guarding:
# Full control: install exactly what the module wrote, and let it
# fail where it fails. Someone asking for the raw behaviour wants
# the real traceback at the real step, not a tidy recovery.
return target
neutral = NEUTRAL.get(name)
if neutral is None:
# Honest about the limit: an unknown hook shape has no known neutral
# result, so it is installed as-is and named. Silently pretending it
# was guarded would be worse than saying it was not.
self.unguarded.append(name)
return target
index = CALLABLE_AT.get(name, 0)
def install(*args, **kwargs):
args = list(args)
if len(args) > index and callable(args[index]):
args[index] = guard(args[index], self._key, neutral, self._dropped)
return target(*args, **kwargs)
return install
def __setattr__(self, name, value):
setattr(self._patcher, name, value)