Skip to content

fix(chat): SSE parser drops large events when chunk boundary lands between header and data#56

Merged
oxyc merged 1 commit into
mainfrom
fix/sse-chunk-straddle
Jun 1, 2026
Merged

fix(chat): SSE parser drops large events when chunk boundary lands between header and data#56
oxyc merged 1 commit into
mainfrom
fix/sse-chunk-straddle

Conversation

@oxyc

@oxyc oxyc commented Jun 1, 2026

Copy link
Copy Markdown
Member

Real bug surfaced by @oskaresh testing #55 locally: in a parallel run of three same-shape gds/content-read tool calls, the middle card got stuck on Running… while the other two flipped to Done. Audit log and persisted conversation both held the correct 100 KB result for the middle call; the UI just never saw it.

Root cause

parseSSE in use-runtime-adapter.ts declared eventType inside the network-chunk loop:

while (true) {
  const { value } = await reader.read();
  ...
  let eventType: string | null = null;  // ← reset every chunk
  for (const line of lines) { ... }
}

When the server's SSE frame is bigger than one TCP chunk, the event: line lands in chunk N and the matching data: <huge json>\n\n line lands in chunk N+1. The new iteration starts with eventType = null, the if (eventType && data) guard fails, and the event is silently dropped. Small results (the two siblings: ~30 KB and ~60 KB) fit in one chunk so they always work; the bug only fires above ~64 KB.

The middle call's tool_result payload was 100 169 chars. That's exactly large enough to straddle a frame.

Fix

Lifted eventType to the same scope as buffer so it persists across reader.read() iterations. The only writes that reset it are the legitimate end-of-event signals (a yielded data: line, a blank line per SSE spec) — both pre-existed and stay.

Refactor

Extracted parseSSE to its own hooks/parse-sse.ts so the regression test can import it without dragging @wordpress/element into the Jest sandbox. No behaviour change beyond the fix.

Regression test

hooks/__tests__/parseSSE.test.ts feeds parseSSE a hand-chopped ReadableStream<Uint8Array> to assert the right events come through:

Case Coverage
Single complete event in one chunk Sanity.
event: in chunk 1, data: in chunk 2 The primary bug.
Mid-data:-line split across three chunks Multi-KB payloads.
Three back-to-back tool_results with middle straddling chunks Mirrors the exact production scenario.
Blank line resets eventType SSE spec compliance.
Malformed JSON skipped, not thrown Defensive.

Test catches the regression: temporarily restoring the inner let eventType fails exactly the 3 chunk-straddling cases (Tests: 3 failed, 30 passed), passes 33/33 with the fix.

@types/node added to devDeps so the test can import node:stream/web / node:util for ReadableStream / TextEncoder / TextDecoder polyfills under wp-scripts' Jest config.

Verification

  • npm run typecheck — clean
  • npm run lint:js0 errors, 0 warnings
  • npm run test:unit — 33/33
  • npm run build — succeeds

🤖 Generated with Claude Code

…tween header and data

The runtime adapter's SSE parser declared `eventType` *inside* the
`while (true) { const { value } = await reader.read(); ... }` loop, so
the variable reset every time a new TCP chunk arrived. When a single
SSE event straddled two chunks — `event: <type>\n` in chunk N and the
huge `data: <json>\n\n` line in chunk N+1 — the new iteration started
with `eventType = null` and the `if (eventType && data)` guard silently
dropped the event.

In production this surfaced as a stuck "Running…" tool-call card. We
hit it on a parallel run of three `gds/content-read` calls where the
middle call's `tool_result` payload was ~100 KB (vs ~30 KB and ~60 KB
for the siblings). The audit log + persisted conversation both held
the correct result; the SSE event for it never made it past the parser.

## Fix

Lifted `eventType` to the same scope as `buffer` so it persists across
`reader.read()` iterations. The only places that reset it are the two
legitimate end-of-event signals: a yielded `data:` line, and a blank
line (SSE-spec separator). Both pre-existed and stay.

## Refactor

Extracted `parseSSE` from `use-runtime-adapter.ts` to its own
`hooks/parse-sse.ts` so a Jest test can exercise it without dragging
the whole runtime adapter's `@wordpress/element` imports into the
sandbox. The new file is a verbatim copy of the (now-correct) parser
plus the doc-comment explaining the bug.

## Regression test

`hooks/__tests__/parseSSE.test.ts` builds a `ReadableStream` from a
hand-chopped chunk array and feeds it to `parseSSE`. Six cases:

- Single complete event in one chunk (sanity).
- `event:` line in chunk 1, `data:` in chunk 2 (the primary bug).
- Mid-`data:`-line split across three chunks (large payloads).
- Three back-to-back `tool_result`s where the middle one straddles
  chunks — mirrors the exact production scenario.
- Blank line resets `eventType` so a stray `data:` doesn't inherit it.
- Malformed JSON is skipped, not thrown.

Verified the test catches the regression: temporarily restoring the
broken `let eventType: string | null = null;` inside the loop fails
exactly 3 of the 6 tests (the chunk-straddling cases) before passing
again with the fix.

`@types/node` added to devDeps so the test can import
`node:stream/web` / `node:util` for `ReadableStream` / `TextEncoder` /
`TextDecoder` polyfills under wp-scripts' jest config.

## Verification

- `npm run typecheck` — clean
- `npm run lint:js` — 0 errors, 0 warnings
- `npm run test:unit` — 33/33 (6 new + 27 carried)
- `npm run build` — succeeds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@oxyc oxyc merged commit 4ea21bd into main Jun 1, 2026
3 checks passed
@oxyc oxyc deleted the fix/sse-chunk-straddle branch June 1, 2026 16:59
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