39 KiB
Refactor Plan 2 — ComfyUI-Distributed
All paths relative to custom_nodes/ComfyUI-Distributed/.
Read every file listed under "Read first" before making any edits.
After every phase, run python -c "import custom_nodes.ComfyUI-Distributed" (or equivalent import check) to confirm nothing is broken.
Phase 1 — Correctness / Data Integrity (do these first)
Task 1.1 — Fix lock contention in USDU requeue path
Problem: _check_and_requeue_timed_out_workers in utils/usdu_managment.py calls
await probe_worker(...) (a network request) while holding distributed_tile_jobs_lock.
Any concurrent heartbeat, submit_tiles, or request_image that also needs the lock is blocked
for the full probe timeout.
Read first: utils/usdu_managment.py (find _check_and_requeue_timed_out_workers),
utils/network.py (confirm probe_worker signature).
Changes:
In _check_and_requeue_timed_out_workers:
-
Snapshot under lock, then release. Collect the list of workers to probe while holding the lock, then exit the lock context before any
await probe_worker(...)call.Before (pseudocode):
async with prompt_server.distributed_tile_jobs_lock: job_data = ... for worker_id, last_seen in timed_out: result = await probe_worker(url, timeout=...) # WRONG: network call under lock if not result: requeue(...)After:
# Step 1: snapshot under lock async with prompt_server.distributed_tile_jobs_lock: job_data = prompt_server.distributed_pending_tile_jobs.get(multi_job_id) if not isinstance(job_data, BaseJobState): return 0 timed_out_snapshot = [ (wid, ts) for wid, ts in job_data.worker_status.items() if (now - float(ts)) > HEARTBEAT_TIMEOUT ] assigned_snapshot = dict(job_data.assigned_to_workers) # Step 2: probe outside lock workers_to_requeue = [] for worker_id, last_seen in timed_out_snapshot: task_ids = assigned_snapshot.get(worker_id, []) wrec = _find_worker_record(worker_id) if wrec: result = await probe_worker(build_worker_url(wrec), timeout=2.0) if result is None: workers_to_requeue.append((worker_id, task_ids)) else: workers_to_requeue.append((worker_id, task_ids)) # Step 3: re-acquire lock to apply requeue decisions if not workers_to_requeue: return 0 async with prompt_server.distributed_tile_jobs_lock: job_data = prompt_server.distributed_pending_tile_jobs.get(multi_job_id) if not isinstance(job_data, BaseJobState): return 0 requeued = 0 for worker_id, task_ids in workers_to_requeue: for task_id in task_ids: await job_data.pending_tasks.put(task_id) requeued += 1 job_data.assigned_to_workers.pop(worker_id, None) job_data.worker_status.pop(worker_id, None) return requeued -
Add a private helper
_find_worker_record(worker_id)that loads config and returns the worker dict (orNone). This keeps the probe-outside-lock section clean.
Verify: The function should now only hold the lock during pure dict reads/writes.
No await expressions should appear inside any async with distributed_tile_jobs_lock: block
in this function.
Task 1.2 — Fix non-idempotent client retries
Problem: web/apiClient.js retries every request unconditionally. Mutating endpoints
(/distributed/queue, launch, stop, update, delete, startTunnel, stopTunnel) can execute
twice on a transient network error, causing duplicate jobs or double-start/stop.
Read first: web/apiClient.js (full file).
Changes:
-
Add a
retryoption to the internalrequestfunction. Defaulttruefor safe reads,falsefor all mutating calls.const request = async (endpoint, options = {}, { retries = TIMEOUTS.MAX_RETRIES, retry = true } = {}) => { const maxAttempts = retry ? retries : 1; let lastError; let delay = TIMEOUTS.RETRY_DELAY; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { const response = await fetch(...); if (!response.ok) { ... } return await response.json(); } catch (error) { lastError = error; if (attempt < maxAttempts - 1) { await new Promise(resolve => setTimeout(resolve, delay)); delay *= 2; } } } throw lastError; }; -
Set
retry: falsefor all mutating endpoints:launchWorker—request('/distributed/launch_worker', { method: 'POST', ... }, { retry: false })stopWorker— samequeueDistributed— sameupdateWorker— samedeleteWorker— sameupdateSetting— sameupdateMaster— samestartTunnel— samestopTunnel— samedispatchToWorker— same
-
Add idempotency key support for
queueDistributed. The caller already generates atrace_execution_id; pass it as anX-Idempotency-Keyheader so a future backend can deduplicate if desired.queueDistributed: (payload) => request('/distributed/queue', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(payload.trace_execution_id ? { 'X-Idempotency-Key': payload.trace_execution_id } : {}), }, body: JSON.stringify(payload), }, { retry: false }), -
Leave
probeWorker,checkStatus,checkMultipleStatuses,getManagedWorkers,getWorkerLog,getTunnelStatus,getNetworkInfoasretry: true— these are read-only and safe to retry.
Verify: queueDistributed, launchWorker, stopWorker, startTunnel, stopTunnel
all pass { retry: false }. No mutating call uses the default retry loop.
Task 1.3 — Make config writes atomic and race-safe
Problem: utils/config.py save_config() writes directly to the config file with
json.dump(..., f). A crash mid-write corrupts the file. Concurrent requests from
api/config_routes.py do independent load–modify–save cycles with no locking, creating
a race condition.
Read first: utils/config.py (full), api/config_routes.py (full).
Changes in utils/config.py:
-
Add a module-level
asyncio.Lock:import asyncio _config_lock = asyncio.Lock() -
Rewrite
save_configto write atomically using a temp file +os.replace:def save_config(config): config_path = _get_config_path() tmp_path = config_path + '.tmp' try: with open(tmp_path, 'w') as f: json.dump(config, f, indent=2) os.replace(tmp_path, config_path) except Exception: try: os.unlink(tmp_path) except OSError: pass raise -
Expose a new async context manager
config_transaction()for use by routes:from contextlib import asynccontextmanager @asynccontextmanager async def config_transaction(): """Acquire config lock, yield loaded config, save on clean exit.""" async with _config_lock: config = load_config() yield config save_config(config)
Changes in api/config_routes.py:
-
Replace every
load_config()/save_config(config)pair in route handlers withasync with config_transaction() as config:. Each handler becomes:async with config_transaction() as config: # mutate config in place # no explicit save needed — context manager handles itApply to:
update_worker_endpoint,delete_worker_endpoint,update_setting_endpoint,update_master_endpoint, and the bulkPOST /distributed/confighandler. -
The read-only
GET /distributed/configroute does not need the lock — leave it usingload_config()directly.
Verify: No save_config call outside of the config_transaction context manager in
route handlers. Grep for save_config( in api/config_routes.py and confirm zero results.
Phase 2 — Structural Splits
Task 2.1 — Split utils/usdu_managment.py god module
Problem: utils/usdu_managment.py mixes job-state CRUD, timeout/requeue policy,
multipart payload parsing, conditioning utilities, and HTTP route registrations in one ~630-line file.
Read first: utils/usdu_managment.py (full), upscale/job_state.py, upscale/job_models.py,
api/worker_routes.py, upscale/worker_comms.py (check _send_heartbeat_to_master import).
Target structure:
upscale/
job_store.py ← job init + ensure_tile_jobs_initialized + MAX_PAYLOAD_SIZE
job_timeout.py ← heartbeat check, requeue logic (_check_and_requeue_timed_out_workers)
payload_parsers.py ← _parse_tiles_from_form, any other multipart parsers
api/
usdu_routes.py ← all HTTP route handlers from usdu_managment.py
utils/
usdu_managment.py ← thin shim: re-exports for backward compat + _send_heartbeat_to_master
Step-by-step:
-
Create
upscale/job_store.py. Move fromusdu_managment.py:ensure_tile_jobs_initialized()_init_job_queue(multi_job_id, mode, batch_size, num_tiles_per_image, batched_static)init_dynamic_job(...)init_static_job_batched(...)_drain_results_queue(multi_job_id)- The
MAX_PAYLOAD_SIZEconstant Imports needed:asyncio,server,upscale.job_models,utils.logging.
-
Create
upscale/job_timeout.py. Move fromusdu_managment.py(after Task 1.1 changes are applied):_check_and_requeue_timed_out_workers(multi_job_id, batch_size)(already refactored in Task 1.1)_find_worker_record(worker_id)(new helper from Task 1.1) Imports needed:utils.network,utils.config,utils.constants,upscale.job_models,upscale.job_store(forensure_tile_jobs_initialized).
-
Create
upscale/payload_parsers.py. Move fromusdu_managment.py:_parse_tiles_from_form(request)and any helper it uses Imports needed:aiohttp,PIL,utils.image.
-
Create
api/usdu_routes.py. Move fromusdu_managment.pyall@server.PromptServer.instance.routes.*decorated functions:heartbeat_endpoint(POST /distributed/heartbeat)submit_tiles_endpoint(POST /distributed/submit_tiles)submit_image_endpoint(POST /distributed/submit_image)request_image_endpoint(POST /distributed/request_image)job_status_endpoint(GET /distributed/job_status) Import from new modules above instead ofusdu_managment.
-
Rewrite
utils/usdu_managment.pyas a thin shim. Keep only:_send_heartbeat_to_master(...)(used byupscale/worker_comms.py)MAX_PAYLOAD_SIZEre-export fromupscale.job_store- Re-exports of
_check_and_requeue_timed_out_workersandensure_tile_jobs_initializedfor any callers that import from here directly.
# utils/usdu_managment.py — backward-compat shim from ..upscale.job_store import ( MAX_PAYLOAD_SIZE, ensure_tile_jobs_initialized, _init_job_queue, init_dynamic_job, init_static_job_batched, ) from ..upscale.job_timeout import _check_and_requeue_timed_out_workers async def _send_heartbeat_to_master(...): ... # keep this here — it's worker-side only -
Register
api/usdu_routes.pyin__init__.pyordistributed.py. Find whereusdu_managmentis currently imported to trigger route registration, and add an import ofapi.usdu_routesin the same place. -
Update all import sites. Grep for
from ..utils.usdu_managment importandfrom .utils.usdu_managment importacross all files. Update each to import from the appropriate new module.
Verify: python -m py_compile on all modified files. Grep confirms no direct
usdu_managment imports remain except the shim itself and worker_comms.py
(which imports _send_heartbeat_to_master).
Task 2.2 — Decompose CloudflareTunnelManager
Problem: utils/cloudflare.py is a 387-line class handling binary management, state
persistence, log streaming, process lifecycle, and status reporting.
Read first: utils/cloudflare.py (full), api/tunnel_routes.py, distributed.py
(check where CloudflareTunnelManager is imported/instantiated).
Target structure:
utils/
cloudflare/
__init__.py ← re-exports CloudflareTunnelManager for backward compat
binary.py ← binary discovery and download
state.py ← config persistence, previous-host restoration
process_reader.py ← thread-based output reader, URL capture, log buffer
tunnel.py ← CloudflareTunnelManager: start/stop lifecycle, status
Step-by-step:
-
Create
utils/cloudflare/package. -
Create
utils/cloudflare/binary.py. Move fromcloudflare.py:_get_cloudflared_dir(),_get_binary_path()(or equivalent path helpers)_get_platform_binary_name()_download_cloudflared()/ensure_binary()— everything related to finding or downloading thecloudflaredbinary. Export a singleensure_binary() -> strfunction that returns the path to the binary.
-
Create
utils/cloudflare/state.py. Move fromcloudflare.py:_save_tunnel_url_to_config(url)/_restore_previous_master_host()/_clear_tunnel_state()— all code that reads/writes the config file to persist or restore tunnel URLs and master host settings. This module imports fromutils.config.
-
Create
utils/cloudflare/process_reader.py. Move fromcloudflare.py:- The
_read_outputthread /_OutputReaderthread class that readscloudflaredstdout line-by-line, extracts the tunnel URL via regex, appends torecent_logs, and sets the URL event. Export:ProcessReaderclass (or equivalent) withstart(process)/stop()/get_url()/get_recent_logs()methods. Keep theLOG_BUFFER_SIZEconstant here (or move toutils/constants.pyper Task 5.2).
- The
-
Create
utils/cloudflare/tunnel.py. This keeps theCloudflareTunnelManagerclass but now delegates to the modules above:from .binary import ensure_binary from .state import save_tunnel_url, restore_previous_host, clear_tunnel_state from .process_reader import ProcessReader class CloudflareTunnelManager: def __init__(self): ... async def start(self): ... # calls ensure_binary, starts process, starts ProcessReader async def stop(self): ... # stops ProcessReader, stops process, calls clear/restore def get_status(self): ... def get_recent_logs(self): ... -
Create
utils/cloudflare/__init__.py:from .tunnel import CloudflareTunnelManager __all__ = ['CloudflareTunnelManager'] -
Delete
utils/cloudflare.py(the original monolithic file) after confirming all import sites resolve toutils.cloudflare(the package), which works identically due to__init__.py.
Verify: from utils.cloudflare import CloudflareTunnelManager resolves. All tunnel
routes still function. TUNNEL_START_TIMEOUT constant is accessible (either via
utils.constants after Task 5.2, or defined in tunnel.py).
Phase 3 — Reliability / Leak Fixes
Task 3.1 — Fix UI modal lifecycle leak
Problem: web/ui.js adds a keydown listener when the log modal opens but never
removes it on close or backdrop-click, leaking one listener per open.
Read first: web/ui.js (full — focus on log modal creation, open/close methods,
and the keydown listener).
Changes:
-
Create
web/ui/logModal.js(file already has aweb/ui/directory). Extract the log modal HTML construction, open/close logic, and auto-refresh timer into a module with explicit lifecycle:export function createLogModal() { let _keydownHandler = null; let _refreshTimer = null; let _modalEl = null; function mount(container, { onClose, fetchLog }) { // build modal DOM _modalEl = buildModalDOM(); container.appendChild(_modalEl); // store listener reference so we can remove it _keydownHandler = (e) => { if (e.key === 'Escape') unmount(); }; document.addEventListener('keydown', _keydownHandler); // backdrop click _modalEl.querySelector('.modal-backdrop') .addEventListener('click', unmount); // auto-refresh if checkbox checked _refreshTimer = setInterval(() => { if (isAutoRefresh()) fetchLog(); }, 2000); } function unmount() { if (_keydownHandler) { document.removeEventListener('keydown', _keydownHandler); _keydownHandler = null; } if (_refreshTimer) { clearInterval(_refreshTimer); _refreshTimer = null; } _modalEl?.remove(); _modalEl = null; } return { mount, unmount }; } -
In
web/ui.js, replace the inline modal construction with:import { createLogModal } from './ui/logModal.js'; // ... showLogModal(workerId) { const modal = createLogModal(); modal.mount(document.body, { onClose: () => modal.unmount(), fetchLog: () => this._fetchAndDisplayLog(workerId), }); } -
Move any inline
style="..."attributes from the modal DOM intodistributed.cssclass rules (e.g.,.log-modal,.log-modal__body,.log-modal__backdrop).
Verify: Open log modal → close with Escape → open again. Confirm only one keydown
listener is registered at a time (check with DevTools → Event Listeners on document).
Phase 4 — Deduplication
Task 4.1 — Extract shared trace logging helpers
Problem: Three copies of identical _trace_prefix / _trace_debug / _trace_info
helpers exist in api/orchestration/dispatch.py:11-20,
api/orchestration/media_sync.py:24-33, api/queue_orchestration.py:32-41.
Read first: All three files (confirm the copies are byte-for-byte identical).
Changes:
-
Create
utils/trace_logger.py:from .logging import debug_log, log def trace_prefix(trace_execution_id: str) -> str: return f"[Distributed][exec:{trace_execution_id}]" def trace_debug(trace_execution_id: str, message: str) -> None: debug_log(f"{trace_prefix(trace_execution_id)} {message}") def trace_info(trace_execution_id: str, message: str) -> None: log(f"{trace_prefix(trace_execution_id)} {message}") -
In each of the three files, delete the local definitions and add:
from ..utils.trace_logger import trace_prefix, trace_debug, trace_infoThen rename call sites:
_trace_prefix→trace_prefix, etc.
Verify: Grep for def _trace_prefix across the repo — should return zero results.
Task 4.2 — Consolidate positive int/float parsing
Problem: Three near-identical implementations:
_parse_positive_int(value, default)inapi/queue_orchestration.py:93-108_coerce_positive_int(value, default)inapi/orchestration/dispatch.py:48-53validate_positive_int(value, field_name)inapi/schemas.py:28-36Plus_parse_positive_floatinqueue_orchestration.py.
Read first: api/schemas.py, api/queue_orchestration.py, api/orchestration/dispatch.py.
Changes:
-
Add to
api/schemas.py(after existingvalidate_positive_int):def parse_positive_int(value, default: int) -> int: """Parse value as positive int, returning default on failure.""" try: parsed = int(value) except (TypeError, ValueError): return max(1, int(default)) return max(1, parsed) def parse_positive_float(value, default: float) -> float: """Parse value as positive float, returning default on failure.""" try: parsed = float(value) except (TypeError, ValueError): return max(0.0, float(default)) return max(0.0, parsed) -
In
api/queue_orchestration.py:- Delete
_parse_positive_intand_parse_positive_float. - Add
from .schemas import parse_positive_int, parse_positive_float. - Replace all call sites.
- Delete
-
In
api/orchestration/dispatch.py:- Delete
_coerce_positive_int. - Add
from ..api.schemas import parse_positive_int. - Replace call sites.
- Delete
Verify: grep -r "_parse_positive\|_coerce_positive" returns zero results outside of
any test files.
Task 4.3 — Deduplicate conditioning slicing
Problem: upscale/modes/dynamic.py contains conditioning slicing logic already
present in upscale/tile_ops.py.
Read first: upscale/modes/dynamic.py (full), upscale/tile_ops.py (full).
Identify the exact duplicated slicing pattern in each file (look for code that slices
positive / negative conditioning tensors by batch index).
Changes:
-
Identify the shared logic. It will look like:
# Slicing positive/negative conditioning for a specific batch index pos_sliced = [[t[:, idx:idx+1, ...] for t in cond_pair] for cond_pair in positive] neg_sliced = [[t[:, idx:idx+1, ...] for t in cond_pair] for cond_pair in negative] -
If the helper already exists in
tile_ops.py, find it and confirm its signature. If not, extract the logic fromdynamic.pyinto a new function intile_ops.py:def slice_conditioning(positive, negative, batch_idx: int): """Return conditioning sliced to a single batch index.""" ... -
In
upscale/modes/dynamic.py, delete the local implementation and replace with:from ..tile_ops import slice_conditioningUpdate all call sites to use
slice_conditioning(positive, negative, idx).
Verify: The slicing logic exists in exactly one place. grep -n "idx:idx+1\|batch_idx:batch_idx"
in dynamic.py returns zero results.
Task 4.4 — Unify worker probe/status logic in JavaScript
Problem: web/workerLifecycle.js and web/executionUtils.js both implement probe +
parse flows. executionUtils.js already uses extension.api.probeWorker() but
workerLifecycle.js still has raw fetch calls for checkMasterStatus and
checkWorkerStatus.
Read first: web/workerLifecycle.js (full), web/executionUtils.js (full),
web/apiClient.js (confirm probeWorker signature).
Changes:
-
In
web/workerLifecycle.js, findcheckMasterStatusandcheckWorkerStatus. Note: These two functions have legitimate reasons for rawfetch:checkMasterStatususes a same-origin call (no CORS) to the local servercheckWorkerStatusmay useAbortSignal.any()to combine timeout + cancellation
Assessment before changing: If
probeWorkerinapiClient.jssupports anAbortSignalparameter, migrate both to useextension.api.probeWorker(). If it does not, add an optionalsignalparameter toprobeWorker:probeWorker: async (workerUrl, timeout, signal = null) => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); const effectiveSignal = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal; try { const response = await fetch(`${workerUrl}/prompt`, { signal: effectiveSignal }); ... } finally { clearTimeout(timeoutId); } } -
Create a shared UI mapper in
web/workerUtils.js(already exists):export function applyProbeResultToWorkerDot(workerId, probeResult) { const dot = document.getElementById(`status-${workerId}`); if (!dot) return; dot.classList.remove('worker-status--online', 'worker-status--offline', 'worker-status--processing', 'worker-status--unknown', 'status-pulsing'); if (!probeResult || !probeResult.ok) { dot.classList.add('worker-status--offline'); dot.title = 'Offline'; } else if (probeResult.queueRemaining > 0) { dot.classList.add('worker-status--processing'); dot.title = `Processing (${probeResult.queueRemaining} queued)`; } else { dot.classList.add('worker-status--online'); dot.title = 'Online'; } } -
Use
applyProbeResultToWorkerDotin bothworkerLifecycle.jsandexecutionUtils.js(theperformPreflightCheckinactive-worker update block at lines 915-922 ofexecutionUtils.js).
Verify: grep -n "style\." web/workerLifecycle.js returns zero results. DOM status
updates flow exclusively through class manipulation helpers.
Task 4.5 — Extract timeout handler duplication in result_collector
Problem: upscale/result_collector.py lines 116-161 contain nearly identical
worker-status logging blocks for static (TileJobState) vs dynamic (ImageJobState) modes
inside the asyncio.TimeoutError handler.
Read first: upscale/result_collector.py (full).
Changes:
-
Extract a helper method on
ResultCollectorMixin:def _log_worker_timeout_status(self, job_data, current_time: float, multi_job_id: str) -> None: """Log which workers have timed out and for how long.""" if not isinstance(job_data, BaseJobState): return worker_status = dict(job_data.worker_status) for worker_id, last_seen in worker_status.items(): elapsed = max(0.0, current_time - float(last_seen)) log( f"[USDU] Timeout: job={multi_job_id} worker={worker_id[:8]} " f"elapsed={elapsed:.1f}s" ) -
In the
asyncio.TimeoutErrorblock, replace both the static-mode and dynamic-mode worker-status logging sections with a single call:except asyncio.TimeoutError: current_time = time.time() self._log_worker_timeout_status(job_data_snapshot, current_time, multi_job_id) if mode == 'dynamic': if current_time - last_heartbeat_check >= HEARTBEAT_INTERVAL: requeued = await self._check_and_requeue_timed_out_workers(...) ...The
job_data_snapshotshould be obtained under the lock before thewait_forcall (snapshot pattern from Task 1.1).
Verify: The timeout handler block has one code path for status logging, not two.
Task 4.6 — Eliminate config update endpoint boilerplate
Problem: api/config_routes.py update_worker_endpoint, update_master_endpoint,
and update_setting_endpoint all repeat the same if "field" in data: target[field] = ...
pattern with special handling for None (pop vs assign) and host normalization.
Read first: api/config_routes.py (full, especially lines 107-275).
Changes:
-
Define a field rule schema near the top of
config_routes.py:# Each entry: (key, normalizer_or_None, remove_on_none: bool) _WORKER_FIELDS = [ ('enabled', None, False), ('name', None, False), ('host', normalize_host, True), # pop key when value is None ('cuda_device', None, True), ('extra_args', None, True), ('type', None, False), ] _MASTER_FIELDS = [ ('name', None, False), ('host', normalize_host, True), ] -
Add a helper function:
def _apply_field_patch(target: dict, data: dict, field_rules: list) -> None: """Apply a partial update to target dict based on field rules.""" for key, normalizer, remove_on_none in field_rules: if key not in data: continue value = data[key] if value is None and remove_on_none: target.pop(key, None) else: target[key] = normalizer(value) if (normalizer and value is not None) else value -
Replace the explicit if-blocks in
update_worker_endpointwith:_apply_field_patch(worker, data, _WORKER_FIELDS)And in
update_master_endpoint:_apply_field_patch(config['master'], data, _MASTER_FIELDS)
Verify: update_worker_endpoint body no longer contains any if "enabled" in data: style
blocks. Behavior is identical — test with a PATCH that sets host to None (should remove
the key) and one that sets it to a URL string (should normalize it).
Phase 5 — Medium-Low
Task 5.1 — Centralize distributed state initialization
Problem: The ensure_distributed_state() / job queue init pattern is repeated in
api/queue_orchestration.py, distributed.py, api/job_routes.py (prepare_job endpoint),
and nodes/collector.py. Each touches prompt_server.distributed_pending_jobs and the
associated lock independently.
Read first: api/queue_orchestration.py (ensure_distributed_state function),
distributed.py (find where state attributes are set on prompt_server),
api/job_routes.py (prepare_job_endpoint, lines 131-146),
nodes/collector.py (lines 198-204).
Changes:
-
Ensure
ensure_distributed_state()inapi/queue_orchestration.pyis the sole place that createsprompt_server.distributed_pending_jobs,distributed_jobs_lock, and any other top-level distributed state attributes. It should be idempotent:def ensure_distributed_state(server_instance=None): ps = server_instance or prompt_server if not hasattr(ps, 'distributed_pending_jobs'): ps.distributed_pending_jobs = {} if not hasattr(ps, 'distributed_jobs_lock'): ps.distributed_jobs_lock = asyncio.Lock() -
In
api/job_routes.pyprepare_job_endpoint, replace the inline init:# Before: async with prompt_server.distributed_jobs_lock: if multi_job_id not in prompt_server.distributed_pending_jobs: prompt_server.distributed_pending_jobs[multi_job_id] = asyncio.Queue()with a call to
ensure_distributed_state()at startup (it's idempotent) and keep the per-job queue init in place (that's correct per-job logic, not state init). -
In
nodes/collector.py(lines 198-204), the early queue creation is correct behavior (race prevention) — leave it. But ensureensure_distributed_state()has been called before the node can ever run (it's called at startup inqueue_orchestration.pymodule import time). -
In
distributed.py, remove any directprompt_server.distributed_* = ...assignments and replace with a call toensure_distributed_state()at startup.
Verify: Grep for distributed_pending_jobs = {} and distributed_jobs_lock = — both
should appear only in ensure_distributed_state. All other sites just call that function.
Task 5.2 — Move all magic numbers to utils/constants.py
Problem: Several timeout and size values are hard-coded in files rather than defined in
utils/constants.py.
Read first: utils/constants.py (full), then check each file listed below.
Changes:
-
In
utils/constants.py, add:# Cloudflare tunnel TUNNEL_START_TIMEOUT: float = float(os.environ.get("TUNNEL_START_TIMEOUT", "25")) CLOUDFLARE_LOG_BUFFER_SIZE: int = 200 # USDU result collection DYNAMIC_MODE_MAX_POLL_TIMEOUT: float = 10.0 # Static mode job poll loop JOB_POLL_INTERVAL: float = 1.0 JOB_POLL_MAX_ATTEMPTS: int = 20 -
In
utils/cloudflare.py(orutils/cloudflare/tunnel.pyafter Task 2.2):- Replace
TUNNEL_START_TIMEOUT = 25(line ~30) with import from constants. - Replace
> 200log buffer check (line ~178) withCLOUDFLARE_LOG_BUFFER_SIZE.
- Replace
-
In
upscale/result_collector.py(line ~65):- Replace
min(10.0, timeout)withmin(DYNAMIC_MODE_MAX_POLL_TIMEOUT, timeout). - Add import:
from ..utils.constants import DYNAMIC_MODE_MAX_POLL_TIMEOUT.
- Replace
-
In
upscale/modes/static.py:- Replace
time.sleep(1.0)withtime.sleep(JOB_POLL_INTERVAL). - Replace
max_attempts=20withmax_attempts=JOB_POLL_MAX_ATTEMPTS. - Add import:
from ...utils.constants import JOB_POLL_INTERVAL, JOB_POLL_MAX_ATTEMPTS.
- Replace
Verify: grep -rn "= 25\b\|> 200\b\|10\.0\b\|max_attempts=20\b" utils/ upscale/modes/
shows no magic number remnants in these files.
Phase 6 — Low / Cleanup
Task 6.1 — Remove dead code and import drift
Problem: api/worker_routes.py imports helpers from workers/detection.py that
are not used within that file. workers/detection.py get_comms_channel() may be dormant.
Read first: api/worker_routes.py (all imports at top of file), workers/detection.py
(full). Grep for every symbol imported in worker_routes.py to confirm usage.
Changes:
-
In
api/worker_routes.py, identify every imported name fromworkers.detection(and any other module). For each, search the file body for usage. Remove any import line whose symbols are not referenced. -
For
workers/detection.pyget_comms_channel(): grep the full codebase forget_comms_channel. If found only innodes/collector.pyimports but never called (checkcollector.pybody), remove the import incollector.pyand the function indetection.py. If it is called, leave it. -
Also in
nodes/collector.py(line 20):from ..workers.detection import is_local_worker, get_comms_channel. Check whetheris_local_workerandget_comms_channelare referenced in the file body. Remove unused ones.
Verify: python -m py_compile on each modified file. No ImportError on module load.
Task 6.2 — Standardize error response format across API routes
Problem: Some endpoints use await handle_api_error(request, msg, status),
others call web.json_response({"error": ...}, status=400) directly, creating
inconsistent client-facing error shapes.
Read first: utils/network.py (handle_api_error implementation),
api/job_routes.py (lines 276-288 — list-of-errors response),
api/config_routes.py (find all direct web.json_response error calls).
Changes:
-
Extend
handle_api_error(if needed) to accept a list of error strings:async def handle_api_error(request, error, status: int = 500): if isinstance(error, list): return web.json_response({"errors": error}, status=status) msg = str(error) debug_log(f"API error [{status}]: {msg}") return web.json_response({"error": msg}, status=status) -
In
api/job_routes.py(lines ~287-288):# Before: return web.json_response({"error": errors}, status=400) # After: return await handle_api_error(request, errors, 400) -
In
api/config_routes.py, replace allreturn web.json_response({"error": ...}, status=...)calls withreturn await handle_api_error(request, ..., status). -
In
api/worker_routes.py, check for any directweb.json_responseerror patterns and standardize them.
Verify: grep -rn 'json_response.*"error"' api/ returns zero results (all error
responses now flow through handle_api_error).
Task 6.3 — Fix redundant server import alias
Problem: api/job_routes.py lines 10-11 have both:
import server
import server as _server
Only one is needed.
Read first: api/job_routes.py (check which alias is used throughout the file).
Changes:
- Find all usages of
serverand_serverinapi/job_routes.py. - Pick one (the dominant one, or
serverfor consistency with other files). - Remove the unused import and update any references to the removed alias.
Task 6.4 — Document mixin inheritance chain
Problem: ResultCollectorMixin, StaticModeMixin, DynamicModeMixin in
upscale/result_collector.py and upscale/modes/ have no documentation of the required
method interface, making them hard to extend.
Read first: upscale/result_collector.py, upscale/modes/static.py,
upscale/modes/dynamic.py, upscale/worker_comms.py.
Changes:
-
In
upscale/result_collector.py, add a docstring toResultCollectorMixinlisting abstract methods / expected attributes:class ResultCollectorMixin: """ Mixin for master-side result collection in USDU distributed jobs. Requires the consuming class to also mix in: - JobStateMixin (upscale.job_state) for job state access - WorkerCommsMixin (upscale.worker_comms) is NOT required here Expected to be used via multiple inheritance in DistributedUSDU node class. """ -
In
upscale/modes/static.pyandupscale/modes/dynamic.py**, add docstrings toStaticModeMixinandDynamicModeMixinlisting what they call onselfand what they require from co-mixins. -
No behavior changes — documentation only.
Verification Checklist (run after all phases)
# Python syntax check all modified files
find custom_nodes/ComfyUI-Distributed -name "*.py" | xargs python -m py_compile
# Confirm no bare usdu_managment god-module imports remain
grep -r "from.*usdu_managment import" custom_nodes/ComfyUI-Distributed \
--include="*.py" | grep -v "usdu_managment.py"
# Confirm trace helpers are in one place
grep -rn "def _trace_prefix\|def _trace_debug\|def _trace_info" \
custom_nodes/ComfyUI-Distributed --include="*.py"
# Expected: 0 results (all moved to utils/trace_logger.py as trace_prefix etc.)
# Confirm no lock-held network calls remain
grep -A5 "distributed_tile_jobs_lock" custom_nodes/ComfyUI-Distributed/utils/usdu_managment.py \
| grep "await probe_worker"
# Expected: 0 results
# Confirm mutating JS calls don't retry
grep -A3 "queueDistributed\|launchWorker\|stopWorker\|startTunnel\|stopTunnel" \
custom_nodes/ComfyUI-Distributed/web/apiClient.js | grep "retry: false"
# Expected: one hit per mutating call
# Confirm no raw web.json_response error patterns remain
grep -rn 'web\.json_response.*"error"' custom_nodes/ComfyUI-Distributed/api/ --include="*.py"
# Expected: 0 results
Notes for the implementing agent
- Do not change any HTTP endpoint paths or request/response JSON schemas. All changes are internal to Python modules and JS modules — the public API surface is frozen.
- Do not change
upscale/job_models.py— the typed dataclasses are already correct. - Phase 1 tasks are prerequisites for Phase 2. Specifically, Task 1.1 must be done before Task 2.1 since the requeued function moves in 2.1.
- Task 2.1 is the most complex. Read all import sites before moving any code. Use
re-exports in the shim
usdu_managment.pygenerously to avoid breaking callers. - Tasks within the same phase are independent and can be done in any order or in parallel.
- After each task, run
python -m py_compileon all touched files before moving on.