How ComfyUI handles exceptions¶
A node that throws becomes a red node and an error dialog. A Cancel press becomes an exception too — but one designed so that node code cannot accidentally catch it. The second half is the part that matters across a process boundary.
Python background: two kinds of exception¶
Every Python exception inherits from BaseException. Almost all of them —
ValueError, RuntimeError, FileNotFoundError, anything you would raise
yourself — inherit from Exception, one level down:
BaseException
├── Exception <- "something went wrong" caught by `except Exception`
│ ├── RuntimeError
│ ├── ValueError
│ └── ...
├── KeyboardInterrupt <- "stop" NOT caught by `except Exception`
├── SystemExit <- "exit" NOT caught by `except Exception`
└── GeneratorExit
The split is deliberate. except Exception is the idiom for "handle any error
and carry on". The three direct children of BaseException are things you
must not carry on from — a user hitting Ctrl-C, a call to sys.exit().
They sail through except Exception untouched, so a program that swallows all
errors can still be stopped.
A library that wants its own "stop everything" signal to have the same
property subclasses BaseException directly. That is what ComfyUI does.
A node that throws¶
The executor wraps each node call and catches two things
(execution.py):
except comfy.model_management.InterruptProcessingException as iex:
logging.info("Processing interrupted")
return (ExecutionResult.FAILURE, {"node_id": real_node_id}, iex)
except Exception as ex:
typ, _, tb = sys.exc_info()
exception_type = full_type_name(typ) # e.g. "builtins.FileNotFoundError"
...
logging.error(f"!!! Exception during processing !!! {ex}")
logging.error(traceback.format_exc())
if comfy.model_management.is_oom(ex):
tips = "This error means you ran out of memory on your GPU..."
comfy.model_management.unload_all_models()
elif isinstance(ex, RuntimeError) and "mat1 and mat2 shapes" in str(ex) and "Sampler" in class_type:
tips = "...make sure the correct CLIP file(s) and type is selected."
The second clause is the ordinary path. It records the exception's fully
qualified type name, the message, the traceback and the formatted inputs,
and ships them to the frontend as execution_error. The frontend paints the
node red and shows the dialog.
Two things hang off exception_type and isinstance here: the OOM detection
that unloads every model, and the "wrong CLIP" tip keyed on RuntimeError.
Anything downstream that special-cases an error — the frontend, ComfyUI-Manager
heuristics, telemetry — keys on that type name.
Cancel is an exception, on purpose¶
The Cancel button is POST /interrupt (server.py), which does one
thing: set a module-level flag.
# comfy/model_management.py
class InterruptProcessingException(BaseException): # <- not Exception
pass
interrupt_processing = False
def interrupt_current_processing(value=True):
with interrupt_processing_mutex:
interrupt_processing = value
def throw_exception_if_processing_interrupted():
with interrupt_processing_mutex:
if interrupt_processing:
interrupt_processing = False # <- cleared BEFORE raising
raise InterruptProcessingException()
Nothing is interrupted by the button. The flag just sits there until something polls it. And the polls are everywhere:
| Poll site | Granularity |
|---|---|
nodes.py before_node_execution |
once per node |
comfy/ops.py run_every_op |
every Linear and Conv forward — 13 call sites in ops.py |
comfy/sd.py |
every tile of a tiled VAE encode/decode |
comfy/context_windows.py |
every context window |
comfy/ldm/minimax_music/ar.py |
every autoregressive step |
So a running sampler checks the flag many times per step. The moment it sees
it, throw_exception_if_processing_interrupted raises, and the exception
unwinds through the model code, through the node, through the executor's
first except clause, and the prompt stops.
Why BaseException¶
Look at what the exception has to pass through on the way out: the node's own code. Node authors write things like
for tile in tiles:
try:
result = process(tile)
except Exception:
continue # skip a bad tile, keep going
If InterruptProcessingException inherited from Exception, that loop would
catch it, log nothing, and continue to the next tile. The user pressed Cancel;
the node kept running. Making it a BaseException means except Exception
cannot see it, for the same reason it cannot see Ctrl-C.
Why the flag is cleared before raising¶
interrupt_processing = False happens inside the check, before the raise.
That makes the interrupt one-shot: whoever polls first consumes it. It
prevents the next prompt from being cancelled by a stale flag — but it also
means that if the raised exception is somehow swallowed, the cancel is gone.
There is no second chance; the flag is already False.
Upstream never has to worry about that, because BaseException guarantees the
exception is not swallowed by ordinary code.
What this means for a second process¶
Three separate facts, the first two of which are gaps on their own:
- The flag is a module global in the host's
comfy.model_management. A worker imports its own copy of that module, with its own flag, which nothing ever sets. Every poll site in the worker — everyrun_every_op, every tiled VAE step — readsFalseforever. - The only way a cancel reaches a worker is as a reply to something the
worker sent. If the node drives a
ProgressBar, the progress callback's reply can say "stop". If it does not, there is no channel. - What the worker raises on that reply has to be a
BaseExceptiontoo, and the host must not spend the flag when it answers. comfy-env's worker-side_InterruptedErrorsubclassesBaseException, so the tile loop above lets it through; and the host reads the flag withprocessing_interrupted()(a read that does not clear it) rather thanthrow_exception_if_processing_interrupted(), so a swallowed reply would not lose the click — upstream's own per-node check still finds the flag set.InterruptRequested, the host-sideRuntimeError, never passes through node code: it is raised in the progress handler and caught by the callback dispatcher that called it.
How comfy-env handles it covers each.
See also¶
- How comfy-env handles it
- Worker lifecycle — what happens when a node call is killed rather than cancelled