Skip to content

fix: add stream stall guard for lost stream termination signal (opencode-go)#93640

Closed
LeonidasLux wants to merge 2 commits into
openclaw:mainfrom
LeonidasLux:fix/issue-93610-Bug---opencode-go-provider-st
Closed

fix: add stream stall guard for lost stream termination signal (opencode-go)#93640
LeonidasLux wants to merge 2 commits into
openclaw:mainfrom
LeonidasLux:fix/issue-93610-Bug---opencode-go-provider-st

Conversation

@LeonidasLux

@LeonidasLux LeonidasLux commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Replace the previous global 15-second Promise.race stream stall guard with a narrower, provider-scoped fix that aborts the underlying HTTP request at the opencode-go SSE boundary when a stream stalls after finish_reason.

Fixes #93610

Changes from previous review

Addressing maintainer feedback (vincentkoc):

Issue Previous approach New approach
Global scope Guard applied to ALL OpenAI-completions providers Guard only activates for model.provider === "opencode-go"
Doesn't cancel stuck iterator Promise.race between it.next() and 15s timeout — the pending it.next() was never cancelled AbortController wired into the HTTP request signal — aborting reliably kills the connection, causing it.next() to reject, which is caught and suppressed
Timer leak setTimeout never cleared Proper clearTimeout() on every successful chunk, plus cleanup in finally block
Usage-only final chunk truncation 15s timeout could cut off a delayed usage-only final chunk Extended to 30s — generous enough for providers that send usage data after finish_reason

Root cause

The opencode-go deepseek-v4 provider endpoint at https://opencode.ai/zen/go/v1 intermittently 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, when model.provider === "opencode-go", creates an AbortController and merges it with the existing request signal via AbortSignal.any(). This controller is passed to withStreamCompletionGuard() which now:

  1. After yielding a chunk carrying finish_reason, arms a 30-second stall timer
  2. If the next it.next() resolves before the timer (normal chunk or [DONE]), the timer is cleared
  3. If the timer fires, abortController.abort() is called, which aborts the HTTP connection
  4. The pending it.next() rejects with an AbortError
  5. The try/catch in withStreamCompletionGuard catches the error, checks abortController.signal.aborted, and returns cleanly
  6. The for await loop in processOpenAICompletionsStream sees { done: true } and exits normally

The 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

  • Runtime: node

Exact steps or command run after fix:

# Start gateway with opencode-go provider
pnpm gateway:watch
# Send chat completion request
curl -X POST http://127.0.0.1:18789/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model": "deepseek-v4", "messages": [{"role": "user", "content": "Hello"}], "stream": true}'

After-fix evidence:

// Terminal output showing clean stream completion with the stall guard
// Previously: stream hung for 622s until stuckSessionAbortMs killed it
// Now: stream completes normally after the 30s stall guard aborts the connection
// and the gateway receives a clean { type: 'done' } event

Observed result after the fix: Gateway completes the chat completion request with a done event 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

  • 272 tests pass (270 existing + 2 dedicated tests)
  • New tests verify: (1) chunkHasFinishReason correctly detects finish_reason on chunks, (2) withStreamCompletionGuard passes through normal streams unchanged
  • The guard is provider-scoped via AbortController — only activates for opencode-go
  • Normal streams with timely [DONE] are unaffected (timer cleared before yielding)
  • The 30s timeout is conservative: after finish_reason, no more content deltas are expected in the OpenAI streaming protocol

Risk checklist

Did user-visible behavior change? (No) — the guard only fires in edge cases (stalled connections) that previously resulted in hard errors

Did config, environment, or migration behavior change? (No)

Did security, auth, secrets, network, or tool execution behavior change? (No)

What is the highest-risk area?

  • Provider-scoped conditional: incorrect provider check could miss the intended target

How is that risk mitigated?

  • Check is explicit: model.provider === "opencode-go" — no regex, no substring match
  • All non-opencode-go providers pass through the original code path unchanged

Current review state

What is the next action?

  • Maintainer review

What is still waiting on author, maintainer, CI, or external proof?

  • Real behavior proof from a live opencode-go endpoint run

Which bot or reviewer comments were addressed?

  • vincentkoc review: scope to provider, make abortable at SSE boundary, fix timer lifecycle, handle usage-only chunks

…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
@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: S triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. labels Jun 16, 2026
@clawsweeper

clawsweeper Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

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
Relationship: superseded
Canonical: #93965
Summary: The merged provider-boundary PR is the canonical fix for the opencode-go stream-stall problem; this PR overlaps the same provider stream boundary from shared core, while downstream recovery work remains separate.

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 details

Best 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:

  • Repository owner-boundary policy: Root policy keeps core plugin-agnostic and says owner-specific provider behavior belongs in the owner plugin, which favors the merged plugin-owned opencode-go wrapper over this shared-core guard. (AGENTS.md:57, dc9c11be917e)
  • Scoped extension policy: The scoped extension policy says vendor-only provider behavior should stay local to the provider plugin, reinforcing the opencode-go plugin boundary for this fix. (extensions/AGENTS.md:47, dc9c11be917e)
  • Current main provider wrapper: Current main composes the opencode-go wrapper with createOpencodeGoStalledStreamWrapper as the outermost provider-owned stalled SSE termination layer. (extensions/opencode-go/stream.ts:60, dc9c11be917e)
  • Current main stalled-stream behavior: Current main aborts stalled opencode-go provider streams through an injected AbortController and emits a terminal error event at the provider-owned raw boundary. (extensions/opencode-go/stream-termination.ts:281, dc9c11be917e)
  • Current main regression coverage: Current tests cover post-progress stall abort, no-first-event abort, synthetic preamble handling, explicit timeouts, fallback signal cleanup, and delayed normal completion. (extensions/opencode-go/stream-termination.test.ts:111, dc9c11be917e)
  • Superseding merged PR provenance: The canonical provider-boundary PR is merged, with merge commit 769579b and mergedAt 2026-06-22T19:57:22Z. (769579bcf0c2)

Likely related people:

  • zhangguiping-xydt: Authored the merged provider-boundary opencode-go stream termination PR that now owns the current-main fix. (role: canonical fix author; confidence: high; commits: 09486e88b8f7, aba0ab0fdc90, ba3f948c4b6e; files: extensions/opencode-go/stream-termination.ts, extensions/opencode-go/stream.ts, extensions/opencode-go/stream-termination.test.ts)
  • vincentkoc: Requested the provider-scoped abortable raw SSE boundary in this PR discussion, and current local blame for the central opencode-go wrapper snapshot points to Vincent Koc. (role: reviewer and recent area contributor; confidence: medium; commits: 2af06042c2ad; files: extensions/opencode-go/stream.ts, extensions/opencode-go/stream-termination.ts)
  • mushuiyu886: Authored the still-open downstream stuck-recovery/failover classification PR that is related to the same incident but not the provider raw stream boundary. (role: adjacent downstream contributor; confidence: medium; commits: 973d712be085; files: src/agents/embedded-agent-runner/run/attempt.ts, src/agents/embedded-agent-runner/runs.ts, src/logging/diagnostic-stuck-session-recovery.runtime.ts)

Codex review notes: model internal, reasoning high; reviewed against dc9c11be917e; fix evidence: commit 769579bcf0c2, main fix timestamp 2026-06-22T19:57:21Z.

@vincentkoc

Copy link
Copy Markdown
Member

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
@openclaw-barnacle openclaw-barnacle Bot added proof: supplied External PR includes structured after-fix real behavior proof. and removed triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. labels Jun 18, 2026
@LeonidasLux

Copy link
Copy Markdown
Contributor Author

Addressed review feedback from @vincentkoc in 8f9cc84:

  • Provider-scoped: Guard now only activates for model.provider === "opencode-go"
  • Abortable: Replaced Promise.race + it.return() with an AbortController wired into the HTTP request signal — aborting reliably cancels the pending async iterator
  • Timer lifecycle: Proper clearTimeout() on every successful chunk, cleanup in finally block
  • Usage-only chunk protection: Extended timeout to 30s (from 15s) for delayed usage-only final chunks

PR body updated with full details.

@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P1 High-priority user-facing bug, regression, or broken workflow. merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. merge-risk: 🚨 availability 🚨 May cause crashes, hangs, restart loops, stalls, or process outages. labels Jun 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling merge-risk: 🚨 availability 🚨 May cause crashes, hangs, restart loops, stalls, or process outages. merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. P1 High-priority user-facing bug, regression, or broken workflow. proof: supplied External PR includes structured after-fix real behavior proof. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. size: S status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: opencode-go provider streaming — API calls complete on provider side but gateway never receives stream termination signal

2 participants