Skip to content

Commit 950f606

Browse files
fix(run): never report a job-state write failure as a lost server connection (#1023)
1 parent aae234e commit 950f606

6 files changed

Lines changed: 426 additions & 15 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,18 @@ history.
135135

136136
### Fixed
137137

138+
- `comfy run` no longer reports a failure to write its own job-state file as a
139+
lost connection to the ComfyUI server. The state file is a convenience
140+
record, written after the server has already accepted the prompt, so a
141+
config directory the CLI cannot write — an unreachable or read-only home, a
142+
sandboxed process, a full disk — used to surface as `ws_disconnected` with a
143+
"check the server is still running; re-run the command" hint. Re-running on
144+
that advice submits the same workflow a second time, and an automated caller
145+
obeying the hint could queue several GPU jobs for one request. A submit the
146+
server accepted now reports `queued` as usual, with `state_file: null` to say
147+
no record was kept; follow the job by its `prompt_id` instead, and run with
148+
`LOG_LEVEL=DEBUG` to see what the write failed on. Real transport failures
149+
are reported exactly as before.
138150
- `comfy deploy run --wait` no longer loses a finished job's outputs to a 401
139151
when the sign-in token it started with expires mid-run. The token lasts
140152
fifteen minutes, and the run sent the one it started with for every request

‎comfy_cli/command/run/__init__.py‎

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"""
99

1010
import json
11+
import logging
1112
import sys
1213
import time
1314
import uuid
@@ -55,6 +56,8 @@
5556

5657
workspace_manager = WorkspaceManager()
5758

59+
logger = logging.getLogger(__name__)
60+
5861
# Bounds on the `partner_nodes` telemetry property and on the partner-node names
5962
# echoed in the missing-credential error. class_type strings are attacker- (or
6063
# just accident-) controlled workflow JSON, so cap the list length and each name.
@@ -575,7 +578,14 @@ def execute(
575578
host=host,
576579
port=port,
577580
)
578-
watcher_spawned = _spawn_watcher(execution.prompt_id, where="local", host=host, port=port, notify=notify)
581+
# No record means nothing for a watcher to update: `_watch-job`
582+
# opens with `jobs_state.read` and exits immediately (or dies on
583+
# the same error). Spawning it anyway would also report
584+
# `watcher_spawned: true`, which tells a consumer something is
585+
# keeping the file fresh when nothing is.
586+
watcher_spawned = state_file is not None and _spawn_watcher(
587+
execution.prompt_id, where="local", host=host, port=port, notify=notify
588+
)
579589
# Emitted after the state file and the watcher spawn attempt, so a
580590
# consumer reading this line can already `comfy jobs status` it.
581591
_emit_queued_event(renderer, execution)
@@ -759,7 +769,12 @@ def _write_state(state):
759769
"""
760770
try:
761771
return jobs_state.write(state)
762-
except (OSError, ValueError):
772+
except (OSError, ValueError) as e:
773+
# The caller gets a null `state_file` and no error, so this log line is
774+
# the only trail a swallowed failure leaves — and the one that names
775+
# the resolved path when the config root is unwritable. `LOG_LEVEL`
776+
# (comfy_cli.logging) surfaces it.
777+
logger.debug("job state write failed for %r, continuing: %s", getattr(state, "prompt_id", None), e)
763778
return None
764779

765780

@@ -875,7 +890,20 @@ def _init_queued_state(prompt_id, client_id, workflow_name, *, where, compose_me
875890
watcher pid + create_time before the first write.
876891
877892
``where_fields`` carries the target-specific locators (``host``/``port`` for
878-
local, ``base_url`` for cloud). Returns the ``(state, state_file)`` pair."""
893+
local, ``base_url`` for cloud). Returns the ``(state, state_file)`` pair,
894+
whose second element is None when the write could not land.
895+
896+
The write goes through ``_write_state``, not ``jobs_state.write`` directly,
897+
and that is load-bearing: both callers run *after* the server accepted the
898+
prompt, and the local one runs inside the ``except (WebSocketException,
899+
ConnectionError, OSError)`` block's try. A raw call let a purely local
900+
filesystem error — a config root resolved outside the sandbox, a read-only
901+
or missing directory — surface to the caller as ``ws_disconnected`` with a
902+
"re-run the command" hint, which an agent caller obeys, submitting another
903+
GPU job for a prompt that was already queued. On the cloud side, where
904+
there is no handler at all, the same raw call turned an accepted billable
905+
job into an uncaught traceback. A state-file failure costs the caller the
906+
durable record (``state_file`` comes back null) and nothing else."""
879907
state = jobs_state.new(
880908
prompt_id=prompt_id,
881909
client_id=client_id,
@@ -884,7 +912,7 @@ def _init_queued_state(prompt_id, client_id, workflow_name, *, where, compose_me
884912
**where_fields,
885913
)
886914
state.item_map = (compose_meta or {}).get("items")
887-
state_file = jobs_state.write(state)
915+
state_file = _write_state(state)
888916
return state, state_file
889917

890918

@@ -915,11 +943,21 @@ def _announce_async_queued(
915943
if renderer.is_pretty():
916944
from comfy_cli.output.glyphs import status_glyph
917945

946+
# `state_file` is None when the record could not be written. Printing
947+
# the bare value gives a human "state None" with no explanation, on
948+
# a run that otherwise succeeded.
949+
state_line = state_file if state_file else "[dim]not written — track by prompt_id[/dim]"
918950
pprint(
919-
f"{status_glyph('queued')} [dim]{prompt_id}[/dim]\n"
951+
# `prompt_id` is server-chosen, and it appears TWICE here: on its
952+
# own line, and again inside `watch_command`, which both callers
953+
# build as `comfy jobs watch <prompt_id>`. A value carrying Rich
954+
# markup raises MarkupError at either sink — aborting pretty output
955+
# for a job that was already accepted — or injects into the
956+
# terminal, so both are escaped.
957+
f"{status_glyph('queued')} [dim]{sanitize_markup(prompt_id)}[/dim]\n"
920958
f" [dim]workflow [/dim]{workflow_name}\n"
921-
f" [dim]watch [/dim][cyan]{watch_command}[/cyan]\n"
922-
f" [dim]state [/dim]{state_file}"
959+
f" [dim]watch [/dim][cyan]{sanitize_markup(watch_command)}[/cyan]\n"
960+
f" [dim]state [/dim]{state_line}"
923961
)
924962
if not watcher_spawned:
925963
pprint("[yellow]⚠ Background watcher could not start; poll manually with `comfy jobs status`[/yellow]")
@@ -1244,7 +1282,11 @@ def _emit_queued_cloud() -> None:
12441282
base_url=target.base_url,
12451283
)
12461284
_journal_run(workflow_name, submit.prompt_id, "cloud")
1247-
watcher_spawned = _spawn_watcher(submit.prompt_id, where="cloud", notify=notify)
1285+
# Same rule as the local async branch, and it matters more here: the
1286+
# watcher inherits COMFY_CLOUD_AUTH_TOKEN / COMFY_CLOUD_API_KEY and
1287+
# outlives us, so launching one that can read no record is a
1288+
# credential-holding orphan for nothing.
1289+
watcher_spawned = state_file is not None and _spawn_watcher(submit.prompt_id, where="cloud", notify=notify)
12481290
# Durable record + watcher exist: safe to announce.
12491291
_emit_queued_cloud()
12501292
_announce_async_queued(

‎comfy_cli/command/run/watcher.py‎

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,18 @@ def _tail_state_file(prompt_id: str, *, seconds: float = 8.0) -> None:
4141
writing after we return — we just give the human a few seconds of
4242
"alive" feedback before the foreground exits.
4343
44-
Always safe to call; no-ops if the renderer isn't pretty or the user
45-
Ctrl-Cs out.
44+
Always safe to call; no-ops if the renderer isn't pretty, the state file
45+
can't be read at all, or the user Ctrl-Cs out.
46+
47+
That "can't be read at all" case is load-bearing, not defensive padding.
48+
This runs from ``_announce_async_queued``, i.e. inside ``execute``'s
49+
``except (WebSocketException, ConnectionError, OSError)`` block's try and
50+
*after* the server accepted the prompt. An ``OSError`` escaping here —
51+
``state_dir()``'s mkdir under a config root that resolved outside the
52+
sandbox, a read-only directory — would therefore be reported to the caller
53+
as ``ws_disconnected`` with a "re-run the command" hint, for a job that is
54+
already queued. See the same guarantee on ``_write_state`` in
55+
``comfy_cli/command/run/__init__.py``.
4656
"""
4757
import time as _t
4858

@@ -56,16 +66,41 @@ def _tail_state_file(prompt_id: str, *, seconds: float = 8.0) -> None:
5666
from comfy_cli import jobs_state
5767
from comfy_cli.output.glyphs import status_glyph
5868

69+
# Truncate first, then escape: slicing can split a markup sequence, and
70+
# `prompt_id` is server-chosen. Unsafe ids used to stop at the submit-time
71+
# `jobs_state.write`; now that `_write_state` swallows that rejection they
72+
# reach this render, where raw markup raises MarkupError (or injects into
73+
# the terminal) on a job the server already accepted.
74+
short_id = sanitize_markup(prompt_id[:8])
75+
5976
def render(status: str, elapsed: float) -> Text:
60-
return Text.from_markup(f" {status_glyph(status)} [dim]· {elapsed:.1f}s · {prompt_id[:8]}…[/dim]")
77+
return Text.from_markup(f" {status_glyph(status)} [dim]· {elapsed:.1f}s · {short_id}…[/dim]")
78+
79+
def read_state():
80+
"""``(state, readable)``. ``readable`` is False only when the record
81+
could not be consulted at all, which is unrecoverable for the whole
82+
tail — distinct from ``state is None``, the ordinary "nothing written
83+
yet" the poll loop is built to wait through.
84+
85+
``ValueError`` is caught alongside ``OSError`` to match
86+
``_write_state``: ``state_path()`` raises it for a server-supplied
87+
prompt_id that fails ``_SAFE_PROMPT_ID``, and since ``_write_state``
88+
now swallows that at submit, this read is where it would otherwise
89+
surface — as a traceback, uncovered by either caller's handler."""
90+
try:
91+
return jobs_state.read(prompt_id), True
92+
except (OSError, ValueError):
93+
return None, False
6194

6295
deadline = _t.time() + seconds
6396
last_status = "queued"
6497
start = _t.time()
6598
try:
6699
with Live(render(last_status, 0.0), console=renderer.console(), refresh_per_second=4, transient=True) as live:
67100
while _t.time() < deadline:
68-
state = jobs_state.read(prompt_id)
101+
state, readable = read_state()
102+
if not readable:
103+
return
69104
if state is not None:
70105
last_status = state.status
71106
live.update(render(last_status, _t.time() - start))
@@ -75,7 +110,7 @@ def render(status: str, elapsed: float) -> Text:
75110
except KeyboardInterrupt:
76111
pass
77112

78-
final_state = jobs_state.read(prompt_id)
113+
final_state, _readable = read_state()
79114
if final_state is None:
80115
return
81116
elapsed = _t.time() - start

‎docs/json-output.md‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,7 @@ On `--wait` success, `data` carries:
504504
| `elapsed_seconds` | float \| null | Wall-clock duration (null when not waiting) |
505505
| `host` / `port` | str / int | **Local only.** Target server |
506506
| `base_url` | str | **Cloud only.** Target cloud endpoint |
507-
| `state_file` | str \| null | Path of the job state file (poll with `comfy jobs status`) |
507+
| `state_file` | str \| null | Path of the job state file (poll with `comfy jobs status`); null when no record could be written — see [below](#known-limit-a-state-file-failure-is-never-a-run-failure) |
508508

509509
`cached_node_ids` and `executed_node_ids` may overlap: a cached
510510
output-bearing node emits both `execution_cached` and `executed`. Agents
@@ -521,6 +521,39 @@ the same on both targets, `watcher_spawned` included (add `--where cloud` to
521521
the follow-up commands for a cloud job). The async envelope carries no
522522
`outputs_by_node` / `outputs_by_item` / `warnings` — nothing has run yet.
523523

524+
### Known limit: a state-file failure is never a run failure
525+
526+
Writing the job's state file is best-effort on an **async submit** (the default,
527+
no `--wait`, local or cloud) and on **local `--wait`**. If the state directory
528+
can't be written — a config root that resolved somewhere unreachable, a
529+
read-only or sandboxed home, a full disk — the run still reports `ok: true`,
530+
because by then the server has already accepted the prompt. What the envelope
531+
says depends on the path, and `data.state_file` is `null` on both:
532+
533+
- **async submit** — `data.status: "queued"`, exactly as it would be with the
534+
record written.
535+
- **local `--wait`** — the run goes on to completion in the foreground and
536+
finishes `data.status: "completed"` with its outputs, so a failed state write
537+
costs you only the on-disk record, not the result.
538+
539+
**`state_file: null` is not a failed run**, and in particular it is not a
540+
transport error: there is nothing to retry, and re-submitting queues a second
541+
job for a prompt that is already running. Follow an async job by `prompt_id`
542+
(`comfy jobs status <prompt_id>`) and treat a missing record as "unknown", not
543+
"did not run".
544+
545+
With no record there is also nothing for a background watcher to update, so the
546+
spawn is skipped and `data.watcher_spawned` is `false` — poll
547+
`comfy jobs status` yourself, as that field already instructs.
548+
549+
Run with `LOG_LEVEL=DEBUG` to see the swallowed cause.
550+
551+
**Cloud `--wait` is not yet covered.** That path still writes its state record
552+
directly, including inside its own error handlers, so an unwritable state
553+
directory there can surface as a traceback after the job was accepted rather
554+
than as a clean envelope. Treat the guarantee above as scoped to the paths
555+
named in the first paragraph.
556+
524557
### Known limit: `--wait` has no background watcher
525558

526559
`--wait` writes the job's state file at submit and finalizes it in the

0 commit comments

Comments
 (0)