fix(stream): cancel the worker on a dflash first-chunk timeout (turn finalises, worker keeps streaming) - #277
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_callhas 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:
The interrupt path sets it before closing:
The dflash first-chunk timeout path does not:
So the sequence is:
_request_cancelled == False, calls it a transient network error, and retries in the background.resultis still empty → webreakwithTimeoutErrorand the turn is finalised.deepseek-v4-flash-w2is 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"] = Truebefore 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:
Mutation check — the test is not vacuous. Reverting only the one added line:
No regressions across the streaming/interrupt/timeout surface:
Risks / notes
resultis populated and the loop continues as before; the flag is inert in that case because the worker has already returned.