fix: abort SSE event subscription on stream close so process can exit#30
Merged
Merged
Conversation
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
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.
This was referenced Jun 11, 2026
Merged
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.
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 likenpx tsx examples/tool-observation.tsprintfinish: stopand 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, andimage-input.tshung deterministically after printing all correct output;abort-signal.tshung flakily.Root cause
doStreamcalledclient.event.subscribe(...)with noAbortSignal. The@opencode-ai/sdkSSE implementation (createSseClient) only cancels its fetch body reader inside an abort-signal handler. When the provider closes the generator viaiterator.return(), the generator'sfinallyblock runsreader.releaseLock()but never callsreader.cancel(), so the underlying SSE socket stays open and keeps the Node event loop alive. (The provider uses the/v2SDK 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,
autoStartServerspawns a child server anddispose()callsserver.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 preconfiguredclient, or evenautoStartServer: truesilently reusing a server already running on port 4096 (anyone withopencode serveup) all leaked on everystreamText()call.Fix
AbortControllerper stream indoStreamand pass its signal as the fetch options argument toclient.event.subscribecloseIterator— beforeiterator.return(), since the abort also unblocks a pendingiterator.next()thatreturn()would otherwise queue behind — and again (idempotently) in the stream's outerfinally, which also covers errors thrown between subscribe and the event loopOpencodeClientManagervia a newregisterEventSubscription()sodispose()aborts any SSE connections still open, including against non-managed (reused) serversVerification
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.tsreusing an externally started OpenCode server on 127.0.0.1:4096 now exits on its own with code 0 in ~8s total, immediately after thefinish: stop/usage lines (previously hung indefinitely)abort-signal.tshang 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 hangsnpm test: all 371 tests pass;tsc --noEmitandeslintclean