Skip to content

fix(web): end cancelled chunk streams cleanly and validate chunk headers - #3846

Merged
ryansolid merged 4 commits into
solidjs:nextfrom
lxsmnsyc:fix/chunk-reader-hardening
Oct 7, 2026
Merged

ryansolid merged 4 commits into
solidjs:nextfrom
lxsmnsyc:fix/chunk-reader-hardening

Conversation

@lxsmnsyc

@lxsmnsyc lxsmnsyc commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Fixes how ChunkReader handles cancellation, malformed headers, failed reads and memory. It reads server function request and response bodies and frames streams. Several of the same problems were fixed in solid-start's reader in solidjs/solid-start#2331.

Problems

  • Cancel partway through a frame throws. cancel() is documented to end a pending next() as done. That only held when the buffer was empty. If the cancel landed mid-frame, next() threw Malformed server function stream. instead. Frames cancels a superseded response through the reader, so open frames got that error record instead of the supersession reason. Which one you got depended on timing.
  • Loose header checks. The delimiters were never checked, and parseInt stopped at the first non-hex character. ;0x5zzzzzzz;, X0x00000005X, ;0000000005; and ;0x0000005 ; all decoded as valid frames.
  • No cleanup on failure. When drain() failed, or the first frame in deserializeStream failed, the body was left locked and unread.
  • Memory kept after a large frame. The store never shrank. After one 8 MiB frame, the reader held a 16 MiB allocation with nothing in it until the stream ended. For a live source or a frames connection, that can be as long as the page is open.
  • Invalid UTF-8 accepted. The decoder replaced invalid bytes with U+FFFD without an error.

Changes

  • cancel() sets a flag. next() checks it on entry and after every read, and returns done once it is set, even with a partial frame buffered. A frame is not delivered after a cancel, including one whose last read had already resolved when cancel() ran.
  • The header must be ;0x plus 8 hex digits plus ;. Anything else throws Malformed server function stream.
  • Payloads are decoded with a fatal UTF-8 decoder. A decode error throws the same malformed-stream error. createChunk always writes valid UTF-8, so only a corrupted or hand-built stream is affected.
  • drain() cancels the body before rethrowing. deserializeStream cancels it when the first frame fails to read, is an error trailer, or fails to decode.
  • After each frame, a store over 64 KiB is shrunk to the bytes still unread. A connection that goes idle after a large frame releases it at once. A steady stream of frames over 64 KiB regrows its store for each one, which costs a few doubling allocations per frame. Frames under 64 KiB never shrink it.
  • createChunk throws a RangeError for a payload longer than 0xffffffff bytes, which would not fit the 8-digit header.
  • One TextEncoder and one TextDecoder are shared at module level.

The wire format is unchanged. The server leg was already bounded by bodySizeLimit, which buffers the whole request body before decoding, so no size limit is added here.

Testing

  • New test/runtime/chunk-reader-hardening.spec.ts, 21 tests:
    • Cancel with nothing buffered, mid-header and mid-payload.
    • No delivery after a cancel, of buffered frames or of a frame whose last read resolved just before the cancel.
    • Each malformed header case, the canonical header in both hex cases, and invalid UTF-8.
    • Multi-byte characters split at every byte.
    • The body is cancelled after a failed drain, a malformed first frame, and a first value that fails to decode.
    • The store is released when the stream goes idle after a large frame, the next frame's bytes survive the shrink, and a store under 64 KiB is kept.
  • 16 of the new tests fail against the old reader. The other 5 are controls that pass on both.
  • The existing chunk-reader.spec.ts (ChunkReader copies the whole buffer per network read, making body decode O(n²) in read count #3154 allocation bounds) passes unchanged.
  • pnpm run test-types in packages/web is clean.
  • vitest run in packages/web across the default, server and hydrate configs. None of the remaining failures go through the reader:
    • dev-warning, lowercase-on-attribute (DOM) and performance-tracks fail the same way on next without this change.
    • frame-binding-slots and the server lowercase-on-attribute case assert native compiler output, and the local compiler binary was stale. I did not re-run these without the change.
    • The textarea-value-id-parity hydration case involves no server functions or frames. I did not re-run it without the change either.

Public API changes

ChunkReader and createChunk are both exported but internal (transport building blocks, not for hand-written code), as is deserializeStream. Behavior changes:

  • Stricter header validation. A chunk header must be exactly ;0x + 8 hex digits (either case) + ;. Anything else throws Malformed server function stream.
  • Invalid UTF-8 is rejected. A payload that is not valid UTF-8 is refused as a malformed stream instead of being decoded with U+FFFD substituted.
  • ChunkReader.cancel() mid-frame ends next() as done. A cancel partway through a header or payload resolves the pending next() as { done: true }, and frames already buffered (or whose last read resolved just before the cancel) are not delivered after a cancel.
  • Failures cancel the body. A failing drain(), or a first frame in deserializeStream that fails to read, is an error trailer, or fails to decode, cancels the body instead of leaving it locked and unread.
  • createChunk throws RangeError for a payload above 0xffffffff bytes (it would not fit the 8-digit header).

The wire format is unchanged.

Size

A follow-up commit trims the reader with no behavior change (regex header check, one try/catch in deserializeStream, inlined store release, shared done result): +849 B → +518 B minified on the frames client. Measured against next @ 721eb06 (Rolldown, brotli 11):

Scenario next this PR delta
frames: eager client consumer 13,787 B br / 43,414 B min 13,969 B br / 43,932 B min +182 br / +518 min
page: base server components 44,968 B br / 145,776 B min 45,102 B br / 146,295 B min +134 br / +519 min
page: live server components 48,573 B br / 157,738 B min 48,803 B br / 158,257 B min +230 br / +519 min

Caps raised to measured + 10 B rounded up to 0.01 KB (13.98 / 45.12 / 48.82 KB), with ledger notes in scripts/size/scenarios.js and floor-caps.json.

Size-Exception: #3846 server-function/frames reader correctness (ChunkReader cancel/header/UTF-8/cleanup hardening), +518 B minified / +134–230 B brotli on the frames and page scenarios; accepted by the maintainer 2026-10-07.

🤖 Generated with Claude Code

- `cancel()` now ends a pending `next()` as done even partway through a
  frame. It used to throw "Malformed server function stream." whenever the
  cancel landed mid-frame. Frames reported a superseded response with that
  message instead of the supersession reason.
- A chunk header must be exactly `;0x` plus 8 hex digits plus `;`. The old
  `parseInt` read accepted wrong delimiters and trailing junk such as
  `;0x5zzzzzzz;`.
- Payloads are decoded with a fatal UTF-8 decoder, so invalid bytes fail
  as a malformed stream instead of becoming U+FFFD.
- `drain()` and a failed first frame in `deserializeStream` cancel the body
  instead of leaving it locked and unread.
- The reader drops an oversized store once it holds no unread bytes. One
  large frame used to keep its doubled allocation until the stream ended.
- `createChunk` refuses a payload whose length does not fit the 8-digit
  header. One encoder and one decoder are shared at module level.

The wire format is unchanged.

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

changeset-bot Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f6fecad

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
todos-server-example Patch
@solidjs/compiler Patch
@solidjs/signals Patch
solid-js Patch
@solidjs/universal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codspeed

codspeed Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 188 untouched benchmarks


Comparing lxsmnsyc:fix/chunk-reader-hardening (f6fecad) with next (721eb06)

Open in CodSpeed

@ryansolid

Copy link
Copy Markdown
Member

Thanks for this. The reader changes check out, and the new spec pins them: 14 of the 19 tests fail against next's reader and the 5 controls pass. Two correctness points before it lands. We'll handle the size gate ourselves, so no need to shave bytes.

1. The store isn't released right after a large frame. releaseStore keeps the store while it is at most max(64 KiB, 4 × (12 + frameBytes)). A store that grew to fit one frame is at most about 2× that frame, so the check never passes right after it. For one 8 MiB frame the store is about 16 MiB and the limit is 32 MiB, so it's kept. It's only released later, when a smaller frame happens to end on an empty buffer. A connection that goes idle after a big frame keeps the allocation, which is the case the description targets. The release test passes only because a small frame follows the large one. Could the rule compare against something other than the frame just read? And could you add a test where the stream goes idle after the large frame?

2. A frame can still be delivered after cancel(). cancelled is checked only at the start of next(). If a reader.read() has already resolved with data when cancel() runs, the next() in progress still returns that frame. Checking this.cancelled after each await this.readChunk() would make "not delivered after a cancel" hold in every case.

— Claude via Cursor

lxsmnsyc and others added 3 commits October 7, 2026 08:49
… after each read

- `releaseStore` now shrinks a store over 64 KiB to its unread bytes after
  every frame. The old rule compared the store with four times the frame
  just read. A store that grew to fit one frame is at most about twice that
  frame, so it was never released right after it. A connection that went
  idle after a large frame kept the allocation until the stream ended.
- A steady stream of frames over 64 KiB now regrows its store for each one.
  Frames under 64 KiB never shrink it, so the solidjs#3154 steady state for small
  frames is unchanged.
- `next()` checks `cancelled` after every read, not only on entry. A read
  that had already resolved with data when `cancel()` ran used to finish
  its frame and deliver it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…caps (solidjs#3846)

No behavior change. Header check is one regex plus parseInt (hexDigit and
the byte compares go), deserializeStream releases the body from one
try/catch, the store release is inlined into next(), and readChunk answers
whether the reader was cancelled so next() returns one shared done result.
Frames client +849 -> +518 B minified against next.

Size-Exception for the remaining cost, accepted by the maintainer
2026-10-07 (server-function/frames reader correctness): frames eager
client consumer 13.79 -> 13.98 KB, page base 44.89 -> 45.12 KB, page live
48.60 -> 48.82 KB, each at measured + 10 B with its minified recorded.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid

Copy link
Copy Markdown
Member

Thanks, both points are addressed: the store is dropped as soon as the stream goes idle after a large frame, and cancelled is rechecked after every read. I pushed a commit that trims the bytes (regex header check, one try/catch in deserializeStream, inlined store release, shared done result) with no behavior change, and added the Public API changes section and the size note. Merging once CI is green.

— Claude via Cursor

@ryansolid
ryansolid merged commit 2eb6e00 into solidjs:next Oct 7, 2026
8 checks passed
ryansolid added a commit that referenced this pull request Oct 7, 2026
48.82 -> 48.89 KB, recorded minified 158,377 B, from Size run
37599458941 after #3846 re-based the floor. Accepted by the maintainer
2026-10-07.

Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Oct 7, 2026
…part of 3) (#3853)

* WIP(signals): L2 fuzz regressions under existing rules (groups 1, 2, part of 3)

Over the size budget on + isPending/latest (+142 B min); shrink follows.
Read-order cases 416 and 827 pass without touching route().

Co-authored-by: Cursor <cursoragent@cursor.com>

* signals: shrink the L2 fuzz fixes (-22 B min on + isPending/latest)

- The pending-seat check decides in laneStage's leaf branch (the propagation
  passes its source as the no-answer argument) instead of a new
  GlobalQueue hook; the decline queues the mainline pending itself, so
  propagateStatus keeps its call shape (core -4 B).
- A correction drops the runs a lane's seam parked (l._held), the fact
  the seam already records, instead of re-judging blocked(l).
- The landing keeps its laneValueOf commit (the slot-only variant had no
  effect in the suite or the fuzzer).

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(size): size exception for the L2 fuzz fixes (#3853)

Raises signals: + isPending/latest (9.49 -> 9.58 KB), app: hydrating +
every store primitive family (28.93 -> 28.97 KB) and app: CSR, observe
tier + attribution engine enabled (28.66 -> 28.70 KB) to CI brotli + 10 B
with their recorded minified, from Size run 37596666473. Accepted by the
maintainer 2026-10-07.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(size): page: live floor exception for the L2 fuzz fixes (#3853)

48.82 -> 48.89 KB, recorded minified 158,377 B, from Size run
37599458941 after #3846 re-based the floor. Accepted by the maintainer
2026-10-07.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
@lxsmnsyc
lxsmnsyc deleted the fix/chunk-reader-hardening branch October 7, 2026 11:23
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.

2 participants