The process boundary¶
Once register_nodes() has built the proxies, every node
execution crosses a process boundary. This page is what happens on that
crossing: the shape of one call, how tensors travel, and the import rules each
side lives under.
What crosses the boundary¶
Everything that moves between the parent and a pack's processes, in one list. Details for each follow on this page or where linked.
- The worker program itself -- as source text into a temp dir, never installed (ADR-0006).
- The metadata scan -- a scan script out, a JSON payload file back:
node schemas,
ROUTES, folder registrations (register_nodes()). - Spawn configuration -- env vars: the socket address, a per-spawn auth secret, accelerator/serializer/debug settings (table below).
- A ComfyUI state snapshot at startup -- a purpose-built
sys_pathslist (the ComfyUI base dir, the pack's working dir, the env's own site-packages and the package root -- never the host'ssys.path, whose site-packages are deliberately withheld) and the parent's entire resolvedfolder_pathsstate (input/output/user dirs, the models search-path registry), pushed as the first config frame. - Node calls -- request/response JSON over the socket. V1 nodes ship the
proxy instance's
self_statedict inbound, and the mutated state comes BACK: the worker diffs the instance after the call and returnssetfor changed keys,deletedfor a realdel self.x, anddroppedwith a reason for anything it will not ship. The diff is by value against a fingerprint taken before the call, so mutating a list or dict in place is caught. It runs in afinally, so state returns even when the node raises. The four drop reasons areover_cap(aboveCOMFY_ENV_NODE_STATE_MAX_BYTES, 8 MiB),device_resident(a CUDA tensor),worker_only_type, andunpicklable. A dropped attribute becomes a named marker held worker side, never a silent truncation. Whether__init__has run is the worker's own book, not a parent flag: a fresh process re-runs it on first contact with each instance, and if it had to drop a marker from the dead process the fresh state replaces the old one entirely, otherwise the old state is overlaid. - Tensors and bulk data -- the serialization ladder: CUDA IPC, pool-FD passing, shared memory, memfd, pickle, inline JSON.
- Callbacks during a call --
report_progress(whose reply is the user-interrupt channel),request_vram_budget(whose reply carries true device-free bytes), andsend_sync/send_progress_textfrom the stand-inservermodule, which the host hands to its realPromptServer. Only from the node's own thread, inside a call. - Model events and eviction commands -- every response can piggyback
three things, not one: newly-CUDA-resident models (
_new_models), the worker's VRAM census (_vram_report: residency, allocator overhead, pinned bytes, total held) and the returned node state (_self_state_out). Any reply path that bypasses the attach step leaves the host's residency figures stale. the parent sendsmodel_to_device/model_partial_load/model_partial_unloadfrom inside ComfyUI's eviction loop (comfy-env's memory management). - HTTP requests -- a pack's
ROUTESbecome real endpoints on ComfyUI's server; the parent forwards the JSON body to the worker and maps the status back (ADR-0029). - Shared-memory lifetime acks -- a one-way
consumedframe per call releases the worker's tensor keepers (ADR-0032). - All pack output -- the worker replaces
printand hooks logging, so every line crosses the socket as alogframe and reprints as[worker:<name>]; only C-level stderr is inherited directly. - Health traffic -- idle-only ping/pong (the pong reports un-acked
keeper counts), plus a spawn-time canary echoing
torch_versionand the CUDA device UUID (mismatch demotes GPU zero-copy). - The side lane -- a second connection to the same listener, opened by
the worker after its ready frame and read by a daemon thread there, for
the three questions the host may ask mid-call:
ping,refresh_input_types,fingerprint. Own lock and monotonic ids on the host (send_side), a reply cap of a second, a five-second back-off after a timeout, and no path to a kill; a worker without one is answered"nolane"and the caller uses the main lane as before. - Crash evidence -- exit code decoded to a signal name, the
faulthandler and worker-debug log files read back by the parent, and
a startup reaper for what a dead parent left behind: stale sockets
(macOS only -- the
unix://filename carries the owning pid; Linux uses the abstract namespace and has no file), orphaned workers found by parent pid, and temp dirs no process is sitting in (ADR-0019).
One node execution¶
A call travels parent → worker and back; progress and VRAM-budget callbacks flow the other way during the call.
sequenceDiagram
participant P as Proxy (parent)
participant W as SubprocessWorker (parent)
participant K as persistent_worker (env)
participant N as Real node
P->>W: FUNCTION(kwargs)
W->>K: call over AF_UNIX socket (JSON meta + shm tensors)
K->>N: run the node
N-->>K: outputs
K-->>W: callback: report_progress / request_vram_budget
W-->>K: response
N-->>K: return value
K-->>W: outputs (shm tensors)
W-->>P: return value
What each side may import¶
_persistent_worker.pyis never imported by the parent. It crosses the boundary as source text: read fromworkers/subprocess.pyand materialized into a temp dir for the isolated interpreter (ADR-0006). The parent therefore always ships the worker source it was released with, so parent/worker version skew is structurally impossible._ipc_shared.pyexists on both sides. It is deliberately stdlib-only at module scope and is copied next to the worker script, so the worker canimport _ipc_sharedwithout comfy-env being installed in its env.SubprocessModelPatcheris the only module that imports ComfyUI at module scope -- worker-resident models participate in ComfyUI's VRAM accounting through it (see comfy-env's memory management).
Tensor serialization ladder¶
Results and inputs cross the boundary via the first applicable strategy (ADR-0005):
The numbering is by mechanism, not by precedence: for a CUDA tensor the
worker's serializer tries Pool IPC first (when the opt-in probe passed),
then legacy CUDA IPC, then falls to CPU shared memory (_worker_tensor_serializer).
| # | Strategy | Wire type | Mechanism | Copies | Constraints |
|---|---|---|---|---|---|
| 1 | CUDA IPC | CudaIPC |
reduce_tensor() / rebuild_cuda_tensor() |
zero-copy GPU | Linux only; dark on a stock ComfyUI -- unsupported under cudaMallocAsync, which ComfyUI enables by default (Zero-copy CUDA transfer) |
| 2 | Pool IPC | PoolIPC |
cudaMemPoolExportPointer + FD passing |
zero-copy GPU | experimental, default-off, Linux only (ADR-0030); the mechanism that replaces it is measured in Zero-copy CUDA transfer |
| 3 | Torch shared memory | TensorRef |
file_system strategy (/dev/shm), or file_descriptor read through /proc/<pid>/fd/<N> |
zero-copy CPU | |
| 4 | NumPy | -- | converted to torch tensor, then #3 | zero-copy CPU | |
| 5 | Pickle (last resort) | -- | pickled into a SharedMemory or memfd block |
1 copy | unregistered types (pack types belong in [types] declarations); unpicklable values raise a named error |
| 6 | Primitives | -- | inline in the JSON message | -- | small values |
The cudaMallocAsync situation
ComfyUI sets PYTORCH_CUDA_ALLOC_CONF=backend:cudaMallocAsync, which
breaks legacy CUDA IPC (reduce_tensor() raises). The _probe_cuda_ipc()
checks on both sides now exercise reduce_tensor() itself and fail
closed (a historical version tested only Event + allocation and
could misreport -- fixed, see
ADR-0005); the canary
handshake additionally verifies the production path per worker at
startup. Pool IPC (strategy 2) is the zero-copy path under
cudaMallocAsync; until it is default-on the ladder falls back to
CPU shared memory.
The spawn-time channel¶
Workers cannot import comfy_env -- the worker program crosses the boundary as source text (ADR-0006), and the isolated venv has no comfy_env installed. So when the parent spawns a worker, environment variables are the configuration channel: argv by another name, set per worker, carrying data rather than toggles.
| Env var | set by | consumed by |
|---|---|---|
COMFY_ENV_IPC_ADDR |
worker spawn | the socket rendezvous: abstract:// (Linux), unix:// (macOS), tcp://127.0.0.1: (Windows fallback). Env rather than argv on purpose -- argv is world-readable |
COMFY_ENV_IPC_AUTHKEY |
worker spawn, fresh per spawn | the worker's first frame must echo this 64-hex secret; the parent also checks the connecting peer's uid (SO_PEERCRED) before speaking the protocol (ADR-0033) |
COMFY_ENV_ACCEL_PKGS |
register_nodes() from [cuda].packages |
metadata scan's top-level-import check (accelerator rule) |
COMFY_ENV_SERIALIZER_FILES |
register_nodes() from [types] custom entries (serialization.py paths) |
worker startup, to load custom type serializers (ADR-0015) |
COMFY_ENV_PARENT_CUDA_IPC |
worker spawn, from a parent-side probe | whether the parent can import CUDA IPC handles; 0 disables worker-side export (the pair property behind the cudaMallocAsync note above) |
COMFY_CPU |
worker spawn, from ComfyUI's --cpu |
the worker's comfy.cli_args |
COMFYUI_BASE, COMFYUI_USER_DIR |
worker/scan spawn | ComfyUI source dir for sys.path; Desktop-app user-data dir for folder_paths |
COMFYUI_ISOLATION_WORKER=1 |
every worker/scan spawn | reentry guard: a worker never isolates again |
COMFY_ENV_POOL_IPC, COMFY_ENV_DEBUG_* |
settings | pool-IPC opt-in and debug categories, parsed by the worker directly |
COMFY_ENV_HOST_ARGS |
worker spawn, from mirrored_args |
the host's resolved CLI flags as JSON, applied before the worker imports comfy because the memory-relevant ones are read once at import |
COMFY_ENV_EXTRA_RESERVED_VRAM |
worker spawn | the host's reserve at spawn time. Distinct from ComfyUI's identically named constant, and consumed only by the worker's dtype heuristic at model creation, where the choice is permanent |
COMFY_ENV_AIMDO_VERSION |
worker spawn | the host's comfy-aimdo version, so the worker can report skew rather than guess |
COMFY_ENV_AIMDO_HEADROOM |
worker spawn, from --vram-headroom |
the per-device headroom the host passed to init_devices |
COMFY_ENV_AIMDO_SIMPLE_HEADROOM |
worker spawn, from --reserve-vram |
the process-wide simple_vram_headroom seed |
COMFY_ENV_AIMDO_NVML |
worker spawn | whether NVML pressure is on, mirroring --disable-nvml-pressure |
COMFY_ENV_WORKER_AIMDO |
worker spawn, "1" or "0" following the host's comfy.memory_management.aimdo_enabled |
which memory manager the worker resolves to: the worker follows the host rather than probing for itself (memory_manager.py) |
COMFY_ENV_HOST_LOG_LEVEL |
worker spawn, from the host's root logger level | the worker's logging.root.setLevel, so it admits the same records the host does (comfy-env's logging) |
KMP_DUPLICATE_LIB_OK, PYTHONIOENCODING |
env construction | two libraries' worth of scar tissue: duplicate OpenMP runtimes in one process, and Windows console encoding |
A pack's [env_vars] outranks some of the rows above
A pack's [env_vars] lands in env first (wrap.py builds the dict,
subenv.build_isolation_env merges it), and the host-derived memory
and CLI writes are then guarded if NAME not in env, so a value pinned
in a pack's config wins over them. That is deliberate, and it makes
[env_vars] a lever rather than only a setting: a pack can pin
COMFY_ENV_MIRROR_ARGS=0 and switch off the host CLI flag mirror for
itself, or pin an aimdo headroom that disagrees with the host's.
Guarded -- [env_vars] wins |
Unconditional -- overwrites [env_vars] |
|---|---|
COMFY_ENV_AIMDO_VERSION, COMFY_ENV_WORKER_AIMDO, COMFY_ENV_AIMDO_HEADROOM, COMFY_ENV_AIMDO_SIMPLE_HEADROOM, COMFY_ENV_AIMDO_NVML, COMFY_ENV_HOST_ARGS, COMFY_CPU, COMFY_ENV_EXTRA_RESERVED_VRAM |
COMFY_ENV_IPC_ADDR, COMFY_ENV_IPC_AUTHKEY, COMFY_ENV_PARENT_CUDA_IPC, COMFY_ENV_HOST_LOG_LEVEL, COMFYUI_BASE, COMFYUI_USER_DIR, COMFYUI_ISOLATION_WORKER, COMFY_ENV_ACCEL_PKGS, COMFY_ENV_SERIALIZER_FILES |
The split is not arbitrary: the right-hand column is the transport,
the identity of the worker's own ComfyUI, and the host's log level
(written last, unguarded). A pack that could pin
COMFY_ENV_IPC_AUTHKEY or COMFYUI_BASE would not be configuring
itself, it would be pointing the worker somewhere else. Platform
scaffolding follows its own rule -- KMP_DUPLICATE_LIB_OK is
unconditional on Windows and setdefault on macOS (subenv.py).
None of these are user settings: set what you need in
the settings reference and the parent forwards the right
things. The spawn also shapes the environment: PYTHONPATH /
PYTHONSTARTUP / PYTHONUSERBASE / PYTHONHOME are scrubbed and
PYTHONNOUSERSITE=1 set, so nothing from the host Python leaks into the
worker's import space; platform library paths (LD_LIBRARY_PATH,
DYLD_FALLBACK_LIBRARY_PATH, win32 PATH) are set instead.
Right after the socket handshake, one config frame crosses before any
call: a sys_paths list the parent builds for the worker (the ComfyUI base
dir, the pack's working dir, then the env's own site-packages and the package
root from wrap.py -- the host's sys.path is deliberately not shipped,
because host site-packages would leak the host's C-extension packages into an
env that has its own torch) and the parent's entire resolved folder_paths
state -- input/output/temp/user directories, models_dir and the whole models
search-path registry -- so the worker's folder_paths answers match the
parent's. The worker
replies ready, optionally passes a CUDA mem-pool file descriptor
(SCM_RIGHTS), and answers a canary echo that carries its torch_version and
CUDA device UUID -- a mismatch demotes GPU zero-copy for that worker.
The channels a single call doesn't show¶
- HTTP routes. A module-level
ROUTESlist, collected at scan time, becomes real aiohttp handlers on ComfyUI's own server. A request's JSON body crosses to the worker as a module call; the handler's dict comes back as the response, with_statusmapped to the HTTP status (ADR-0029, example in register_nodes()). - Model events, inbound. The worker hooks
nn.Module.to()/.cuda()globally, and any response frame -- including errors and route replies -- can piggyback_new_models: id, size, kind, device for each model that landed on CUDA. The parent drains this intoSubprocessModelPatchers registered in ComfyUI'scurrent_loaded_models. - Eviction commands, outbound. From inside ComfyUI's
free_memoryloop, the patcher sendsmodel_to_device/model_partial_load/model_partial_unload; replies report the bytes actually moved. These frames are answered even while the worker is blocked waiting on its own callback, so eviction cannot deadlock against a running node (comfy-env's memory management). - Interrupts. There is no cancel frame. A user interrupt raises inside
the parent's
report_progresshandler and travels back as the error reply to the worker's own callback, which the worker converts to an interruption of the running node. - The
consumedack. After the parent has fully read a reply, it sends one-way{"type": "consumed", call_id}; the worker then releases the tensor/shm keepers backing that reply. The TTL sweep is only the crash fallback (ADR-0032). - Pack output. The worker replaces
builtins.printand installs a root-logger handler, so every print and log record crosses as an unsolicitedlogframe and reprints as[worker:<name>]. C-level output does not cross: native stdout goes to DEVNULL, native stderr is inherited. Full detail, including what falls on the floor, in comfy-env's logging. - Health. Workers idle for more than 60 s get a
ping; thepongreports how many un-acked keepers they still hold.
Crash and teardown¶
Teardown is a shutdown frame, a 5 s grace, then a kill of the worker's whole process group; the worker's
temp dir is removed. A crash leaves evidence the parent reads back
(_worker_exit_diagnostic): the exit code (a negative code is decoded to a
POSIX signal name via signal.Signals; nothing else is decoded), and the
last 20 lines of the worker's debug log and faulthandler dump
($TMPDIR/comfy_worker_* -- the faulthandler basename is a shared constant
because it drifted once). The watchdog's periodic all-thread stack dumps,
when enabled, go to their own file and are not read back -- open it
yourself. The next startup reaps what a crashed parent left behind
(_cleanup_stale_workers): on macOS the unix:// socket filename embeds
the owning pid, so a socket whose owner is dead is unlinked (Linux binds in
the abstract namespace and leaves no file); worker processes are found by
command line and killed when their parent pid is gone; comfyui_pvenv_*
temp dirs are removed when no live process has them in its cwd or command
line (ADR-0019).
Only the pool's replacement of a dead worker bumps the worker generation;
the in-place restart _ensure_started performs after a failed health ping
fires _on_restart (which invalidates the old patchers) without a new
generation. Either way, stale patchers from the old worker are quarantined
as already-offloaded rather than evicted.