Skip to content

Buffer the compression stage's output in blocks released as they are … - #7425

Merged
jasnell merged 4 commits into
mainfrom
jasnell/ts-streams-decompression-delivery
Sep 21, 2026
Merged

jasnell merged 4 commits into
mainfrom
jasnell/ts-streams-decompression-delivery

Conversation

@jasnell

@jasnell jasnell commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

compression.ts drained the codec's entire output into the readable after every write as one Uint8Array, so each write's output was held twice at its peak (C++ stage buffer + JS copy), and chunks were unbounded. The shared C++ CodecStage also kept its output in a doubling kj::Vector that never released capacity. Reproduced at 79bfba9: a 16 MiB member came out as one chunk (through tee, pipeThrough, bodies); 256 MiB peaked at 661 MiB RSS (C++ 462).

@jasnell
jasnell requested review from guybedford and npaun September 17, 2026 22:09
@jasnell
jasnell added this pull request to stack #7413 September 17, 2026 22:09
@jasnell
jasnell requested review from a team as code owners September 17, 2026 22:09
@ask-bonk

ask-bonk Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

I'm Bonk, and I've done a quick review of your PR.

Bounds compression-stream output delivery and releases codec buffers incrementally.

  1. High: Added an inline suggestion to compatibility-gate the observable default-read chunking change.

github run

@jasnell
jasnell force-pushed the jasnell/ts-streams-decompression-delivery branch from a775d55 to 12d1c95 Compare September 18, 2026 01:33
@codecov-commenter

codecov-commenter commented Sep 18, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.62338% with 36 lines in your changes missing coverage. Please review.
✅ Project coverage is 37.55%. Comparing base (5adb86c) to head (54de095).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
src/workerd/api/compression-test.c++ 77.10% 4 Missing and 15 partials ⚠️
src/workerd/util/ring-buffer-test.c++ 60.00% 0 Missing and 12 partials ⚠️
src/workerd/api/compression.c++ 86.20% 3 Missing and 1 partial ⚠️
src/workerd/util/ring-buffer.h 87.50% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7425      +/-   ##
==========================================
+ Coverage   37.52%   37.55%   +0.03%     
==========================================
  Files         836      837       +1     
  Lines      258430   258556     +126     
  Branches    23747    23787      +40     
==========================================
+ Hits        96963    97096     +133     
+ Misses     148229   148199      -30     
- Partials    13238    13261      +23     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@guybedford guybedford 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.

Reviewed this layer (ts-streams-body-consumption-oom...ts-streams-decompression-delivery). Ran the compression suite (all variants) plus the new kj_test locally, all green, and probed the delivery state machine with a few extra cases (stale demandUnmet after releaseLock, BYOB min across writes, two BYOB reads pending at close, slow reader over many small writes) - no loss, duplication or ordering issues. The pull-driven delivery, BYOB fill-in-place and bounded default pieces all look right.

One design concern with where the bytes wait, detailed inline on compression.c++: this layer moves the readable's write-ahead backlog out of ArrayBuffer backing stores (inside the V8 sandbox, accounted as external memory) into plain C++ heap. The delivery mechanics can stay as they are; the storage should stay in the sandbox. The remaining comments are nits.

Comment thread src/workerd/api/compression.c++ Resolved
Comment thread src/workerd/api/compression.c++ Resolved
Base automatically changed from jasnell/ts-streams-body-consumption-oom to main September 21, 2026 18:58
…pulled

The CodecStage behind CompressionStream/DecompressionStream (both
implementations) kept its output in a kj::Vector that doubled as it grew
and never gave capacity back: decompressing a 256 MiB member held 384 MiB
during the last doubling and 256 MiB of capacity for the rest of the
stream's life, however long ago the output was pulled.

The output now lives in blocks, one per pump iteration (at most the
16 KiB scratch), consumed from the front and freed as they are pulled. A
burst of output costs its own size, with no growth copy, and is given
back as it is consumed; the block ring itself is replaced once it has
grown and emptied. For the C++ pair, decompressing 256 MiB of zeros
peaks at 385 MiB RSS, down from 462 MiB, and takes 5.3 s instead of
6.4 s.

CompressionCodec gains clear(), so the TypeScript pair can drop the
buffered output when the stream is cancelled, aborted or errored, as the
C++ frontend's cancelInternal does.

compression-test.c++ drives CodecStage without an isolate: pulls of
assorted sizes across block boundaries, output buffered across pushes
and the flush tail, and clear().
…eces

The TypeScript CompressionStream/DecompressionStream pair drained the
codec's whole output into the readable after every write, as one chunk:
16 MiB of zeros gzipped to 16 KB came out as a single 16,777,216-byte
chunk (C++: 4,096 chunks of 4 KiB), and every write's output was held
twice at its peak, in the stage buffer and in the JS copy. Decompressing
a 256 MiB member peaked at 661 MiB RSS against 462 MiB for C++, which is
what a decompression bomb costs against the isolate's memory.

The output now waits in the stage buffer and moves into the readable one
read at a time: pull() fills a BYOB request's view in place, or enqueues
a piece of at most 64 KiB for a default read. Nothing is queued ahead of
demand (highWaterMark 0), so a BYOB view is filled to its size as in
C++, rather than from a piece already queued. A pull that finds no
output leaves its demand recorded, and the sink serves it as soon as a
push or the flush tail produces some, so a read parked before the first
write is still served by that write. close() closes the readable once
the codec has ended and the stage is drained; cancel, abort and errors
clear the stage. The codec still runs eagerly inside write(), so the
error timing is unchanged; on a codec error one piece of the output
produced before the error point goes to a waiting read before the pair
errors, the order WPT decompression-extra-input pins.

The 256 MiB decompression now peaks at 350 MiB RSS (1.1 s instead of
1.7 s), and no chunk exceeds 64 KiB through tee(), pipeThrough() or a
Response body. Every measured shape is faster without the large
allocations: arrayBuffer() of a 64 MiB output 494 -> 243 ms, a JS pipe
279 -> 202 ms, a body pumped by C++ 324 -> 259 ms.

Pinned in the compression suite: delivery-shape.js (a 4 MiB single-write
output read in bounded, byte-exact pieces; BYOB views filled to their
size; two pending reads take consecutive pieces; tee branches get
bounded pieces; trailing junk after a large output delivers one piece
then errors, where C++ rejects the waiting read: ledger #16, #17) and
draining-reader.js (a 1 MiB backlog is taken in 64 KiB pieces, the last
with done).
@jasnell
jasnell force-pushed the jasnell/ts-streams-decompression-delivery branch from 12d1c95 to 8d99027 Compare September 21, 2026 19:04
The TypeScript CompressionStream/DecompressionStream pair kept each
write's output in the C++ stage buffer until reads pulled it out piece by
piece. That put the readable's write-ahead backlog, unbounded by design,
in plain C++ heap: outside the V8 sandbox, and with no external-memory
adjustment on the stage's blocks, so it no longer counted against the
isolate at all, where the ArrayBuffers the pair had queued before were
accounted.

The sink now drains the stage into the readable's queue before each
write or close settles, as Uint8Arrays of at most 64 KiB, so the backlog
waits in ArrayBuffers V8 accounts for and the stage holds output only
within a single push or end. Chunks stay bounded, and a write's output is
still never held whole in JS beside the stage's copy: the stage releases
each block as it is pulled, so the peak is the output plus one piece.
The readable needs neither a pull hook nor BYOB respond plumbing; the
controller fills a BYOB read from the queued pieces. Each enqueue serves
one waiting read, so every read waiting when a codec error strikes gets a
piece of the output produced before it, which is what the trailing-junk
test and ledger #17 now say. CompressionCodec loses clear(): the stage is
empty between sink steps, and cancelling or erroring the readable drops
the queued output.

RingBuffer gains capacity() and shrinkToInitial(), which OutputBuffer
uses in place of inferring growth from the element count.

delivery-shape.js pins that two reads waiting at a trailing-junk error
each get a consecutive piece, and states the intent of its piece-count
check as sizes.length * sizes[0] == size (the 4 MiB output is an exact
multiple of every implementation's piece); draining-reader.js pins that
one draining read sweeps the whole 1 MiB backlog as its sixteen 64 KiB
pieces.
@jasnell
jasnell requested a review from guybedford September 21, 2026 20:05
Comment thread src/workerd/api/compression.c++ Resolved
Enqueuing a piece into the compression pair's readable resolves a waiting
read with a plain { value, done } object, and resolving looks up `then`
on it, so an Object.prototype.then getter runs user code inside the
enqueue. Cancelling the reader from there closed the readable and ran the
source's cancel hook while the sink's drain still had pieces to move: the
next enqueue threw, the write rejected with that TypeError instead of the
cancel reason (and, inside failCodec, instead of the codec's own error,
which never reached failBoth), and the rest of the output stayed in the
stage until the pair was collected.

The pair now records its teardown (a reader cancel, or an error through
failBoth) in `finished`, with the reason; the drain checks it between
pieces and stops, and every teardown clears the stage, for which
CompressionCodec regains clear(). A write or close whose drain was cut
short fails with the teardown's reason, as one queued behind the teardown
does, while a codec error still wins inside failCodec. The reentrancy
suite pins the cancel-from-then-getter case for both implementations
(C++ has settled the write by the time the read resolves, so its write
resolves and the writable is untouched, per ledger #13).
@jasnell
jasnell merged commit e464148 into main Sep 21, 2026
23 of 24 checks passed
@jasnell
jasnell deleted the jasnell/ts-streams-decompression-delivery branch September 21, 2026 23:45
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.

4 participants