Skip to content

fix: cap SSE line and event buffer - #4

Open
mrgoonie wants to merge 4 commits into
claude/dewee-chrome-ext-bootstrap-58115dfrom
fix/2-sse-buffer-cap
Open

mrgoonie wants to merge 4 commits into
claude/dewee-chrome-ext-bootstrap-58115dfrom
fix/2-sse-buffer-cap

Conversation

@mrgoonie

Copy link
Copy Markdown
Contributor

Stacked on #3 (base claude/dewee-chrome-ext-bootstrap-58115d). Retarget to main after #3 merges.

Closes #2

Problem

src/protocol/sse-reader.ts kept the unterminated line and the event being built with no limit. A misbehaving server could stream an endless line and grow the panel's memory until the tab died.

Change

  • Cap: MAX_SSE_PENDING = 1 Mi UTF-16 code units (JavaScript string length). It covers the partial line plus the current event's held fields (event name, data lines with their joining newlines, id). Code units need no re-encoding. The cap is at most 2 MiB of string memory and at least 1 MiB of UTF-8 on the wire, well above the 160 KiB respond body cap.
  • Parser: push scans each chunk for newlines and checks the cap before it appends anything. Past the cap it drops everything it holds, throws SseProtocolError and stays failed. Many small events whose total passes the cap still parse, because each event is released when it ends. The line splitting is unchanged, including CR/LF handling.
  • Reader: readSseFrames cancels the body on SseProtocolError.
  • Client: after the 200, streamChat maps SseProtocolError to DeweeApiError(0, 'protocol_error', …, afterResponse: true, cause). Other non-API faults still become runtime_request_failed.
  • Panel: protocol_error was added to the transport codes in use-chat, and it has en/vi strings. afterResponse already makes notReceived false, so the turn follows the documented Chat policy: it is uncertain, marked delivery_unconfirmed and reconciled, with no "Send again" and no automatic resend. A continuation stays held, as for other drops after the response.
  • Docs: docs/architecture.md (Chat) names protocol_error under decoder faults and states the cap.

Tests

  • Parser: an endless line fed in 64 KiB chunks. pending stays at or below the cap on every push, the error fires on the push that would pass it, and the held data is dropped afterwards (pending is 0 and later pushes throw).
  • Parser: one chunk with an oversized line, and an oversized multi-line event at a small cap and at the default cap.
  • Parser: many events whose total passes the cap still parse. An event right at the cap, split into 97-character chunks with CRLF, parses. The same event one character over is refused.
  • Reader: an endless body is cancelled with the error after a bounded number of pulls.
  • Client: chat.started then an endless data line gives protocol_error with afterResponse: true and a SseProtocolError cause.
  • Hook: protocol_error gives delivery_unconfirmed, no retryText, and a single streamChat call.
  • Mutation check: 7 mutants, all caught. They were: no cap check, no check before appending the partial line, data size not counted, state not dropped on failure, failed flag ignored, body not cancelled, and the client mapping removed.

Gates

pnpm lint, pnpm typecheck, pnpm test (387 passed) and pnpm build pass. pnpm test:e2e with no .env: 5 smoke tests passed and 16 live tests skipped.

🤖 Generated with Claude Code

The stream parser held the unterminated line and the event being built
with no limit, so a server streaming an endless line grew panel memory
until the tab died.

The parser now holds at most 1 Mi UTF-16 code units for the partial
line plus the current event's fields. Past that it drops what it holds,
cancels the body and throws SseProtocolError. The client reports it as
DeweeApiError protocol_error with afterResponse, so the panel reconciles
the turn like other faults after the response and never resends it.

Closes #2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the cap tripped partway through a chunk, the parser threw and
dropped the frames that chunk had already completed, such as
chat.started or message.completed, so the panel could not learn the
session key or settle a continuation.

SseProtocolError now carries the frames completed before the overflow.
readSseFrames cancels the body, yields those frames, then throws. The
client maps SseProtocolError to protocol_error before it checks the
abort signal, so a Stop that lands while the body is cancelled no longer
turns the fault into a raw error that could offer "Send again".

The joined data size no longer counts one newline too many, and the
redundant post-line cap check is gone: a parsed line never holds more
than its own length.

Refs #2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mrgoonie

Copy link
Copy Markdown
Contributor Author

Addressed the review findings in 34a9f15:

  • Frames lost on overflow. SseProtocolError now carries the frames the chunk completed before the cap tripped. readSseFrames cancels the body, yields those frames, then throws, so chat.started and message.completed from that chunk still reach the panel.
  • Stop could bring back "Send again". streamChat maps SseProtocolError to protocol_error (afterResponse: true) before the abort and stall checks, so an abort that fires while the body is being cancelled keeps the protocol error.
  • Tests added:
    • Parser: frames kept before an oversized line, an oversized id, a held id counted toward the cap, an oversized comment, and the joined data size.
    • Reader: frames yielded before the error, and a body whose cancel rejects.
    • Client: chat.started from the overflowing chunk is delivered, so the session key is learned; an abort during the cancel still gives protocol_error with afterResponse.
    • use-chat: protocol_error in a known session shows the error and reconciles, with no "Send again"; a continuation stays held, with "Continue", on protocol_error.
  • Nits: removed the redundant post-line limit(0) (a parsed line never holds more than its own length), and the joined data size no longer counts one newline too many. The existing boundary tests still pin the cap exactly.

Mutation-checked: removing the frame delivery fails the reader and client tests, and restoring the abort guard before the mapping fails the abort test.

Gates: lint, typecheck, test (398 passed) and build all pass.

When the chunk that overflowed the SSE cap also completed
message.completed, the reply finished normally and the protocol_error
that followed then added an error bubble and a reconcile to a turn that
had already succeeded.

use-chat now ignores a stream error once the turn has seen its terminal
frame. A continuation that saw message.completed is already acknowledged
and out of the queue, and stays so. The client keeps reading after a
terminal frame, so frames the server may still send are not cut off.

Refs #2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mrgoonie

Copy link
Copy Markdown
Contributor Author

Re-review addressed in 955e8be:

  • A finished reply no longer turns into a failure. use-chat now ignores a stream error once the turn has seen its terminal frame (message.completed, chat.error or chat.stopped). A continuation that saw message.completed stays acknowledged and out of the queue.
    • I fixed this in use-chat and left streamChat reading after a terminal frame. The contract promises one terminal frame per stream, but the Gateway does not promise that nothing else follows it, so the client should not cut the stream off.
  • New integration tests run the real client over one chunk holding chat.started, message.completed and an oversized line:
    • A send shows the reply as completed, with no error bubble and no "Send again".
    • A continuation is removed from the queue as delivered, with no error.
    • Mutation check: removing the guard makes both tests fail, because the reconcile replaces the finished reply.
  • Cancel-rejects reader test: it now has a complete frame before the oversized line and asserts that the frame is still yielded.

Gates: lint, typecheck, test (400 passed) and build all pass.

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