fix(jobs): detect a dead local server and record a terminal server_died error (BE-4751) - #604
Conversation
…ed error (BE-4751) An async-submitted local job (`comfy run` without `--wait`) whose ComfyUI server is OOM-killed left a forever-`queued` ghost: `_snapshot` swallows the /queue connection failure and returns None on the /history one, and the watcher reads `None` as "no record yet — keep polling", so it polled a corpse every 2s for the full 6h ceiling and never recorded the death. The watcher now probes local-server liveness explicitly each cycle with the same `check_comfy_server_running` helper `comfy jobs` uses. Three consecutive failed probes (~6s, so a blip or a quick restart is tolerated) on a non-terminal job writes a terminal `server_died` error naming the host, port, last status and probe count, then breaks so the existing notification fires. Any successful probe resets the streak. Probe and poll share a new `_resolve_watch_target` helper so they can never disagree on the target. `server_died` is added to the error-code registry, which the repo's own contract test requires. The cloud branch is untouched.
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 22 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
✨ Finishing Touches🧪 Generate unit tests (beta)
✨ Simplify code
Comment |
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Found 5 finding(s).
| Severity | Count |
|---|---|
| 🟠 High | 1 |
| 🟡 Medium | 2 |
| 🟢 Low | 2 |
Panel: 6/8 reviewers contributed findings.
Reviewers that did not contribute: kimi-k2.5:adversarial (empty), kimi-k2.5:edge-case (empty)
…-orphaned jobs (BE-4751) Addresses the cursor-review panel on #604. The liveness check went through `check_comfy_server_running`, whose single boolean collapses "the port is refused" with "the server took longer than 5s to answer". A server busy loading a large model into VRAM answers slowly, so three of those in a row filed a terminal `server_died` error against a job that was still running. `_probe_local_server` now classifies the probe itself and only a refused connection counts toward the death streak — a timeout or a non-200 means something is listening, so the streak resets and the poll runs as normal. `Timeout` is caught before `ConnectionError` because `ConnectTimeout` subclasses both. The probe also asks for `/history?max_items=1` rather than the unbounded `/history`, whose body grows without limit on a long-lived server and could time the probe out by itself — feeding the same false verdict. Before writing the verdict the watcher now takes one last poll: the probe hits `/history`, the poll hits `/queue` + `/history/<id>`, so a job that finished right as the probe started failing is reported as completed instead of as a failure with empty outputs. A server OOM-killed and restarted inside the detection window used to reset the streak and then poll a prompt the fresh process had never heard of until the 6h ceiling — the exact hang this change set out to remove. ComfyUI holds its queue and history in memory only, so a restart is always fatal to an in-flight job: once an outage has been seen, a record missing for `_LOST_AFTER_RESTART_S` is now recorded as `server_died` with `restarted: true`. The gate on a previously-observed outage keeps a not-yet-published record from being mistaken for one. Finally, `_finalize_server_died` re-reads the state file and does the read and the write as one locked transaction, so a concurrent `comfy jobs cancel` is no longer clobbered by the watcher's stale in-memory copy (`locking.file_lock` is reentrant per thread, so `jobs_state.write`'s nested acquire is a no-op). `_poll_local_once` returns `(terminal, record_seen)` so the restart guard can tell "no record" from "still running". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ELI-5
If you start a job with
comfy run(no--wait), a little background watcher keeps an eye on it and writes what it sees into a state file. But if ComfyUI itself dies mid-job — say the GPU runs out of memory and the OS kills it — the watcher never noticed. It kept asking a dead server for news every 2 seconds for six hours, andcomfy jobs lscheerfully reported the job asqueuedthe whole time. This teaches the watcher to check whether the server is actually still alive, and to write downserver_diedwhen it isn't.What changed
The watcher's local branch now does an explicit liveness probe each cycle, using the same
check_comfy_server_runninghelpercomfy jobsalready uses for its own up/down check._SERVER_DOWN_CONSECUTIVE_LIMIT = 3, ≈6s at the 2s poll interval — enough to ride out a blip or a quick restart) on a non-terminal job writes a terminalerrorwithcode: "server_died", naming the host, port, last status and probe count, then breaks out so the existing_notifyfires. Any successful probe in between resets the streak, so a restart within the window is not death._resolve_watch_target(state, host, port)(theresolve_local_host_portprecedence + IPv6 bracketing lifted out of_poll_local_once), so they can never disagree about which server is being watched.jobs.py::_snapshotkeeps its error-swallowing — other callers rely on it.Why this shape
_snapshotdeliberately swallows the connection failure on/queue(except RuntimeError: q = {}) and returnsNoneon/history, andNoneis indistinguishable from the legitimate "prompt hasn't shown up yet" case. So the poll path structurally cannot tell a dead server from a slow one — a separate liveness signal is the only thing that can.Tests
Six new tests in
tests/comfy_cli/jobs/test_jobs.py(where the other watcher tests already live — the ticket offered a new file as the alternative). They monkeypatch the probe andtime.sleep, and assert against the real on-disk state file via the autouse_isolate_jobs_state_dirfixture rather than an in-memory object:queued→ file endsstatus == "error",error.code == "server_died",details.last_status == "queued", host/port/probe-count populated, loop exits (no 6h wait), and the poll is asserted never to run against a server that failed its probe;False, False, True→ streak resets, noserver_died, a subsequent completed snapshot endsstatus == "completed"with outputs;_notifycalled exactly once, with the finalserver_diedstate (asserted inside test 1);::1→[::1]);Full suite: 2774 passed, 37 skipped.
Judgment calls and deviations — please read
job_watcher.py+ tests. It also addsserver_diedtocomfy_cli/error_codes.py. That registry is the canonical agent-facing contract and the repo's owntest_every_raised_code_is_registeredfails without it, so the ticket's "confined to" criterion could not be met as written. This is the only extra file.ruff check .is not clean on this branch — and it is not clean onmaineither. There are 15 pre-existingUP038errors (comfy_cli/workflow_to_api.py,cloud/oauth.py,command/generate/emit.py,cql/default_workflow.py,registry/config_parser.py) plus one pre-existingruff formatdiff intests/comfy_cli/command/github/test_pr.py. None are in files this PR touches, and I left them alone rather than smuggling an unrelated cleanup into this diff.ruff checkandruff format --checkare clean on all three touched files.watcher_timeout. Without this the "don't overwrite a terminal state" rule would have left an unbreakable loop. Only reachable when a watcher is (re-)launched against an already-finished job./historywithin the probe's 5s timeout three times running would be misreported as dead. Two things bound that: the probe reuses the product's established liveness signal, andcomfy jobs status/jobs lsalready hard-exitserver_not_runningon a single failure of that same probe at the same choke point — so this check is strictly more tolerant than behavior users already depend on. Raising_SERVER_DOWN_CONSECUTIVE_LIMITis a one-line change if field data disagrees.Result
comfy jobs ls/jobs waitnow surfaceserver_diedwith the prompt_id instead of a forever-queuedghost. (jobs statusfollows once its independent state-file-fallback ticket lands — no ordering dependency.)