Skip to content

fix(stream): cancel the worker on a dflash first-chunk timeout (turn finalises, worker keeps streaming) - #277

Merged
OmarB97 merged 1 commit into
mainfrom
fix/dflash-first-chunk-timeout-orphans-worker
Jul 14, 2026
Merged

fix(stream): cancel the worker on a dflash first-chunk timeout (turn finalises, worker keeps streaming)#277
OmarB97 merged 1 commit into
mainfrom
fix/dflash-first-chunk-timeout-orphans-worker

Conversation

@OmarB97

@OmarB97 OmarB97 commented Jul 14, 2026

Copy link
Copy Markdown
Owner

The bug

Observed live on the desktop with deepseek-v4-flash-w2: the turn times out and Hermes marks it finished — then the model's answer arrives anyway and streams into the transcript. The composer shows the idle "send" affordance the whole time, so there is no way to interrupt the text still appearing on screen.

Root cause

interruptible_streaming_api_call has two paths that force-close the in-flight request. Only one of them tells the worker it was deliberate.

The worker's exception handler cannot distinguish our own close from a network blip — it relies entirely on a flag:

# agent/chat_completion_helpers.py  (streaming worker)
except Exception as e:
    if _request_cancelled["value"]:
        # our own abort -- exit, no retry, no fallback  (#6600)
        return
    ...  # otherwise: treat as transient, RETRY

The interrupt path sets it before closing:

if agent._interrupt_requested:
    _request_cancelled["value"] = True      # <-- correct
    _close_request_client_once("stream_interrupt_abort")

The dflash first-chunk timeout path does not:

_close_request_client_once("dflash_first_chunk_kill")   # <-- flag never set
agent._replace_primary_openai_client(reason="dflash_first_chunk_pool_cleanup")
t.join(timeout=_env_float("HERMES_STREAM_ABORT_JOIN_TIMEOUT", 2.0))
if result["error"] is None and result["response"] is None:
    result["error"] = TimeoutError(...)
    break                                    # main loop gives up here

So the sequence is:

  1. No first chunk before the deadline → we kill the socket.
  2. The worker raises, sees _request_cancelled == False, calls it a transient network error, and retries in the background.
  3. Because it retried, it does not finish inside the 2s join → result is still empty → we break with TimeoutError and the turn is finalised.
  4. The orphaned retry is still running. deepseek-v4-flash-w2 is a 149 GB MoE that fits in neither the 32 GB of VRAM nor the 123 GB of RAM on taro, so it streams experts off NVMe and can take minutes to first token. When it eventually answers, the orphan writes into the dead turn.

That is the UI desync: text streaming into an idle composer.

The fix

Set _request_cancelled["value"] = True before the close, mirroring the interrupt path.

It must be set before the close, not after the join — the worker can raise and re-enter its retry loop the instant the socket dies, well before we reach the break.

The stale-stream path deliberately does not set the flag: it kills the socket precisely so the worker's retry loop opens a fresh connection, and it does not break. That asymmetry is intentional and left alone.

Evidence

New regression test asserts the invariant directly on the worker's own closure:

$ python3 -m pytest tests/agent/test_local_stream_timeout.py -q
82 passed in 3.85s

Mutation check — the test is not vacuous. Reverting only the one added line:

$ python3 -m pytest -k orphan          # without the fix
FAILED ... test_first_chunk_timeout_cancels_the_worker_instead_of_orphaning_it
1 failed

$ python3 -m pytest -k orphan          # with the fix
1 passed

No regressions across the streaming/interrupt/timeout surface:

$ python3 -m pytest tests/agent/ -q -k "stream or timeout or interrupt or chat_completion"
485 passed, 5090 deselected

Risks / notes

  • Behaviour change is confined to the first-chunk-timeout path, which already abandoned the turn — the worker's late response was never consumed by the main loop, it only leaked into the UI. Cancelling it cannot lose a result the caller would otherwise have used.
  • If the worker happens to finish inside the join window, result is populated and the loop continues as before; the flag is inert in that case because the worker has already returned.
  • This fixes the symptom (a finalised turn that keeps streaming). It does not make W2 fast — that model is genuinely oversized for the host and its time-to-first-token is what trips the deadline in the first place.

The first-chunk timeout force-closes the connection and breaks out with a
TimeoutError, but never marks the request cancelled. The streaming worker then
catches the transport error caused by our own close, cannot distinguish it from
a transient network blip, and silently retries.

That retry is orphaned. The main loop has already given up (it only waits
HERMES_STREAM_ABORT_JOIN_TIMEOUT, 2s, for the thread to die), so when a slow
local model finally answers -- deepseek-v4-flash-w2 is a 149GB MoE that streams
experts off NVMe and can take minutes to first token -- the worker writes into a
turn that was finalised long ago. The desktop renders that late text into an
idle composer: content streams in while the UI shows the send affordance and
offers no way to interrupt.

Set _request_cancelled before the close, exactly as the interrupt path does
(NousResearch#6600). The worker then recognises the abort and exits without retrying. It has
to be set before the close, not after the join: the worker can raise and re-enter
its retry loop the instant the socket dies.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@OmarB97
OmarB97 merged commit 41afe89 into main Jul 14, 2026
2 of 3 checks passed
OmarB97 added a commit that referenced this pull request Jul 20, 2026
The first-chunk timeout force-closes the connection and breaks out with a
TimeoutError, but never marks the request cancelled. The streaming worker then
catches the transport error caused by our own close, cannot distinguish it from
a transient network blip, and silently retries.

That retry is orphaned. The main loop has already given up (it only waits
HERMES_STREAM_ABORT_JOIN_TIMEOUT, 2s, for the thread to die), so when a slow
local model finally answers -- deepseek-v4-flash-w2 is a 149GB MoE that streams
experts off NVMe and can take minutes to first token -- the worker writes into a
turn that was finalised long ago. The desktop renders that late text into an
idle composer: content streams in while the UI shows the send affordance and
offers no way to interrupt.

Set _request_cancelled before the close, exactly as the interrupt path does
(NousResearch#6600). The worker then recognises the abort and exits without retrying. It has
to be set before the close, not after the join: the worker can raise and re-enter
its retry loop the instant the socket dies.

Co-authored-by: Omar Baradei <omar@kostudios.io>
Co-authored-by: Claude Code <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant