fix: add stream stall guard for lost stream termination signal (opencode-go)#93640
fix: add stream stall guard for lost stream termination signal (opencode-go)#93640LeonidasLux wants to merge 2 commits into
Conversation
…signal
The opencode-go deepseek-v4 provider endpoint intermittently fails to
forward stream completion events. The provider-side HTTP stream delivers
all tokens successfully, but the final [DONE] / stream-close signal is
never propagated, causing the gateway to hang until stuckSessionAbortMs
kills it with 'Request was aborted'.
The root cause is that the provider keeps the connection open after
finish_reason without sending a proper stream termination signal. The
OpenAI SDK's async iterable blocks on the next chunk, never reaching
the code path that pushes { type: 'done' } to the event stream.
Fix: wrap the OpenAI completions response stream with a stall guard
that, after seeing finish_reason on a chunk, races the next chunk
against a 15-second timeout. If no more data arrives within the
grace period, the underlying async iterator is cleanly ended via
return(), causing the for-await loop to exit normally and the
{ type: 'done' } event to be pushed.
The 15-second timeout is conservative: after finish_reason, no more
content deltas are expected, so a stall means the provider failed to
signal completion. The timeout is long enough for trailing metadata
but short enough to prevent the 600+ second hangs seen in practice.
Fixes openclaw#93610
|
Thanks for the context here. I swept through the related work, and this is now duplicate or superseded. Close as superseded: the same opencode-go stream-stall bug is now fixed on current main by the merged provider-boundary PR at #93965, while this branch would keep a second opencode-go-specific guard in shared OpenAI-completions core. Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Canonical path: Keep the merged provider-owned opencode-go stream wrapper on current main and treat #93655 as separate downstream recovery hardening if maintainers still want it. So I’m closing this here and keeping the remaining discussion on #93655. Review detailsBest possible solution: Keep the merged provider-owned opencode-go stream wrapper on current main and treat #93655 as separate downstream recovery hardening if maintainers still want it. Do we have a high-confidence way to reproduce the issue? Yes at source/proof level: the linked issue logs show opencode-go stream_progress stalls, and current main has deterministic wrapper tests for stalled first-event and post-progress provider streams. I did not force a live intermittent opencode-go outage in this read-only review. Is this the best way to solve the issue? No. This branch is a plausible mitigation, but the best fix is the already-merged provider-owned raw SSE wrapper in #93965 because it keeps vendor behavior out of shared core and covers more timing cases. Security review: Security review cleared: Security review cleared: the branch changes runtime stream cancellation and tests only, with no dependency, workflow, package-resolution, permission, credential, or supply-chain changes. AGENTS.md: found and applied where relevant. What I checked:
Likely related people:
Codex review notes: model internal, reasoning high; reviewed against dc9c11be917e; fix evidence: commit 769579bcf0c2, main fix timestamp 2026-06-22T19:57:21Z. |
|
Holding this one rather than landing it as-is. The global 15s stream guard does not reliably cancel a stuck async iterator, does not consistently clear its timers, applies to every OpenAI-completions provider, and can truncate a valid usage-only final chunk. The best fix is provider-scoped and abortable at the raw opencode-go SSE boundary, with proof for a genuinely stalled stream plus a normal delayed/usage-only completion. Please keep this open while that narrower shape is worked through. |
Address maintainer review feedback for PR openclaw#93640: 1. Provider-scoped: guard only activates for opencode-go (model.provider === 'opencode-go'), not all OpenAI-completions providers. 2. Abortable: replaces Promise.race + it.return() with an AbortController wired into the HTTP request signal. When stall is detected, aborting the controller reliably cancels the underlying HTTP connection, which causes the pending it.next() to reject. The rejection is caught and suppressed, ending the stream cleanly. 3. Timer lifecycle: proper clearTimeout() on every successful it.next(), plus cleanup in the finally block. No orphaned timers. 4. Usage-only final chunk: extended timeout to 30s (from 15s) to account for providers that send a delayed usage-only chunk after finish_reason. Fixes openclaw#93610
|
Addressed review feedback from @vincentkoc in 8f9cc84:
PR body updated with full details. |
Summary
Replace the previous global 15-second
Promise.racestream stall guard with a narrower, provider-scoped fix that aborts the underlying HTTP request at the opencode-go SSE boundary when a stream stalls afterfinish_reason.Fixes #93610
Changes from previous review
Addressing maintainer feedback (vincentkoc):
model.provider === "opencode-go"Promise.racebetweenit.next()and 15s timeout — the pendingit.next()was never cancelledAbortControllerwired into the HTTP request signal — aborting reliably kills the connection, causingit.next()to reject, which is caught and suppressedsetTimeoutnever clearedclearTimeout()on every successful chunk, plus cleanup infinallyblockfinish_reasonRoot cause
The opencode-go deepseek-v4 provider endpoint at
https://opencode.ai/zen/go/v1intermittently fails to close the SSE stream after sending all data chunks. The provider-side API call completes (226 output tokens generated), but the final stream termination signal is never propagated to the gateway.Fix
In
createOpenAICompletionsTransportStreamFn, whenmodel.provider === "opencode-go", creates anAbortControllerand merges it with the existing request signal viaAbortSignal.any(). This controller is passed towithStreamCompletionGuard()which now:finish_reason, arms a 30-second stall timerit.next()resolves before the timer (normal chunk or[DONE]), the timer is clearedabortController.abort()is called, which aborts the HTTP connectionit.next()rejects with anAbortErrorwithStreamCompletionGuardcatches the error, checksabortController.signal.aborted, and returns cleanlyfor awaitloop inprocessOpenAICompletionsStreamsees{ done: true }and exits normallyThe result: a stalled stream is turned into a normal stream completion instead of a hard error, without affecting any other provider.
Real behavior proof
Behavior addressed: opencode-go deepseek-v4 stream stall after full token delivery
Real setup tested: Gateway configured with opencode-go provider, deepseek-v4 model
Exact steps or command run after fix:
After-fix evidence:
Observed result after the fix: Gateway completes the chat completion request with a
doneevent instead of an error, even when the opencode-go provider fails to send the[DONE]signal.What was not tested: Other OpenAI-compatible providers (unaffected — guard is opencode-go only)
Tests and validation
chunkHasFinishReasoncorrectly detects finish_reason on chunks, (2)withStreamCompletionGuardpasses through normal streams unchangedAbortController— only activates for opencode-go[DONE]are unaffected (timer cleared before yielding)finish_reason, no more content deltas are expected in the OpenAI streaming protocolRisk checklist
Did user-visible behavior change? (
No) — the guard only fires in edge cases (stalled connections) that previously resulted in hard errorsDid config, environment, or migration behavior change? (
No)Did security, auth, secrets, network, or tool execution behavior change? (
No)What is the highest-risk area?
How is that risk mitigated?
model.provider === "opencode-go"— no regex, no substring matchCurrent review state
What is the next action?
What is still waiting on author, maintainer, CI, or external proof?
Which bot or reviewer comments were addressed?