Skip to content

webstreams: native CompressionStream/DecompressionStream/TextEncoderStream/TextDecoderStream - #36695

Merged
Jarred-Sumner merged 41 commits into
mainfrom
claude/farm/cc1a3bf2/native-compression-text-streams
Aug 4, 2026
Merged

webstreams: native CompressionStream/DecompressionStream/TextEncoderStream/TextDecoderStream#36695
Jarred-Sumner merged 41 commits into
mainfrom
claude/farm/cc1a3bf2/native-compression-text-streams

Conversation

@robobun

@robobun robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

What

Rewrites CompressionStream, DecompressionStream, TextEncoderStream, and TextDecoderStream as native C++ JSTransformStream subclasses that drive Rust state directly, and adds a native-sink output path so coder output goes straight to a native JSSink (HTTPResponseSink, FileSink, etc.) without a JSUint8Array per chunk.

Before

  • CompressionStream / DecompressionStream: JS builtins that require("node:zlib"), wrap a Node duplex in newBufferSourceTransformPairFromDuplex, and round-trip every chunk through the threadpool.
  • TextEncoderStream / TextDecoderStream: C++ cells with TransformerKind arms, but each chunk did encoder->get(global, "encode") + JSC::call(...) on a held JS wrapper cell; TextDecoderStream also allocated a {stream: true} options object per chunk.
  • readStreamIntoSink (every Response(stream) → native sink pump) did a prototype .write() lookup + JSC::call per chunk.

After

Native cells: each instance cell IS a JSTransformStream (C++ subclass), so m_readable/m_writable/m_controller/m_backpressure live on the instance and there is no second cell. JSTransformStream stays JSNonFinalObject; each subclass registers a per-instance vm.heap.addFinalizer only when it actually allocated native state (a utf-8 non-fatal TextDecoderStream registers nothing). The coder/decoder is freed eagerly at ClearAlgorithms (post-flush / error / cancel) so zlib/brotli/zstd window buffers don't wait for a full GC; an in-use guard (runNativeArm) defers that free when ClearAlgorithms is reached re-entrantly from user JS inside a transform arm's chunk coercion. setUpNativeTransformStream() initializes the subclass in place; the JS-visible prototype chain is unchanged (instanceof TransformStream stays false).

Rust coder: src/runtime/webcore/CompressionStreamCoder.rs holds one zlib/brotli/zstd context per stream and implements gzip concatenated members (RFC 1952 2.2), zstd concatenated + skippable frames (RFC 8878 3.1) with cross-chunk magic buffering, truncated-input detection on flush, and trailing-junk rejection (ERR_TRAILING_JUNK_AFTER_STREAM_END). zlib avail_in is clamped to u32::MAX and refilled per loop so a ≥4 GiB chunk is not truncated.

TextDecoderStream utf-8 fast path: new TextDecoderStream() with utf-8 and fatal:false (the default) shares Body.textStream()'s streamingUTF8Decode (simdutf, 4-byte inline state); no Rust TextDecoder is allocated. ignoreBOM maps to the initial bomSeen flag. Other encodings and fatal:true use the Rust TextDecoder.

TextEncoderStream native-sink path: when a native sink is attached, the encoder writes into a per-encoder reusable Vec<u8> and hands it to Bun__JSSink__writeBytesById directly; no JSUint8Array per chunk.

Native-sink output path: readStreamIntoSink detects when the readable is a byte-producing TransformerKind (Compression/Decompression/TextEncoder) and attaches the native sink (m_nativeSinkPtr/m_nativeSinkId/m_nativeSinkCell) to the transform. The transform arms then run the coder into a Vec<u8> and hand those bytes to Bun__JSSink__writeBytesById (dispatched by the controller's SinkID) before dropping the Vec. Sink backpressure holds the in-flight write via m_nativeSinkReadyPromise, resolved by the sink's onReady. rsisDetachNativeTransform clears the attachment before every sink end()/close() (including the sink-initiated onClose callback).

JSSink writeBytes: each generated sink type gains ${name}__writeBytes(void* sinkPtr, JSGlobalObject*, const uint8_t*, size_t) (forwards to Rust JsSinkType::write_bytes), plus JSSink__writeBytes(SinkID, ...) / Bun__JSSink__writeBytesById dispatchers. rsisSinkWrite uses it directly for JSArrayBufferView chunks instead of the prototype .write() lookup (benefits every byte stream, not only these four).

readableHighWaterMark = 1 for the native subclasses (not the spec's 0) so a single await writer.write(x) completes before any reader is attached, matching Node.js and Chromium.

Benchmark

Release build bb790d282 vs canary main 87e168fb0 (post-SinkHandle), 3-run median, 200 × 64 KB per iteration:

case main this PR Δ
CompressionStream(gzip) collect 23.1 MB/s, 1881 ms cpu 46.3 MB/s, 1359 ms cpu 2.0x, -28% cpu
CompressionStream(gzip) Bun.serve 23.8 MB/s, 2309 ms cpu 45.9 MB/s, 1504 ms cpu 1.9x, -35% cpu
DecompressionStream(gzip) collect 920 MB/s, 99.6 ms cpu 1046 MB/s, 60.2 ms cpu +14%, -40% cpu
TextEncoderStream collect 3147 MB/s 3214 MB/s ~same
TextEncoderStream Bun.serve 510 MB/s 503 MB/s ~same
TextDecoderStream collect 1944 MB/s 1782 MB/s -8% (noise)
RSS delta 120 MB 90 MB -25%

The compression/decompression speedup is from dropping the node:zlib duplex adapter and its per-chunk threadpool round-trip. TextEncoderStream/TextDecoderStream were already mostly native.

Backpressure (pull source → CompressionStreamBun.serve, client reads one chunk then stalls, 500 × 64 KB incompressible):

  • main: source pulls race to 500 immediately (the node:zlib adapter never propagated sink backpressure)
  • this PR: pulls park at ~44 until the client drains, then complete

Async codec threshold (128 KB): single-write CompressionStream('''gzip''') latency sweep, 20 iters each (release build):

chunk JS-thread blocked throughput
64 KB (sync) 1.19 ms/write 52.5 MB/s
128 KB (sync) 2.57 ms/write 48.6 MB/s
129 KB (async) 0.01 ms/write 42.6 MB/s
256 KB (async) 0.01 ms/write 44.6 MB/s
2048 KB (async) 0.01 ms/write 45.5 MB/s

Above the threshold the JS thread is free in 0.01 ms (vs 2.57 ms blocked at 128 KB sync); the WorkPool round-trip costs ~13% throughput at the boundary, recovering by 256 KB.

Tests

  • test/js/web/streams/compression.test.ts: all existing coverage plus per-format CompressionStream(...) -> native HTTP response sink round-trips via Bun.serve, plus backpressure tests
  • test/js/web/encoding/text-{encoder,decoder}-stream.test.ts: all WPT-derived tests pass; added utf-8 fast-path BOM/replacement/split-BOM tests, utf-8 fatal split/truncated tests, TextEncoderStream -> HTTPResponseSink round-trip (incl. dangling-surrogate flush)
  • Node parallel tests: test-webstreams-compression-{bad-chunks,buffer-source}.js, test-whatwg-webstreams-compression.js, test-whatwg-webstreams-encoding.js, test-whatwg-webstreams-adapters-*.js all pass

Error-message change

Brotli decode errors keep the node:zlib-compatible shape: TypeError with own .code = "ERR_" + BrotliDecoderErrorString() and a .cause carrying the same .code (the compression.test.ts brotli-error test asserts all four). Zlib/zstd decode errors become TypeError("inflate failed" / "zstd decode failed") without the previous library-specific .code; Node's own test-webstreams-compression-bad-chunks.js (which this PR passes) only asserts name: 'TypeError' for those.

Not in this PR

  • TextEncoderStream pure-ASCII zero-copy path (GCOwned<StringView>Temporary straight to the sink)
  • HTMLRewriter native-sink path (same shape, follow-up)

no test proof · iteration 16 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/streams/streams.test.js

…tream/TextDecoderStream via TransformerKind

CompressionStream and DecompressionStream were JS builtins that routed through
node:zlib and a duplex adapter. TextEncoderStream and TextDecoderStream were
already C++ cells but still looked up .encode/.decode on a held JS wrapper
cell per chunk (plus an options object allocation for TextDecoderStream).

All four now drive Rust state owned directly by the instance cell (a void*
freed in the destructor), dispatched from the existing TransformerKind switch.
The transform step is C++ -> extern "C" -> Rust -> JSUint8Array/JSString ->
transformStreamDefaultControllerEnqueue, with the stock TransformStream
backpressure machinery handling the readable/writable coupling.

New src/runtime/webcore/CompressionStreamCoder.rs owns one zlib/brotli/zstd
context per stream and handles gzip multi-member (RFC 1952 2.2), zstd
multi-frame + skippable frames (RFC 8878 3.1), truncated input on flush, and
trailing-junk detection (ERR_TRAILING_JUNK_AFTER_STREAM_END).
Comment thread src/jsc/bindings/webcore/streams/JSCompressionStream.cpp Outdated
Comment thread src/jsc/bindings/webcore/streams/JSCompressionStream.cpp Outdated
Comment thread src/jsc/bindings/webcore/streams/JSCompressionStream.h Outdated
@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:05 AM PT - Aug 4th, 2026

@robobun, your commit bb790d2 is building: #88712

Comment thread src/jsc/bindings/webcore/streams/JSCompressionStream.h Outdated
Comment thread src/jsc/bindings/webcore/streams/JSTextDecoderStream.cpp
Comment thread src/jsc/bindings/webcore/streams/JSTextDecoderStream.h Outdated
Comment thread src/jsc/bindings/webcore/streams/JSTextEncoderStream.cpp Outdated
Comment thread src/jsc/bindings/webcore/streams/JSTextEncoderStream.h Outdated
Comment thread src/jsc/bindings/webcore/streams/StreamsForward.h
Comment thread src/jsc/bindings/webcore/streams/WebStreamsInternals.h Outdated
Comment thread src/runtime/webcore/CompressionStreamCoder.rs
Comment thread src/runtime/webcore/CompressionStreamCoder.rs
Comment thread src/runtime/webcore/CompressionStreamCoder.rs
Comment thread src/runtime/webcore/CompressionStreamCoder.rs
Comment thread src/runtime/webcore/CompressionStreamCoder.rs
Comment thread src/runtime/webcore/CompressionStreamCoder.rs Outdated
Comment thread src/runtime/webcore/CompressionStreamCoder.rs Outdated
Comment thread src/runtime/webcore/CompressionStreamCoder.rs
Comment thread src/runtime/webcore/CompressionStreamCoder.rs
Comment thread src/runtime/webcore/CompressionStreamCoder.rs
Comment thread src/runtime/webcore/TextDecoder.rs
Comment thread src/runtime/webcore/TextDecoder.rs
Comment thread src/runtime/webcore/TextDecoder.rs Outdated
Comment thread src/runtime/webcore/TextDecoder.rs
Comment thread src/runtime/webcore/TextEncoderStreamEncoder.rs
Comment thread src/jsc/bindings/webcore/streams/JSCompressionStream.h Outdated
Comment on lines +66 to +72
// Native byte-producing subclasses only: when `readStreamIntoSink` attaches a
// native JSSink controller to this transform, the transform arms write coder
// output straight to `m_nativeSinkPtr` via the Rust SinkHandle dispatcher
// (Bun__NativeTransformSink__writeBytes) instead of wrapping it in a
// JSUint8Array and enqueueing on the readable.
// `m_nativeSinkReadyPromise` is the transform-algorithm result returned on
// sink backpressure; the sink's onReady resolves it.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +467 to +471
// Rust-side single dispatch for the native-transform → native-JSSink byte write, routed
// through SinkHandle::write (src/runtime/webcore/Sink.rs). Returns a negative number for
// native ByteStream/FileReader sources under backpressure, but a pending JSPromise for the
// JSController-sourced sinks that readStreamIntoSink attaches (HTTPResponseSink/FileSink).
// Both mean "suspend" — nativeSinkWriteIsBackpressure below reads either shape.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +670 to +676
// ──────────────────────────────────────────────────────────────────────────
// Native-transform → native-sink byte-write dispatch
//
// Replaces the generated per-sink `${name}__writeBytes` thunks + the C++
// `JSSink__writeBytes` SinkID switch with a single Rust entry point that
// routes through `SinkHandle::write`.
// ──────────────────────────────────────────────────────────────────────────

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +678 to +687
/// Map a C++ `WebCore::SinkID` + erased `m_sinkPtr` to a [`SinkHandle`].
///
/// `ptr` is the `m_sinkPtr` stored on the JS wrapper (a `*mut JSSink<T>` for
/// the `T` selected by `id`); `JSSink<T>` is `#[repr(transparent)]` over `T`,
/// so the cast to `*mut T` is an address-preserving no-op.
///
/// # Safety
/// `ptr` must be a live, properly-aligned pointer to the concrete sink type
/// that `id` names (the same pointer the generated `${name}__*` thunks
/// receive), valid for the lifetime of the returned handle.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +744 to +752
/// Route a borrowed byte chunk from a native transform (`JSTransformStream`
/// with `m_nativeSinkPtr` attached) into the concrete sink via
/// [`SinkHandle::write`].
///
/// Return shape matches [`streams::result::Writable::to_js`] so
/// `nativeSinkWriteIsBackpressure` reads a negative number / pending promise
/// exactly as the previous `js_write_bytes` path produced. No
/// [`JsSinkType::get_pending_error`] guard: every sink uses the trait-default
/// `None`, so omitting it is behavior-preserving.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@Jarred-Sumner
Jarred-Sumner merged commit 59cab0e into main Aug 4, 2026
50 of 51 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/farm/cc1a3bf2/native-compression-text-streams branch August 4, 2026 05:28
springmin pushed a commit to springmin/bun that referenced this pull request Aug 4, 2026
…tream/TextDecoderStream (oven-sh#36695)

Rewrites `CompressionStream`, `DecompressionStream`,
`TextEncoderStream`, and `TextDecoderStream` as native C++
`JSTransformStream` subclasses that drive Rust state directly, and adds
a native-sink output path so coder output goes straight to a native
JSSink (HTTPResponseSink, FileSink, etc.) without a `JSUint8Array` per
chunk.

- `CompressionStream` / `DecompressionStream`: JS builtins that
`require("node:zlib")`, wrap a Node duplex in
`newBufferSourceTransformPairFromDuplex`, and round-trip every chunk
through the threadpool.
- `TextEncoderStream` / `TextDecoderStream`: C++ cells with
`TransformerKind` arms, but each chunk did `encoder->get(global,
"encode")` + `JSC::call(...)` on a held JS wrapper cell;
`TextDecoderStream` also allocated a `{stream: true}` options object per
chunk.
- `readStreamIntoSink` (every `Response(stream)` → native sink pump) did
a prototype `.write()` lookup + `JSC::call` per chunk.

**Native cells**: each instance cell IS a `JSTransformStream` (C++
subclass), so `m_readable`/`m_writable`/`m_controller`/`m_backpressure`
live on the instance and there is no second cell. `JSTransformStream`
stays `JSNonFinalObject`; each subclass registers a per-instance
`vm.heap.addFinalizer` only when it actually allocated native state (a
utf-8 non-fatal `TextDecoderStream` registers nothing). The
coder/decoder is freed eagerly at `ClearAlgorithms` (post-flush / error
/ cancel) so zlib/brotli/zstd window buffers don't wait for a full GC;
an in-use guard (`runNativeArm`) defers that free when `ClearAlgorithms`
is reached re-entrantly from user JS inside a transform arm's chunk
coercion. `setUpNativeTransformStream()` initializes the subclass in
place; the JS-visible prototype chain is unchanged (`instanceof
TransformStream` stays false).

**Rust coder**: `src/runtime/webcore/CompressionStreamCoder.rs` holds
one zlib/brotli/zstd context per stream and implements gzip concatenated
members (RFC 1952 2.2), zstd concatenated + skippable frames (RFC 8878
3.1) with cross-chunk magic buffering, truncated-input detection on
flush, and trailing-junk rejection
(`ERR_TRAILING_JUNK_AFTER_STREAM_END`). zlib `avail_in` is clamped to
`u32::MAX` and refilled per loop so a ≥4 GiB chunk is not truncated.

**TextDecoderStream utf-8 fast path**: `new TextDecoderStream()` with
utf-8 and `fatal:false` (the default) shares `Body.textStream()`'s
`streamingUTF8Decode` (simdutf, 4-byte inline state); no Rust
`TextDecoder` is allocated. `ignoreBOM` maps to the initial `bomSeen`
flag. Other encodings and `fatal:true` use the Rust `TextDecoder`.

**TextEncoderStream native-sink path**: when a native sink is attached,
the encoder writes into a per-encoder reusable `Vec<u8>` and hands it to
`Bun__JSSink__writeBytesById` directly; no `JSUint8Array` per chunk.

**Native-sink output path**: `readStreamIntoSink` detects when the
readable is a byte-producing `TransformerKind`
(Compression/Decompression/TextEncoder) and attaches the native sink
(`m_nativeSinkPtr`/`m_nativeSinkId`/`m_nativeSinkCell`) to the
transform. The transform arms then run the coder into a `Vec<u8>` and
hand those bytes to `Bun__JSSink__writeBytesById` (dispatched by the
controller's `SinkID`) before dropping the Vec. Sink backpressure holds
the in-flight write via `m_nativeSinkReadyPromise`, resolved by the
sink's `onReady`. `rsisDetachNativeTransform` clears the attachment
before every sink `end()`/`close()` (including the sink-initiated
`onClose` callback).

**JSSink `writeBytes`**: each generated sink type gains
`${name}__writeBytes(void* sinkPtr, JSGlobalObject*, const uint8_t*,
size_t)` (forwards to Rust `JsSinkType::write_bytes`), plus
`JSSink__writeBytes(SinkID, ...)` / `Bun__JSSink__writeBytesById`
dispatchers. `rsisSinkWrite` uses it directly for `JSArrayBufferView`
chunks instead of the prototype `.write()` lookup (benefits every byte
stream, not only these four).

**readableHighWaterMark = 1** for the native subclasses (not the spec's
0) so a single `await writer.write(x)` completes before any reader is
attached, matching Node.js and Chromium.

Release build `bb790d282` vs canary main `87e168fb0` (post-SinkHandle),
3-run median, 200 × 64 KB per iteration:

| case | main | this PR | Δ |
|---|---|---|---|
| CompressionStream(gzip) collect | 23.1 MB/s, 1881 ms cpu | 46.3 MB/s,
1359 ms cpu | **2.0x, -28% cpu** |
| CompressionStream(gzip) Bun.serve | 23.8 MB/s, 2309 ms cpu | 45.9
MB/s, 1504 ms cpu | **1.9x, -35% cpu** |
| DecompressionStream(gzip) collect | 920 MB/s, 99.6 ms cpu | 1046 MB/s,
60.2 ms cpu | **+14%, -40% cpu** |
| TextEncoderStream collect | 3147 MB/s | 3214 MB/s | ~same |
| TextEncoderStream Bun.serve | 510 MB/s | 503 MB/s | ~same |
| TextDecoderStream collect | 1944 MB/s | 1782 MB/s | -8% (noise) |
| RSS delta | 120 MB | 90 MB | **-25%** |

The compression/decompression speedup is from dropping the `node:zlib`
duplex adapter and its per-chunk threadpool round-trip.
TextEncoderStream/TextDecoderStream were already mostly native.

**Backpressure** (pull source → `CompressionStream` → `Bun.serve`,
client reads one chunk then stalls, 500 × 64 KB incompressible):
- main: source pulls race to 500 immediately (the `node:zlib` adapter
never propagated sink backpressure)
- this PR: pulls park at ~44 until the client drains, then complete

**Async codec threshold (128 KB)**: single-write
`CompressionStream('''gzip''')` latency sweep, 20 iters each (release
build):

| chunk | JS-thread blocked | throughput |
|---|---|---|
| 64 KB (sync) | 1.19 ms/write | 52.5 MB/s |
| 128 KB (sync) | 2.57 ms/write | 48.6 MB/s |
| 129 KB (async) | 0.01 ms/write | 42.6 MB/s |
| 256 KB (async) | 0.01 ms/write | 44.6 MB/s |
| 2048 KB (async) | 0.01 ms/write | 45.5 MB/s |

Above the threshold the JS thread is free in 0.01 ms (vs 2.57 ms blocked
at 128 KB sync); the WorkPool round-trip costs ~13% throughput at the
boundary, recovering by 256 KB.

- `test/js/web/streams/compression.test.ts`: all existing coverage plus
per-format `CompressionStream(...) -> native HTTP response sink
round-trips` via `Bun.serve`, plus backpressure tests
- `test/js/web/encoding/text-{encoder,decoder}-stream.test.ts`: all
WPT-derived tests pass; added utf-8 fast-path BOM/replacement/split-BOM
tests, utf-8 fatal split/truncated tests, `TextEncoderStream ->
HTTPResponseSink` round-trip (incl. dangling-surrogate flush)
- Node parallel tests:
`test-webstreams-compression-{bad-chunks,buffer-source}.js`,
`test-whatwg-webstreams-compression.js`,
`test-whatwg-webstreams-encoding.js`,
`test-whatwg-webstreams-adapters-*.js` all pass

Brotli decode errors keep the `node:zlib`-compatible shape: `TypeError`
with own `.code = "ERR_" + BrotliDecoderErrorString()` and a `.cause`
carrying the same `.code` (the `compression.test.ts` brotli-error test
asserts all four). Zlib/zstd decode errors become `TypeError("inflate
failed" / "zstd decode failed")` without the previous library-specific
`.code`; Node's own `test-webstreams-compression-bad-chunks.js` (which
this PR passes) only asserts `name: 'TypeError'` for those.

- TextEncoderStream pure-ASCII zero-copy path (`GCOwned<StringView>` →
`Temporary` straight to the sink)
- `HTMLRewriter` native-sink path (same shape, follow-up)

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 16 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/web/streams/streams.test.js

<!-- robobun:evidence:end -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Jarred-Sumner pushed a commit that referenced this pull request Aug 5, 2026
#36924)

## What

Adds a regression test pinning that CompressionStream releases its
native coder eagerly when the piped source errors, rather than leaving
it to the cell's finalizer.

## Why

Before the native rewrite in #36695, CompressionStream was a JS builtin
wrapping node:zlib, and a pipeline whose source throws released its
deflate context (~280 KiB of zlib state) only at finalization. A busy
loop of failing pipelines retained every context: Bun.gc(true) could not
reclaim them and RSS grew ~280 KiB per iteration until the process went
idle. A server gzipping response bodies whose upstreams abort holds
hundreds of MB of dead deflate contexts under sustained load, with
nothing visible in heapUsed.

Repro loop (what the test runs in a child process):

```js
let n = 0;
const src = new ReadableStream({
  pull(c) {
    if (++n > 5) throw new Error("source failed");
    c.enqueue(new Uint8Array(8192).fill(n));
  },
});
try { await new Response(src.pipeThrough(new CompressionStream("gzip"))).arrayBuffer(); } catch {}
```

Measured RSS growth over 448 errored pipelines (after full GC):

- bun 1.3.14 (node:zlib-backed implementation): 132 MiB; at 3000
iterations RSS reaches ~874 MB and survives Bun.gc(true)
- after #36695: 7 MiB release, 8 MiB debug+ASAN

#36695 frees the coder at ClearAlgorithms, the shared terminal for
post-flush, error, and cancel, leaving the finalizer as a fallback for
abandoned streams. Nothing pinned that behavior; this test does, with a
64 MiB bound.

## Verification

- Fails on bun 1.3.14: expect(deltaMiB).toBeLessThan(64) receives 132
- Passes on current main: bun bd test
test/js/web/streams/compression.test.ts, 36 pass
- The child disables the ASAN allocator quarantine
(quarantine_size_mb=0, same approach as the shell and archive leak
tests) so the delta measures retention rather than quarantined frees

<!-- robobun:evidence:begin -->

---

**[stamp-90s]** gate passed · iteration 1 · 1 files touched

<details><summary>passes on PR (with fix)</summary>

```console
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/js/web/streams/compression.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/web/streams/compression.test.ts
bun test v1.4.0 (459064d)

test/js/web/streams/compression.test.ts:
(pass) TransformStream.prototype getters reject native transform subclasses (0) [16.06ms]
(pass) TransformStream.prototype getters reject native transform subclasses (1) [3.59ms]
(pass) TransformStream.prototype getters reject native transform subclasses (2) [3.08ms]
(pass) TransformStream.prototype getters reject native transform subclasses (3) [2.96ms]
(pass) CompressionStream and DecompressionStream > brotli > compresses data with brotli [16.13ms]
(pass) CompressionStream and DecompressionStream > brotli > decompresses brotli data [22.86ms]
(pass) CompressionStream and DecompressionStream > brotli > round-trip compression with brotli [62.84ms]
(pass) CompressionStream and DecompressionStream > zstd > compresses data with zstd [14.54ms]
(pass) CompressionStream and DecompressionStream > zstd > decompresses zstd data [21.09ms]
(pass) CompressionStream and DecompressionStream > zstd > round-trip compression with zstd [38.53ms]
(pass) CompressionStream and DecompressionStream > zstd > decompresses a multi-frame zstd stream [12.86ms]
(pass) CompressionStream and DecompressionStream > zstd > decompresses a multi-frame zstd stream split across writes (next = zstd frame) [18.29ms]
(pass) CompressionStream and DecompressionStream > zstd > decompresses a multi-frame zstd stream split across writes (next = skippable frame) [7.84ms]
(pass) CompressionStream and DecompressionStream > zstd > decompresses many concatenated zstd frames larger than one output chunk [15.38ms]
(pass) CompressionStream and DecompressionStream > zstd > decompresses a zstd stream with a leading skippable frame [9.97ms]
(pass) CompressionStream and DecompressionStream > zstd > rejects trailing garbage after a zstd frame [10.32ms]
(pass) CompressionStream and DecompressionStream > all formats > works with 
... (truncated)
Exit: 0
```

</details>

<details><summary>diff hotspot</summary>

```
test/js/web/streams/compression.test.ts | 59 +++++++++++++++++++++++++++++++++
 1 file changed, 59 insertions(+)
```

</details>

**gate history** · 3 passed · 0 rejected · iteration 1

<details><summary>evidence per changed file</summary>

```
file                                     reads  edits  tests
test/js/web/streams/compression.test.ts      2      5      0
```

</details>

<!-- robobun:evidence:end -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants