Skip to content

fix: abort SSE event subscription on stream close so process can exit#30

Merged
ben-vargas merged 1 commit into
mainfrom
worktree-fix-sse-event-stream-leak
Jun 11, 2026
Merged

fix: abort SSE event subscription on stream close so process can exit#30
ben-vargas merged 1 commit into
mainfrom
worktree-fix-sse-event-stream-leak

Conversation

@ben-vargas

@ben-vargas ben-vargas commented Jun 11, 2026

Copy link
Copy Markdown
Owner

Problem

Any Node process using the provider against an external OpenCode server hangs after a streamText() call completes: the event loop never drains, so scripts like npx tsx examples/tool-observation.ts print finish: stop and the usage line but never exit. The process holds an ESTABLISHED TCP connection to the OpenCode server indefinitely (13+ minutes observed).

Found during a post-release validation sweep of all 12 examples for v3.0.5, which ran examples against one shared, externally started server (opencode serve --port 4096) instead of letting each example auto-start its own. streaming.ts, tool-observation.ts, and image-input.ts hung deterministically after printing all correct output; abort-signal.ts hung flakily.

Root cause

doStream called client.event.subscribe(...) with no AbortSignal. The @opencode-ai/sdk SSE implementation (createSseClient) only cancels its fetch body reader inside an abort-signal handler. When the provider closes the generator via iterator.return(), the generator's finally block runs reader.releaseLock() but never calls reader.cancel(), so the underlying SSE socket stays open and keeps the Node event loop alive. (The provider uses the /v2 SDK entry point; its SSE client has the same behavior as the v1 one.)

Why this went unnoticed

This is not a v3.0.4/v3.0.5 regression — the un-aborted subscribe has been present since the initial release (14068fc). On the default path, autoStartServer spawns a child server and dispose() calls server.close(); killing the server tears down every socket to it as a side effect, including the leaked SSE socket, so the process exits and the leak is masked.

On the external-server path, dispose() correctly does not close a server it didn't spawn — so nothing tore down the leaked socket. This path is first-class and easy to hit: baseUrl, a preconfigured client, or even autoStartServer: true silently reusing a server already running on port 4096 (anyone with opencode serve up) all leaked on every streamText() call.

Fix

  • Create an AbortController per stream in doStream and pass its signal as the fetch options argument to client.event.subscribe
  • Abort it in closeIterator — before iterator.return(), since the abort also unblocks a pending iterator.next() that return() would otherwise queue behind — and again (idempotently) in the stream's outer finally, which also covers errors thrown between subscribe and the event loop
  • Track active subscription controllers in OpencodeClientManager via a new registerEventSubscription() so dispose() aborts any SSE connections still open, including against non-managed (reused) servers

Verification

Note: reproducing the hang (or verifying the fix) requires an externally started server — the auto-start path masks the leak via server shutdown, so it proves nothing.

  • npx tsx examples/tool-observation.ts reusing an externally started OpenCode server on 127.0.0.1:4096 now exits on its own with code 0 in ~8s total, immediately after the finish: stop/usage lines (previously hung indefinitely)
  • In the validation sweep, the previously flaky abort-signal.ts hang did not reproduce with this fix built into dist (6/6 watchdogged runs exited cleanly in 3–10s), so this leak fully accounts for the observed hangs
  • npm test: all 371 tests pass; tsc --noEmit and eslint clean

The SDK's SSE generator only cancels its fetch reader from an
abort-signal handler; closing the iterator alone runs releaseLock()
without cancel(), leaving the socket open. Every completed stream
therefore held an ESTABLISHED connection to the OpenCode server that
kept the Node event loop alive indefinitely after streamText() finished.

- Create an AbortController per stream in doStream and pass its signal
  to client.event.subscribe
- Abort it in closeIterator (before iterator.return(), which also
  unblocks a pending next()) and in the stream's outer finally
- Track active subscription controllers in OpencodeClientManager so
  dispose() tears down SSE connections to non-managed servers
@ben-vargas
ben-vargas merged commit cdc1e76 into main Jun 11, 2026
3 checks passed
@ben-vargas
ben-vargas deleted the worktree-fix-sse-event-stream-leak branch June 11, 2026 15:55
ben-vargas added a commit that referenced this pull request Jun 11, 2026
…oGenerate abort signal (#31)

* fix: cancel prompt and session-abort requests on stream close; wire doGenerate abort signal

Follow-up to #30, which fixed the leaked GET /event SSE connection that
kept the Node event loop alive after streaming finished. This extends
the same per-stream AbortController to the remaining unanchored
requests:

- session.prompt and session.abort in doStream now receive the stream's
  abort signal, so a prompt the server never completes (e.g. after an
  abort race) cannot pin the event loop, and dispose() tears these
  requests down along with the SSE subscription. Prompt results that
  arrive after an intentional close are ignored instead of being
  mis-reported as empty-response errors (the SDK resolves with
  { error } rather than rejecting on fetch abort).

- doGenerate now forwards options.abortSignal to the prompt request, so
  aborting generateText cancels the underlying HTTP request instead of
  leaving it pending. A caller-initiated abort surfaces as AbortError
  (previously a generic empty-response error) and best-effort aborts
  the server-side session to stop generation.

Adds the regression tests #30 lacked: SSE signal cancellation on stream
close (including abort-before-iterator.return ordering), prompt-fetch
cancellation on the abort and completion paths, doGenerate mid-flight
abort semantics, and manager dispose() aborting registered controllers.
Also adds the missing CHANGELOG entries for #30 and this change.

* fix: abort server session when throwOnError client rejects on doGenerate abort

Codex review on #31 flagged that clients configured with the supported
clientOptions.throwOnError passthrough reject the session.prompt await
on fetch abort instead of resolving a fields-style { error } result, so
the best-effort session.abort in the empty-data path never ran and the
server kept generating after generateText was cancelled.

Hoist the server-side abort into a helper and issue it from both the
rejection path and the { error } path before rethrowing.
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