Skip to content

fix: handle mid-stream socket termination gracefully#416

Merged
robert-j-y merged 3 commits intomainfrom
devin/1770920696-fix-mid-stream-termination-error
Apr 2, 2026
Merged

fix: handle mid-stream socket termination gracefully#416
robert-j-y merged 3 commits intomainfrom
devin/1770920696-fix-mid-stream-termination-error

Conversation

@robert-j-y
Copy link
Copy Markdown
Contributor

@robert-j-y robert-j-y commented Feb 12, 2026

Description

Fixes #412

When a TLS socket closes mid-stream during SSE streaming (e.g. upstream provider drops), the TypeError: terminated error bypasses the TransformStream's transform() and flush() callbacks entirely, causing a raw unhandled error to propagate to consumers with no structured error event and no finish event.

This PR wraps the response stream with a withStreamErrorHandling utility that catches read errors and closes the stream cleanly, allowing flush() to fire. In flush(), if a stream error was captured, an error event is emitted and finishReason is set to 'error' before the normal flush logic runs—preserving any partial content and accumulated state (usage, metadata, etc.).

Before:

stream → TypeError: terminated (thrown, no error event, no finish event)

After:

stream → text-delta("Hello") → text-delta(" World") → error(TypeError) → finish(reason: error, usage: {...})

Changes

  • src/utils/with-stream-error-handling.ts — New utility that wraps a ReadableStream to catch read errors via a pull()-based approach, calling an onError callback and closing cleanly. Releases the source reader lock on error via reader.cancel().
  • src/utils/with-stream-error-handling.test.ts — 6 co-located unit tests covering: healthy stream forwarding, mid-stream errors, errors before any chunks, empty streams, cancellation propagation, and reader release on error
  • src/chat/index.ts — Wrap response with withStreamErrorHandling before pipeThrough; check streamError at the top of flush()
  • src/completion/index.ts — Same pattern applied to the completion model
  • e2e/issues/issue-412-mid-stream-termination.test.ts — 4 regression tests using controlled-stream + TestResponseController.error()

Suggested review focus

  1. Error → finish event ordering in flush() — The streamError check emits { type: 'error' } and sets finishReason to 'error' at the top of flush(), before the Gemini 3 thoughtSignature override and other flush logic. Verify downstream consumers handle this ordering correctly. (The Gemini override only triggers on finishReason === 'stop', so it won't interfere.)
  2. reader.cancel().catch(() => {}) — Cancelling an already-errored stream's reader rejects with the stored error, so the .catch() is needed to prevent unhandled rejections. Worth confirming this doesn't mask errors that should be surfaced.
  3. Catches all stream errors, not just TypeError: terminated — The wrapper catches any error thrown during reader.read(). This is intentional (any mid-stream failure should be handled gracefully) but worth confirming this is desired.
  4. Completion model e2e test coverage — Only the chat model path has e2e tests. The completion model integration is identical but has no dedicated e2e test (the utility itself is unit-tested).

Checklist

  • I have run pnpm stylecheck and pnpm typecheck
  • I have run pnpm test and all tests pass
  • I have added tests for my changes (if applicable)
  • I have updated documentation (if applicable)

Changeset

  • I have run pnpm changeset to create a changeset file

Link to Devin run | Requested by @robert-j-y

devin-ai-integration Bot and others added 2 commits February 12, 2026 18:29
When a TLS socket closes mid-stream during SSE streaming, the
TypeError now gets caught and converted into a structured error
event followed by a proper finish event with finishReason 'error'.

This preserves any partial content already streamed before the
connection drop, and ensures flush() fires to emit accumulated
state (usage, metadata, etc.).

Fixes #412

Co-Authored-By: Robert Yeakel <robert.yeakel@openrouter.ai>
Co-Authored-By: Robert Yeakel <robert.yeakel@openrouter.ai>
@ldriss
Copy link
Copy Markdown

ldriss commented Feb 18, 2026

Hi @robert-j-y Any updates on this

@robert-j-y robert-j-y requested a review from mattapperson March 7, 2026 00:07
devin-ai-integration[bot]

This comment was marked as resolved.

- Add reader.cancel() in catch block to release reader lock on error
- Add with-stream-error-handling.test.ts with 6 unit tests covering:
  healthy stream forwarding, mid-stream errors, errors before chunks,
  empty streams, cancellation propagation, and reader release on error

Co-Authored-By: Robert Yeakel <robert.yeakel@openrouter.ai>
@robert-j-y
Copy link
Copy Markdown
Contributor Author

Addressing "Suggested review focus"

1. Error → finish event ordering in flush()

Verified correct. The streamError check at the top of flush() emits { type: 'error' } and sets finishReason to 'error' before any other flush logic. The Gemini 3 thoughtSignature override only triggers on finishReason === 'stop', so it won't interfere with error finishes. The ordering ensures consumers always see the error event before the finish event, which is the expected contract.

2. reader.cancel().catch(() => {})

Correct and necessary. When a stream reader's source has already errored, calling reader.cancel() returns a promise that rejects with the stored error. Without .catch(() => {}), this would cause an unhandled promise rejection. The error itself is already surfaced through the onError callback — the .catch() only suppresses the duplicate rejection from the cancel operation. This doesn't mask any errors that should be surfaced.

3. Catches all stream errors, not just TypeError: terminated

Intentional and correct. Any mid-stream failure (TLS termination, network drops, server reset, etc.) should be handled gracefully rather than propagating as a raw unhandled error. The withStreamErrorHandling wrapper catches any error during reader.read(), which is the right behavior — we want structured error events for all stream failures, not just TypeError: terminated.

4. Completion model e2e test coverage

Acceptable. The chat model has dedicated e2e tests. The completion model uses the identical withStreamErrorHandling wrapper with the same integration pattern (src/completion/index.ts). The utility itself has 6 co-located unit tests covering all edge cases. Adding a separate completion e2e test would be redundant given the shared utility is already thoroughly tested.

@robert-j-y robert-j-y merged commit fece5d0 into main Apr 2, 2026
3 checks passed
@robert-j-y robert-j-y deleted the devin/1770920696-fix-mid-stream-termination-error branch April 2, 2026 16:51
@github-actions github-actions Bot mentioned this pull request Apr 2, 2026
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.

TypeError: terminated — TLS socket closes mid-stream during SSE response

2 participants