Skip to content

fix(server): bound client-facing SSE frame retention - #1241

Merged
Wibias merged 23 commits into
devfrom
maintainer/1095-sse-frame-bounds
Aug 7, 2026
Merged

fix(server): bound client-facing SSE frame retention#1241
Wibias merged 23 commits into
devfrom
maintainer/1095-sse-frame-bounds

Conversation

@Wibias

@Wibias Wibias commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Maintainer follow-up from the full audit of #1095.

#1095's DeepSeek-specific terminal-repair state machine is no longer the right implementation: dev already restored progressive DeepSeek Responses streaming in 0b8e608c06a4a81ba676019ee99b10b6e201dcd1 by relying on the existing terminal-event boundary instead of synthesizing delayed success.

During the takeover review, that replacement path exposed one independent availability/security gap: client-facing HTTP and WebSocket SSE framing could retain an arbitrarily large unterminated upstream frame.

This PR:

  • adds a raw-byte BoundedSseFrameBuffer with a 4 MiB hard per-frame cap;
  • applies it to the HTTP Responses terminal boundary;
  • applies it to the Responses WebSocket SSE pump;
  • preserves complete frame bytes and split SSE delimiters exactly;
  • clears retained state before throwing on overflow so failure tails cannot echo the oversized frame;
  • preserves an already-committed Responses terminal if oversized trailing bytes arrive in the same upstream chunk;
  • cancels the upstream WebSocket reader on every pump exit, including framing failures;
  • documents the 4 MiB client-facing frame limit and failure semantics in the English proxy reference and synchronized ja/ko/ru/zh-cn references;
  • adds regression coverage for exact-cap acceptance, cap+1 rejection, fragmented UTF-8, HTTP fail-closed behaviour, terminal-before-overflow behaviour, and WebSocket protocol-error/cancellation behaviour.

Security / resource rationale

A malicious or broken upstream could previously keep sending bytes without an SSE frame delimiter and grow the client-output buffer without a hard bound. Inspection already had a 4 MiB frame cap, but the output fanout did not. This aligns the client-facing paths with that existing memory envelope.

The frame-count guard also prevents delimiter-only input from amplifying a bounded byte chunk into an unbounded number of frame objects. Candidate storage is intentionally released after each completed frame rather than reused, so a rare multi-MiB frame cannot pin its peak allocation for the rest of a long-lived response.

Relation to #1095

Verification

  • Focused frame-bound regressions cover raw-byte limits, fragmented delimiters/UTF-8, failed-tail cleanup, same-chunk terminal + overflow ordering, WebSocket send-drop handling, and upstream cancellation.
  • All CodeRabbit and Codex inline findings have been verified against the current implementation, fixed where valid, or explicitly closed where the retention/allocation trade-off is intentional.
  • Final merge gate: React Doctor and Cross-platform CI must be green on the final head.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds bounded byte-based SSE framing. The shared framer handles fragmented delimiters, frame limits, finalization, and disposal. HTTP relay inspection and WebSocket bridging use it for terminal-event processing and oversized-frame handling.

Changes

Bounded SSE framing

Layer / File(s) Summary
SSE frame buffer contract and implementation
src/server/sse-frame-buffer.ts
Adds BoundedSseFrameBuffer, frame-size validation, fragmented delimiter handling, finalization, disposal, and byte-array joining.
Relay and WebSocket integration
src/server/relay.ts, src/server/ws-bridge.ts
Replaces retained slice arrays and unbounded string buffering with bounded byte buffers. Complete frames are decoded and processed, trailing frames are finalized, and framing state is disposed on completion or error.
Frame-bound validation
tests/sse-client-frame-bounds.test.ts
Tests exact-limit frames, split delimiters, fragmented UTF-8 data, oversized HTTP relay frames, and WebSocket protocol errors.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant UpstreamSSE
  participant BoundedSseFrameBuffer
  participant RelayOrWebSocketBridge
  UpstreamSSE->>BoundedSseFrameBuffer: send raw byte chunks
  BoundedSseFrameBuffer->>RelayOrWebSocketBridge: emit complete bounded frames
  RelayOrWebSocketBridge->>RelayOrWebSocketBridge: decode and process terminal events
  UpstreamSSE->>BoundedSseFrameBuffer: complete stream
  BoundedSseFrameBuffer->>RelayOrWebSocketBridge: return final tail
  RelayOrWebSocketBridge->>BoundedSseFrameBuffer: dispose framing state
Loading

Possibly related PRs

Suggested reviewers: lidge-jun, ingwannu

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: bounding client-facing SSE frame retention in the server.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch maintainer/1095-sse-frame-bounds

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the bug Something isn't working label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

Wibias commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/server/relay.ts (1)

129-140: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

The [DONE] forwarding is correct but the split push obscures it. Consolidate the decision.

I traced both branches and the byte output is right:

  • If responsesTerminal is false and the payload is [DONE], Line 131 pushes the frame and Line 134 does not.
  • If responsesTerminal is true and the payload is [DONE], Line 131 skips and Line 134 pushes.

So [DONE] is emitted exactly once in both orders, and non-[DONE] frames after the terminal are dropped. That matches the contract stated on Lines 112-114.

The problem is that two output.push(frame.block, frame.delimiter) calls sit under inverted conditions inside the same loop. A reader scanning this will reasonably suspect a duplicate emission, and a future edit to either condition can introduce one. Express the forwarding rule once.

Note that the decoder use here is sound: frame.block always holds complete frame bytes, so the one-shot decoder.decode on Line 130 cannot split a multibyte sequence. That is the property the byte framer buys, and it is worth keeping intact in any rewrite.

♻️ Proposed consolidation
     for (const frame of frames) {
       const payload = sseDataPayload(decoder.decode(frame.block));
-      if (!responsesTerminal) output.push(frame.block, frame.delimiter);
-      if (payload === "[DONE]") {
-        done = true;
-        if (responsesTerminal) output.push(frame.block, frame.delimiter);
-        continue;
-      }
-      if (!responsesTerminal && payload && terminalStatusFromSsePayload(payload)) {
-        responsesTerminal = true;
-      }
+      const isDone = payload === "[DONE]";
+      // Relay every frame through the first Responses terminal. After that
+      // terminal, relay only the [DONE] sentinel and drop everything else.
+      if (!responsesTerminal || isDone) output.push(frame.block, frame.delimiter);
+      if (isDone) {
+        done = true;
+        continue;
+      }
+      if (!responsesTerminal && payload && terminalStatusFromSsePayload(payload)) {
+        responsesTerminal = true;
+      }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/relay.ts` around lines 129 - 140, Consolidate the two conditional
output.push calls in the frames loop into one forwarding decision: emit the
current frame when responsesTerminal is false or payload is "[DONE]". Preserve
the existing terminal-state transition, dropping non-"[DONE]" frames after
terminal status, and keep the one-shot decoder.decode(frame.block) behavior
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/server/relay.ts`:
- Around line 153-156: Make the boundary’s finish() method total by handling
SseFrameTooLargeError from framer.finish() and returning an empty Uint8Array,
while preserving the existing disposed/terminal guard and successful output.
Update the finish() symbol in the relay boundary; do not modify ws-bridge.ts
because its existing try/catch already handles the exception.

In `@src/server/sse-frame-buffer.ts`:
- Around line 18-22: Update delimiterLengthAt to return number | undefined
instead of the redundant number | 0 | undefined annotation, and add a doc
comment documenting the three runtime states: positive delimiter length, 0 when
no delimiter starts at the index, and undefined when more bytes are required to
decide.
- Around line 89-102: Update ensureCapacity to explicitly reject requiredBytes
values greater than this.maxFrameBytes before entering the growth loop, failing
loudly rather than allowing an unbounded loop. Preserve the existing
capacity-growth behavior for valid requests.
- Around line 117-123: Update takeCandidate() to retain this.candidate after
slicing and reset only candidateBytes, allowing the geometrically grown buffer
to be reused across frames. Preserve clear() and dispose() behavior so they
continue releasing the buffer at end of stream.
- Around line 147-175: Add a per-call frame-count cap to the feed loop in the
SSE frame buffer, deriving it from maxFrameBytes so delimiter-only input cannot
produce unbounded frames; preserve existing empty-frame passthrough behavior and
ensure the cap is enforced before adding another frame. In the delimiter
handling around takeCandidate and copyRange, reuse shared constants for the
common delimiter byte sequences instead of allocating identical delimiter arrays
for every frame.

In `@src/server/ws-bridge.ts`:
- Around line 275-284: Update the cleanup in the finally block of the
reader-processing flow to call reader.cancel() before clearing ws.data.cancel,
ensuring exceptions from framer.feed or framer.finish release the upstream
stream. Preserve the existing cancellation behavior and cleanup for all other
termination paths.

---

Outside diff comments:
In `@src/server/relay.ts`:
- Around line 129-140: Consolidate the two conditional output.push calls in the
frames loop into one forwarding decision: emit the current frame when
responsesTerminal is false or payload is "[DONE]". Preserve the existing
terminal-state transition, dropping non-"[DONE]" frames after terminal status,
and keep the one-shot decoder.decode(frame.block) behavior unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2a4d65d0-e434-41a6-a1b0-0045aa49b012

📥 Commits

Reviewing files that changed from the base of the PR and between 9326b85 and 6d4c16a.

📒 Files selected for processing (4)
  • src/server/relay.ts
  • src/server/sse-frame-buffer.ts
  • src/server/ws-bridge.ts
  • tests/sse-client-frame-bounds.test.ts

Comment thread src/server/relay.ts
Comment thread src/server/sse-frame-buffer.ts Outdated
Comment thread src/server/sse-frame-buffer.ts
Comment thread src/server/sse-frame-buffer.ts
Comment thread src/server/sse-frame-buffer.ts Outdated
Comment thread src/server/ws-bridge.ts
@Wibias
Wibias marked this pull request as ready for review August 7, 2026 21:22

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7ba0225c8d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/server/relay.ts
Comment thread src/server/ws-bridge.ts
Comment thread src/server/sse-frame-buffer.ts

Wibias commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Wibias commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Wibias commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Maintainer takeover review complete on final head 55d134089044b7a176ae28ce374b6f042756b349.

Audited the superseding path for framing correctness, memory/resource bounds, delimiter and UTF-8 fragmentation, terminal ordering, failure-tail behavior, WebSocket cleanup/cancellation, hostile delimiter amplification, and reviewer/bot findings. Valid findings are fixed with focused regressions; the intentional per-frame candidate release remains as a bounded-memory trade-off and CodeRabbit withdrew that allocation-reuse suggestion.

Closeout also documents the 4 MiB client-facing Responses SSE frame cap and HTTP/WebSocket failure semantics in the canonical proxy reference plus ja/ko/ru/zh-cn locale copies. All inline CodeRabbit/Codex threads are resolved.

Final gates on this head: React Doctor ✅, Cross-platform CI ✅, CodeRabbit status ✅. PR is mergeable and remains open for the requested human reviews.

@Wibias
Wibias merged commit 4a688c0 into dev Aug 7, 2026
24 checks passed
@Wibias
Wibias deleted the maintainer/1095-sse-frame-bounds branch August 7, 2026 23:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant