-
Notifications
You must be signed in to change notification settings - Fork 143
fix(run): emit the queued NDJSON event only after the job state file is persisted
#663
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -385,6 +385,12 @@ def execute( | |
| wait_state.status = "running" | ||
| _write_state(wait_state) | ||
|
|
||
| # Only now, with the durable record on disk, announce the queue. | ||
| # The event-order contract (docs/json-output.md) is | ||
| # `prompt_preview → queued → node events`, so this must also | ||
| # precede `watch_execution`'s WebSocket stream. | ||
| _emit_queued(renderer, execution) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium — This emit now sits inside the
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed by the same change as the The narrow exemption was the wrong shape for exactly this reason — the guard now keys on who owns the failing fd, not on which Because the flag lives in the renderer rather than at this one call site, it also covers a stdout failure during the node events Covered by |
||
|
|
||
| # `watch_execution` reports a terminal server event by rendering | ||
| # the error and raising `typer.Exit` (1 for `execution_error`, 130 | ||
| # for `execution_interrupted`) — the ordinary failure path, not an | ||
|
|
@@ -460,6 +466,10 @@ def execute( | |
| state_file = jobs_state.write(state) | ||
| watcher_spawned = _spawn_watcher(execution.prompt_id, where="local", host=host, port=port, notify=notify) | ||
|
|
||
| # Emitted after the state file and the watcher spawn attempt, so a | ||
| # consumer reading this line can already `comfy jobs status` it. | ||
| _emit_queued(renderer, execution) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High — The local async branch now sequences the only pre-envelope carrier of
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — routed through You're right that this PR is what makes it matter: before the reordering,
Pinned by |
||
|
|
||
| if renderer.is_pretty(): | ||
| from comfy_cli.output.glyphs import status_glyph | ||
|
|
||
|
|
@@ -527,6 +537,12 @@ def execute( | |
| ) | ||
| raise typer.Exit(code=1) | ||
| except (WebSocketException, ConnectionError, OSError) as e: | ||
| if isinstance(e, BrokenPipeError): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High —
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in the follow-up commit — you're right that the exception type can't attribute the failing fd, and the short-circuit was the worse half of it. The bail-out now gates on stdout ownership rather than the type: I also moved the Both are pinned by tests that I verified fail against the old arrangement:
|
||
| # A closed stdout is the consumer leaving, not the server dying — | ||
| # let click/__main__ turn it into the documented silent exit 0 and | ||
| # leave the just-written state record untouched. Same principle as | ||
| # `completed_payload` being emitted outside this try. | ||
| raise | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High — Re-raising
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — the bail-out now hands the record off before re-raising. Confirmed the phantom is this PR's doing: pre-PR a Took your first suggestion, since the job genuinely is still running server-side — marking it terminal would be a lie. New The same hand-off is wired into the cloud Pinned by |
||
| # If we closed the WebSocket ourselves in response to Ctrl-C, the recv | ||
| # loop exits with a WebSocketException that *looks* like the server | ||
| # vanished. Check the cancellation token first so we emit the right | ||
|
|
@@ -594,6 +610,24 @@ def execute( | |
| renderer.emit(completed_payload, command="run", where="local") | ||
|
|
||
|
|
||
| def _emit_queued(renderer, execution) -> None: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium —
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — the outcome is now carried in the event. Agreed the docs guarantee was overstated. Of your two options I took carry the outcome rather than gate the emit: gating would mean a failed state write also costs the caller the So
Pinned by |
||
| """Emit the contractual local `queued` event (docs/json-output.md#queued). | ||
|
|
||
| Lives in the caller rather than ``WorkflowExecution.queue()`` so it can be | ||
| emitted *after* the job's state file is persisted — a consumer that sees | ||
| this line can immediately `comfy jobs status <prompt_id>` / see the job in | ||
| `jobs ls`, and a `BrokenPipeError` here can no longer abort setup before | ||
| the durable record exists. The field set is unchanged. | ||
| """ | ||
| renderer.event( | ||
| "queued", | ||
| prompt_id=execution.prompt_id, | ||
| client_id=execution.client_id, | ||
| validation_warnings=execution.validation_warnings, | ||
| nodes=execution.workflow_manifest(), | ||
| ) | ||
|
|
||
|
|
||
| def _write_state(state): | ||
| """Best-effort ``jobs_state.write``. Returns the path, or None if the | ||
| write was skipped, the state dir was unwritable, or the prompt_id was | ||
|
|
@@ -860,7 +894,8 @@ def execute_cloud( | |
| # with `queued` (async) / `executing` (--wait) carrying {workflow, base_url} | ||
| # — both wrong per docs/json-output.md: `queued` means "the server accepted | ||
| # the prompt" (so it cannot precede the POST), and `executing` is a per-node | ||
| # event. The contractual `queued` is emitted after submit, below. | ||
| # event. The contractual `queued` is emitted after submit *and* after the | ||
| # job state file is written, below. | ||
|
|
||
| try: | ||
| if not wait and renderer.is_pretty(): | ||
|
|
@@ -915,19 +950,27 @@ def execute_cloud( | |
| raise typer.Exit(code=1) | ||
|
|
||
| # The contractual `queued`: the server has the prompt. Same shape the local | ||
| # path emits from `WorkflowExecution.queue()` (docs/json-output.md#queued), | ||
| # plus `base_url` so a cloud consumer still learns the target. | ||
| # `validation_warnings` is always empty here — unlike local, the cloud | ||
| # treats any `node_errors` on an accepted submit as a hard `prompt_rejected` | ||
| # above, so a partially-valid graph never reaches this line. | ||
| renderer.event( | ||
| "queued", | ||
| prompt_id=submit.prompt_id, | ||
| client_id=client_id, | ||
| validation_warnings=[], | ||
| nodes=workflow_manifest(parsed_workflow), | ||
| base_url=target.base_url, | ||
| ) | ||
| # path emits (docs/json-output.md#queued), plus `base_url` so a cloud | ||
| # consumer still learns the target. `validation_warnings` is always empty | ||
| # here — unlike local, the cloud treats any `node_errors` on an accepted | ||
| # submit as a hard `prompt_rejected` above, so a partially-valid graph | ||
| # never reaches this line. | ||
| # | ||
| # Deliberately NOT emitted at this point: both branches below emit it only | ||
| # once the job's state file (and, on the async branch, the watcher) exist. | ||
| # A `BrokenPipeError` here — `comfy run --where cloud --json-stream … | | ||
| # head -n1`, where the NDJSON renderer flushes every line — used to abort | ||
| # setup before `jobs_state.write` ran while `__main__` still exited 0, | ||
| # leaving a billable cloud job with no journal entry, state file or watcher. | ||
| def _emit_queued_cloud() -> None: | ||
| renderer.event( | ||
| "queued", | ||
| prompt_id=submit.prompt_id, | ||
| client_id=client_id, | ||
| validation_warnings=[], | ||
| nodes=workflow_manifest(parsed_workflow), | ||
| base_url=target.base_url, | ||
| ) | ||
|
|
||
| if not wait: | ||
| state = jobs_state.new( | ||
|
|
@@ -942,6 +985,9 @@ def execute_cloud( | |
| _journal_run(workflow_name, submit.prompt_id, "cloud") | ||
| watcher_spawned = _spawn_watcher(submit.prompt_id, where="cloud", notify=notify) | ||
|
|
||
| # Durable record + watcher exist: safe to announce. | ||
| _emit_queued_cloud() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High — Same hazard on both cloud branches:
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — both cloud submit-time writes now go through the best-effort Agreed on the framing: this is the loss the reordering exists to prevent, and doing it after a billable submit is the worst place for it. Pinned by |
||
|
|
||
| if renderer.is_pretty(): | ||
| from comfy_cli.output.glyphs import status_glyph | ||
|
|
||
|
|
@@ -991,6 +1037,12 @@ def execute_cloud( | |
| state_file = jobs_state.write(state) | ||
| _journal_run(workflow_name, submit.prompt_id, "cloud") | ||
|
|
||
| # Announced once the record is durable, and *outside* the polling try below | ||
| # — none of its handlers should ever see (and rewrite state for) a | ||
| # BrokenPipeError raised by this emit. It propagates to click/`__main__`, | ||
| # which exits 0 silently, with the job already recorded. | ||
| _emit_queued_cloud() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium — Placing
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — same hand-off as the local You're right that this branch is the worst case for it: it spawns no watcher by design, so nothing would ever advance or reap the record, and the job keeps running and billing. The emit stays outside the polling try:
_emit_queued_cloud(state_file)
except OSError:
_hand_off_record(state, where="cloud", notify=notify)
raise
Pinned by |
||
|
|
||
| try: | ||
|
|
||
| def _probe(): | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
⚪ Nit — The event stream is now sequenced behind
jobs_state.write, which takeslocking.file_lockwith no timeout (fcntl.flock(fd, LOCK_EX)blocks indefinitely). A stuck holder of<prompt_id>.lock— a SIGSTOPped watcher, a hung NFS mount — now stalls thequeuedannouncement itself, so a consumer blocking on that line hangs unbounded; passing atimeout=would keep the announcement live. Raised by 1 of 8 reviewers (claude-opus-5-thinking-max adversarial).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Real, but deferring to a follow-up ticket rather than fixing here.
Confirmed the mechanics:
jobs_state.write(comfy_cli/jobs_state.py:125) takeslocking.file_lock(path.with_suffix(".lock"))with notimeout, andlocking.file_lock(comfy_cli/locking.py:49) blocks indefinitely whentimeout is None. So a stuck holder does stall the announcement, and a consumer blocking on that line hangs unbounded.Out of scope for this PR because the lock has always been unbounded — this PR only changed which line is sequenced behind the write (the async path already wrote state before emitting its envelope). Fixing it means threading a timeout through a primitive shared by the foreground run paths, the detached watcher (
command/job_watcher.py), andjobs ls's stale-watcher reap (command/jobs.py:211-222), and deciding per call site what a timeout means. The run command's submit-time writes now go through the best-effort_write_state, so they can degrade tostate_file: nullcleanly — but the watcher and the reap write assume the write lands, and making them fail silently on timeout would quietly stopjobs lsreaping. That needs a decision before coding, so it isn't something to bolt on here.Recorded as a follow-up (low severity) with that analysis and the proposed
write(state, *, lock_timeout=…)shape. Worth notingfile_lock's own docstring already flags thattimeoutis best-effort and that flock on NFS is often a silent no-op, so the hung-NFS half may need a different primitive rather than a timeout.