Files
olivv-cs-ComfyUI-FunPack/modules/sampling/tests/test_cancel.py
T
Claude 3adebb731f Rename FunPack to FunUI (5.0.3 Classy Chopper): /fun endpoint, wink logo, load-time migration of old names
Old project/workflow names and browser settings migrate on load; /funpack redirects to /fun.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016acRss69eH1235eRdFTZHY
2026-10-25 13:11:03 +00:00

182 lines
6.4 KiB
Python

"""Cancelling a run, and what it must leave behind: nothing.
Stopping a generation is ordinary -- a bad seed, a wrong prompt, a rental
minute -- so it is not an error path that can be left approximate. What matters
is that the next run starts clean, because a leak here is invisible: the run
after a cancel just behaves slightly wrong.
"""
import pytest
@pytest.fixture(autouse=True)
def _needs_comfy(comfyui):
"""Imports comfy."""
@pytest.fixture
def patcher():
import torch
from comfy.model_patcher import ModelPatcher
class Stub(torch.nn.Module):
pass
return ModelPatcher(Stub(), load_device=torch.device("cpu"),
offload_device=torch.device("cpu"))
@pytest.fixture
def registry(monkeypatch):
from core import registry as registry_mod
fake = registry_mod.Registry()
monkeypatch.setattr(registry_mod, "current", lambda rescan=False: fake)
return fake
def _spec(module_id, install):
from core.contract import ModuleSpec
return ModuleSpec(id=module_id, title=module_id, mount="", stage="sampling",
provides={"modifier": install})
def test_a_cancel_reaches_the_top_and_is_not_absorbed(patcher, registry):
"""The guard catches Exception, and ComfyUI's cancel is a BaseException, so
it passes through. If that ever changed, stop would stop nothing."""
from comfy.model_management import InterruptProcessingException
from comfy.patcher_extension import WrappersMP
from modules.sampling.modifiers.nodes import FunUILoadModifiers
def install(target, values, key):
def cancels(args):
raise InterruptProcessingException()
target.set_model_sampler_pre_cfg_function(cancels)
return "on"
registry.specs["m"] = _spec("m", install)
patched, _ = FunUILoadModifiers.execute(patcher).result
hook = patched.model_options["sampler_pre_cfg_function"][0]
with pytest.raises(InterruptProcessingException):
hook({"conds_out": ["a", "b"]})
def test_a_cancelled_run_leaves_nothing_on_the_shared_model(patcher, registry):
"""The model everyone else holds must be exactly as it was. Installing on a
clone is what makes this true, and a cancel must not change that."""
from comfy.model_management import InterruptProcessingException
from comfy.patcher_extension import WrappersMP
from modules.sampling.modifiers.nodes import FunUILoadModifiers
def install(target, values, key):
target.add_wrapper_with_key(WrappersMP.SAMPLER_SAMPLE, key,
lambda e, *a, **k: e(*a, **k))
return "on"
registry.specs["m"] = _spec("m", install)
for _ in range(5):
patched, _ = FunUILoadModifiers.execute(patcher).result
try:
raise InterruptProcessingException() # cancelled mid-run
except InterruptProcessingException:
pass
assert patcher.wrappers.get(WrappersMP.SAMPLER_SAMPLE, {}) == {}
assert not [k for k in patcher.model_options.get("sampler_pre_cfg_function", [])]
def test_a_cancel_does_not_mark_a_modifier_as_dropped(patcher, registry):
"""A cancelled modifier did not fail. Recording it would disable it on the
next run for something the user did on purpose."""
from comfy.model_management import InterruptProcessingException
from core import patching
dropped = patching.Dropped()
def cancels(args):
raise InterruptProcessingException()
guarded = patching.guard(cancels, "funui.m",
lambda args: args["conds_out"], dropped)
with pytest.raises(InterruptProcessingException):
guarded({"conds_out": "x"})
assert "funui.m" not in dropped and not dropped
def test_the_next_run_starts_with_a_clean_record(patcher, registry):
"""The record travels on the model, which ComfyUI may hand back from its
cache -- so a run cancelled after a modifier failed must not leave that
modifier disabled for the next one."""
from core import patching
from modules.sampling.sampler.nodes import FunUISampler
dropped = patching.Dropped()
dropped.record("funui.m", RuntimeError("failed last time"))
patcher.funui_dropped = dropped
# What the sampler does at the start of every generation.
found = getattr(patcher, "funui_dropped", None)
found.clear()
assert "funui.m" not in found
def test_alg_puts_the_sharp_anchor_back_when_a_run_is_cancelled():
"""ALG swaps the pinned anchor per step. A cancel between steps must not
leave the blurred copy pinned on a denoiser that outlives the run."""
import torch
import comfy.samplers
from comfy.model_management import InterruptProcessingException
from modules.sampling import alg
sharp = torch.randn(1, 3, 4, 16, 16)
holder = {}
def sampler_function(model_k, x, sigmas, **kwargs):
model_k(x, torch.tensor([0.9]), denoise_mask=None)
holder["denoiser"] = model_k._inner
raise InterruptProcessingException()
captured = {}
class Patcher:
def add_wrapper_with_key(self, kind, key, wrapper):
captured["wrapper"] = wrapper
alg.install(Patcher(), {"enabled": True, "strength": 4.0, "until_sigma": 0.6},
key="funui.alg")
class Guider:
def __init__(self):
class ModelSampling:
sigma_max = 1.0
def noise_scaling(self, sigma, noise, latent_image, max_denoise=False):
return noise
def inverse_noise_scaling(self, sigma, x):
return x
class InnerModel:
model_sampling = ModelSampling()
def scale_latent_inpaint(self, **kw):
return kw["latent_image"]
self.inner_model = InnerModel()
def __call__(self, x, sigma, model_options={}, seed=None):
return x
class Executor:
class_obj = comfy.samplers.KSAMPLER(sampler_function)
def __call__(self, *a, **k):
# The end of a real chain: the sampler's own sample(), running the
# sampler_function ALG swapped in for this call.
return self.class_obj.sample(*a, **k)
with pytest.raises(InterruptProcessingException):
captured["wrapper"](Executor(), Guider(), torch.tensor([0.9]), {}, None,
torch.zeros_like(sharp), sharp, torch.ones_like(sharp), True)
assert torch.equal(holder["denoiser"].latent_image, sharp), (
"a cancelled run left the loosened anchor pinned")