Skip to content

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.

  1. The worker program itself -- as source text into a temp dir, never installed (ADR-0006).
  2. The metadata scan -- a scan script out, a JSON payload file back: node schemas, ROUTES, folder registrations (register_nodes()).
  3. Spawn configuration -- env vars: the socket address, a per-spawn auth secret, accelerator/serializer/debug settings (table below).
  4. A ComfyUI state snapshot at startup -- a purpose-built sys_paths list (the ComfyUI base dir, the pack's working dir, the env's own site-packages and the package root -- never the host's sys.path, whose site-packages are deliberately withheld) and the parent's entire resolved folder_paths state (input/output/user dirs, the models search-path registry), pushed as the first config frame.
  5. Node calls -- request/response JSON over the socket. V1 nodes ship the proxy instance's self_state dict inbound, and the mutated state comes BACK: the worker diffs the instance after the call and returns set for changed keys, deleted for a real del self.x, and dropped with 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 a finally, so state returns even when the node raises. The four drop reasons are over_cap (above COMFY_ENV_NODE_STATE_MAX_BYTES, 8 MiB), device_resident (a CUDA tensor), worker_only_type, and unpicklable. 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.
  6. Tensors and bulk data -- the serialization ladder: CUDA IPC, pool-FD passing, shared memory, memfd, pickle, inline JSON.
  7. Callbacks during a call -- report_progress (whose reply is the user-interrupt channel), request_vram_budget (whose reply carries true device-free bytes), and send_sync / send_progress_text from the stand-in server module, which the host hands to its real PromptServer. Only from the node's own thread, inside a call.
  8. 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 sends model_to_device / model_partial_load / model_partial_unload from inside ComfyUI's eviction loop (comfy-env's memory management).
  9. HTTP requests -- a pack's ROUTES become real endpoints on ComfyUI's server; the parent forwards the JSON body to the worker and maps the status back (ADR-0029).
  10. Shared-memory lifetime acks -- a one-way consumed frame per call releases the worker's tensor keepers (ADR-0032).
  11. All pack output -- the worker replaces print and hooks logging, so every line crosses the socket as a log frame and reprints as [worker:<name>]; only C-level stderr is inherited directly.
  12. Health traffic -- idle-only ping/pong (the pong reports un-acked keeper counts), plus a spawn-time canary echoing torch_version and the CUDA device UUID (mismatch demotes GPU zero-copy).
  13. 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.
  14. 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.py is never imported by the parent. It crosses the boundary as source text: read from workers/subprocess.py and 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.py exists on both sides. It is deliberately stdlib-only at module scope and is copied next to the worker script, so the worker can import _ipc_shared without comfy-env being installed in its env.
  • SubprocessModelPatcher is 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 ROUTES list, 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 _status mapped 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 into SubprocessModelPatchers registered in ComfyUI's current_loaded_models.
  • Eviction commands, outbound. From inside ComfyUI's free_memory loop, the patcher sends model_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_progress handler 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 consumed ack. 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.print and installs a root-logger handler, so every print and log record crosses as an unsolicited log frame 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; the pong reports 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.