Skip to content

Make CompressionStream native - #31728

Draft
alii wants to merge 17 commits into
ali/transformstream-transformer-cancelfrom
ali/native-sync-compression-stream
Draft

Make CompressionStream native#31728
alii wants to merge 17 commits into
ali/transformstream-transformer-cancelfrom
ali/native-sync-compression-stream

Conversation

@alii

@alii alii commented Jun 2, 2026

Copy link
Copy Markdown
Member

What does this PR do?

Makes CompressionStream / DecompressionStream ~4.7x / ~1.6x faster by replacing the node:zlib-adapter implementation with a native transformer class, and offloads large chunks to the work pool so single huge writes no longer hold the JS thread.

Before: the constructor built a node:zlib Duplex and wrapped it with Readable.toWeb + Writable.toWeb. Every chunk took the async write path — one threadpool round-trip per 16KB of output — plus the Node Duplex machinery and a full copy of every output chunk in the Readable adapter.

After: a native CompressionStreamTransformer class (the TextEncoderStreamEncoder pattern) owns the same streaming zlib/brotli/zstd contexts node:zlib uses and the drive loop: transform(chunk, isFinish) runs the whole consume/produce loop in Rust and returns ≤16KB output chunks, each its own adopted allocation — full output windows hand their buffer to JS with no copy, and separate allocations mean no shared backing store for a consumer to reach past. The JS side of the builtin is only type coercion, error wrapping, and enqueue — no node:zlib stream object, no Duplex, no JS drive loop. The dead newBufferSourceTransformPairFromDuplex adapter and its kValidateChunk/kDestroyOnSyncError hooks are deleted.

Work-pool offload: chunks past 64KB call transformAsync instead — input is copied, the same drive loop runs on the work pool via AnyTaskJob, and the write resolves once the worker completes. Decompression and brotli encode go through transformAsync regardless of input size (a sub-threshold compressed chunk can expand to arbitrary output; brotli encode is slow at every size). The finish-flush also runs on the work pool. The engine stays in place — z_stream is self-referential and must not move — guarded by write_in_progress/pending_close flags exactly as NativeZlib does, with close() deferring until the in-flight job returns. Output is byte-identical to the synchronous path and to node:zlib's defaults.

Behaviors deliberately preserved (pinned by tests):

  • Write/read ordering. A spec-default TransformStream (readable highWaterMark: 0) stalls the first write until a reader attaches; the previous implementation resolved writes immediately while buffering, and code in the wild relies on that. The readable side keeps a byte-counting strategy with one chunk of headroom.
  • Node-flavored surface. String chunks accepted; bare ArrayBuffer and null rejected with the same error codes; ERR_INVALID_ARG_VALUE on unknown formats; corrupt input rejects with the engine's code (Z_DATA_ERROR etc.) and message. All five formats (gzip/deflate/deflate-raw/brotli/zstd), byte-identical output (same engine defaults).
  • Engine lifecycle. transformer.cancel releases the native context on writer.abort() / reader.cancel(); errors and the finish-flush close it (before enqueueing the final outputs, so a teardown racing the last enqueue cannot reach a live context); GC-abandoned streams release it in the finalizer.

Also fixed along the way: writing a TypedArray over a detached ArrayBuffer now throws a TypeError (previously relied on Buffer.from throwing; an explicit check pins it).

Measured (16MB gzip payload, release builds, macOS arm64)

before after
DecompressionStream 17.5 ms 3.7 ms 4.7x — 1.4x over gunzipSync (was 6.7x)
CompressionStream (level 6) 8.8 ms 5.4 ms 1.6x — at gzipSync parity
construction 18.8 µs 10.3 µs 1.8x

Tests

test/js/web/streams/compression.test.ts: 53 pass — round-trips for all five formats, input-type/error-code pins, write-before-read ordering, corrupt-input rejection, cancel/abort teardown, detached-buffer rejection, engine-lifecycle leak coverage (completed / cancelled mid-stream / GC-abandoned, RSS-bounded), and the work-pool path: 256KB single-write round-trips for all five formats, byte-identical to gzipSync, sequential gating, reader.cancel() while a large write is in flight, corrupt-large-chunk rejection. test-whatwg-webstreams-compression.js and test-global-webstreams.js pass. Two pins that asserted the old shared-backing-buffer strategy itself (zeroed reachable tail, buffer reuse) now assert the strictly stronger property: output chunks have byteOffset === 0 and no reachable tail at all.

Stack

Stacked on #32595 (transformer.cancel spec hook), which is stacked on #32620 (Web IDL promiseResolvedWith semantics). Against that base this PR's delta to TransformStreamInternals.ts / TransformStream.ts is the createCompressionTransform builtin only — zero changes to the spec algorithms. #32601 (full transform-streams WPT suite, 132/133) sits alongside.

@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator
Updated 10:07 AM PT - Jun 23rd, 2026

@robobun, your commit 0e0affc has 1 failures in Build #64240 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31728

That installs a local version of the PR into your bun-31728 executable, so you can run:

bun-31728 --bun

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Add a synchronous compression TransformStream helper; extend TransformStream internals to support cancel/finishPromise and robust teardown; migrate CompressionStream/DecompressionStream to use the helper (removing node:stream adapters); add tests for compression ordering, error propagation, and transformer.cancel semantics.

Changes

Stream Compression/Decompression Refactoring

Layer / File(s) Summary
Native CompressionStreamTransformer binding
src/runtime/webcore/CompressionStreamTransformer.rs, src/runtime/webcore/compression.classes.ts, src/runtime/webcore.rs, src/jsc/generated_classes_list.rs, src/js/builtins/BunBuiltinNames.h, src/jsc/bindings/ZigGlobalObject.cpp
New Rust #[bun_jsc::JsClass] CompressionStreamTransformer owns an Engine (zlib/brotli/zstd/closed states), validates mode on construction, drives the native context in transform(chunk, isFinish) via bounded windows, collects output into Uint8Arrays, throws synchronous errors with code/errno/cause, and cleans up on GC. Class is wired as a private global constructor and registered in the generated classes list.
TransformStream cancel, finishPromise, and robust teardown
src/js/builtins/TransformStreamInternals.ts, src/js/builtins/BunBuiltinNames.h
Extend controller setup with optional cancelAlgorithm and finishPromise state. Add transformStreamUnblockWrite() and transformStreamDefaultSourceCancelAlgorithm() to coordinate cancellation. Rewrite abort/close algorithms to dedupe completion via finishPromise, clear algorithms during teardown, and reject in-flight writes when algorithms are cleared. Add createCompressionTransform(mode) that wraps the native transformer, normalizes inputs, drives it incrementally, enqueues output slices, handles errors, and configures readable backpressure via highWaterMark and size.
CompressionStream and DecompressionStream migration
src/js/builtins/CompressionStream.ts, src/js/builtins/DecompressionStream.ts
Remove node:stream adapters. Both streams validate format against local mode maps, construct transforms via $createCompressionTransform(modes[format]), and assign transform.readable/transform.writable directly to instance private slots.
Transformer.cancel hook: types and wiring
src/js/builtins/TransformStream.ts, packages/bun-types/bun.d.ts, packages/bun-types/globals.d.ts
TransformStream constructor now captures optional transformer.cancel, validates it is a function, and wires it as cancelAlgorithm. Add TransformerCancelCallback interface and extend Transformer<I, O> with optional cancel property.
Compression and TransformStream cancel tests
test/js/web/streams/compression.test.ts, test/js/web/streams/streams.test.js, test/js/web/streams/streams-leak.test.ts
Add compression tests for write/read ordering (buffered writes before close), input validation (type errors matching Node.js), malformed data (truncation, corruption, garbage), gzip member concatenation, chunk shape/sizing, and engine lifecycle/memory reclamation. Add comprehensive TransformStream transformer.cancel tests covering invocation semantics, error propagation, single-invocation guarantee, async ordering, races with flush/transform, and termination edge cases. Relax leak test assertions to robustly validate buffer reuse without fixed chunk relationships.

Possibly related issues

  • oven-sh/bun#32190: Flaky leak test assertion is addressed by relaxing buffer-reuse expectations to be environment-agnostic.

Possibly related PRs

  • oven-sh/bun#30593: Both PRs refine test/js/web/streams/streams-leak.test.ts assertions to match NativeReadableStreamSource pull-buffer tail reuse behavior.

Suggested reviewers

  • cirospaciari
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Make CompressionStream native' directly and clearly describes the primary objective of the PR—replacing the node:zlib-adapter implementation with a fully native transformer class.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description is detailed and addresses both required template sections: comprehensive coverage of implementation changes and multiple verification methods including measured performance comparisons, test suite results, and specific test case examples.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/builtins/TransformStreamInternals.ts`:
- Around line 365-370: The close() function only runs after
flush()/engine.errored paths, leaving the zlib engine open if
controller.enqueue() throws or the writable is aborted; modify the teardown so
close() is invoked on all non-flush teardown paths (readable cancel and writable
abort) by wiring readable cancel and writable abort handlers to call close()
and/or reuse the internal transform helper routines used by drive()/flush(),
ensuring engine.close() is always executed regardless of where the transform
fails (refer to close(), drive(), flush(), controller.enqueue(), and
engine.errored to locate the spots to update).
- Around line 375-377: The drive() loop in createCompressionTransform(engine)
rejects plain ArrayBuffer; normalize chunks by checking chunk instanceof
ArrayBuffer before ArrayBuffer.isView so bare ArrayBuffer becomes a Buffer; also
ensure the zlib engine is always closed on error/cancellation by wrapping
controller.enqueue(...) and the drive/flush/finish paths with try/finally (or
add a centralized cleanup that calls engine.close()) so engine.close() runs if
enqueue throws or the transform is aborted, and remove reliance solely on
engine.errored/explicit close after flush.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: bfe7175a-ab44-43af-984a-efdb760b13af

📥 Commits

Reviewing files that changed from the base of the PR and between 9fd8850 and f269eb6.

📒 Files selected for processing (4)
  • src/js/builtins/CompressionStream.ts
  • src/js/builtins/DecompressionStream.ts
  • src/js/builtins/TransformStreamInternals.ts
  • test/js/web/streams/compression.test.ts

Comment thread src/js/builtins/TransformStreamInternals.ts
Comment thread src/js/builtins/TransformStreamInternals.ts Outdated
Comment thread src/js/builtins/TransformStreamInternals.ts
Comment thread src/js/builtins/TransformStreamInternals.ts Outdated
@alii

alii commented Jun 2, 2026

Copy link
Copy Markdown
Member Author

@robobun fix reviews & ci - also make sure we have good test coverage for Node.js compatibility. think about edge cases

@alii

alii commented Jun 2, 2026

Copy link
Copy Markdown
Member Author

(re/ compatibility, we want to make sure that even errors are the same, including when theyre thrown - e.g. async or not)

@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

✅ Fully native on the stacked base (#32595 transformer.cancel + #32601 WPT). The drive loop runs in Rust, chunks surface as exact-size adopted Uint8Arrays, and per-mode work-pool routing keeps the JS thread responsive: DecompressionStream and CompressionStream('brotli') take transformAsync on every non-empty write (input size says nothing about per-call work for either), zlib/zstd encode keep the 64KB sync fast path, and the finish-flush always offloads. reader.cancel()/writer.abort() release the native context deterministically on every teardown race. shieldingPromiseResolve replicates PromiseResolveThenableJob timing through $enqueueJob + $Promise.prototype.$then.$call so Web IDL "a promise resolved with" stays two-hop without touching a patched Promise.prototype.then.

All 34 review threads resolved. compression.test.ts 57/57, transformer.cancel suite 14/14, WPT cancel.any.js 11/11, readablestreamtoarraybuffer tamper test passes, vendored node webstreams-compression tests green; clean under BUN_JSC_validateExceptionChecks=1.

CI build 64240 on 0e0affc: 284/286 green. The two red lanes are darwin-26-aarch64: one is a buildkite artifact-download timeout (test runner never ran), the other is fetch-leak.test.ts RSS-threshold wobble (fixture-6 mid-loop sample 54.4MB vs final 66.0MB, budget 5MB). Both flakes also appear in main's build 64149; neither touches this diff. compression.test.ts and the streams suites are green on every lane. Diff is ready; needs a maintainer to merge past the darwin flake.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/builtins/TransformStreamInternals.ts`:
- Around line 433-465: In transformStreamDefaultSourceCancelAlgorithm, avoid
calling .$call on a possibly-cleared cancelAlgorithm by first reading
cancelAlgorithm into a local (e.g., const cancelAlgorithm =
$getByIdDirectPrivate(controller, "cancelAlgorithm")), then if cancelAlgorithm
is undefined set cancelPromise to a resolved Promise (e.g., Promise.resolve())
instead of calling .$call, otherwise call cancelAlgorithm.$call(undefined,
reason); keep the rest of the flow (clearing algorithms via
$transformStreamDefaultControllerClearAlgorithms, wiring cancelPromise.$then
handlers, and resolving/rejecting promiseCapability) unchanged so behavior
matches the sink abort path and prevents a crash when algorithms were cleared.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: cb1388f2-9ad1-434d-85fc-e6df342a22b7

📥 Commits

Reviewing files that changed from the base of the PR and between f269eb6 and b786929.

📒 Files selected for processing (5)
  • src/js/builtins/BunBuiltinNames.h
  • src/js/builtins/TransformStream.ts
  • src/js/builtins/TransformStreamInternals.ts
  • test/js/web/streams/compression.test.ts
  • test/js/web/streams/streams.test.js

Comment thread src/js/builtins/TransformStreamInternals.ts
Comment thread src/js/builtins/TransformStreamInternals.ts Outdated
Comment thread test/js/web/streams/compression.test.ts Outdated

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • This PR has far too many changes to TransformStream internals to support this one particular usage of it. It's an important usage, but I think this going to cause bugs and I'm hesitant to make a large change to this without running web platform tests in CI
  • CompressionStream & DecompressionStream really shouldn't be using node:zlib internally. They should get their own bindings specific to the use-case. And yes, synchronous makes sense for very tiny inputs but for large inputs it will certainly be a regression for main thread performance.
  • The particulars here - of using Buffer and all the tiny allocations - probably don't make sense. But doing it as a natively-managed Vec will be challenging to properly inform the GC of the memory cost so it shows up in reporting (it would need to both call reportMemoryAllocated and reportMemoryVisited, behind a mutex since GC happens) and would need a bunch of tests

Comment thread src/js/builtins/TransformStreamInternals.ts Outdated
Comment thread src/js/builtins/TransformStream.ts
@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Re: the TransformStream internals concern — for sizing the risk, the non-compression part of the delta is one self-contained spec feature: the transformer.cancel hook (whatwg/streams#1283) — cancelAlgorithm/finishPromise on the controller and the spec text for the sink abort / sink close / source cancel algorithms, which the engine-teardown review findings needed as a hook. Since WPT doesn't run in CI, the WPT transform-streams cancel cases are mirrored as bun tests in streams.test.js (reason propagation both directions, once-only across both sides, flush-vs-cancel exclusivity, async settle ordering, rejection propagation), with each behavior checked against Node v24, which implements the same spec text — plus regression tests for two teardown races the spec's reference implementation (and Node) crash on.

The CompressionStream rework direction (dedicated native bindings, threadpool for large inputs, Vec-backed output with reportMemoryAllocated/reportMemoryVisited) is your and @alii's call — noting only that the Node-compat suite added here (chunk-type matrix, per-format empty/truncated/trailing-garbage/multi-member semantics, error class+code surfacing, teardown reason propagation, write-before-read ordering incl. >16KB expansions) is implementation-agnostic, so it would validate a native rewrite as-is.

@alii

alii commented Jun 2, 2026

Copy link
Copy Markdown
Member Author

Let's do the most idiomatic, proper solution, which sounds like correctly moving the code to native. The tests are correctly pinning and match WPT, so keep at least those. Consider even more coverage, too, including things like checking for leaks.

Comment thread src/js/builtins/TransformStreamInternals.ts Outdated
Comment thread src/js/builtins/TransformStreamInternals.ts
Comment thread src/js/builtins/TransformStreamInternals.ts Outdated
@alii

alii commented Jun 4, 2026

Copy link
Copy Markdown
Member Author

Done — the implementation is now fully native. Two commits:

  • 74be624 adds a CompressionStreamTransformer native class (the TextEncoderStreamEncoder pattern) wrapping the same streaming zlib/brotli/zstd contexts node:zlib uses; the builtins no longer construct a node:zlib stream at all (no Duplex, no threadpool, no toWeb adapters).
  • c3ac99b moves the drive loop into Rust too: transform(chunk, isFinish) runs the whole consume/produce loop natively and returns ≤16KB output chunks, each its own adopted allocation (full windows are zero-copy handoffs). The JS side is now only type coercion, error wrapping, and enqueue — no loop, no Uint32Array state, net −54 lines. Separate allocations also remove the shared-backing-buffer concern entirely, so the two tests that pinned the old zero-filled-tail strategy now pin the strictly stronger property (byteOffset === 0, no reachable tail). A real gap surfaced and fixed along the way: detached ArrayBuffer views threw via Buffer.from before but passed through silently in an intermediate version — now an explicit TypeError, with a test.

Release-build numbers (16MB gzip payload, same machine):

before (toWeb adapters) now
DecompressionStream 17.5 ms 3.7 ms 4.7x
CompressionStream (level 6) 8.8 ms 5.4 ms 1.6x — gzipSync parity
construction 18.8 µs 10.3 µs 1.8x

Coverage: compression.test.ts 38/38 (incl. 3 new engine-lifecycle leak tests: completed / cancelled mid-stream / GC-abandoned, RSS-bounded), streams dir green (the one cat-pipe RSS failure is the pre-existing ASAN-baseline threshold issue, unrelated — it never constructs a compression stream), both node parallel webstreams files pass.

@alii alii changed the title CompressionStream: drive the zlib handle synchronously Make CompressionStream native Jun 4, 2026
Comment thread src/runtime/webcore/compression.classes.ts
Comment thread src/runtime/webcore/CompressionStreamTransformer.rs Outdated
Comment thread src/js/builtins/BunBuiltinNames.h Outdated

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/web/streams/compression.test.ts`:
- Line 289: Replace the swallowed writer.close() call with an explicit await and
assertion: after the drain step completes, await the writer.close() promise and
assert its settlement (e.g., use the test framework's "resolves"/"rejects"
helpers or await and let a rejection fail the test) instead of using .catch(()
=> {}), so any unexpected close failure fails the test and is captured for
diagnosis.
- Line 294: Replace the `"hello".repeat(8)` usage with a Buffer.alloc-based
construction as per the test guideline: in the assertion that calls
expect(total.subarray(64 * 1024).toString()).toBe(...), change the RHS to
Buffer.alloc(5 * 8, "hello").toString() (or Buffer.alloc(40,
"hello").toString()) so the repetitive string is built via Buffer.alloc rather
than String.prototype.repeat.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fe1fcdb9-8e4e-4dd6-b098-a59546027613

📥 Commits

Reviewing files that changed from the base of the PR and between 7efc7b1 and ee35373.

📒 Files selected for processing (14)
  • packages/bun-types/bun.d.ts
  • packages/bun-types/globals.d.ts
  • src/js/builtins/BunBuiltinNames.h
  • src/js/builtins/CompressionStream.ts
  • src/js/builtins/DecompressionStream.ts
  • src/js/builtins/TransformStreamInternals.ts
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/generated_classes_list.rs
  • src/runtime/webcore.rs
  • src/runtime/webcore/CompressionStreamTransformer.rs
  • src/runtime/webcore/compression.classes.ts
  • test/js/web/streams/compression.test.ts
  • test/js/web/streams/streams-leak.test.ts
  • test/js/web/streams/streams.test.js

Comment thread test/js/web/streams/compression.test.ts Outdated
Comment thread test/js/web/streams/compression.test.ts Outdated
Comment thread src/runtime/webcore/CompressionStreamTransformer.rs Outdated
Comment thread src/runtime/webcore/CompressionStreamTransformer.rs Outdated
Comment thread src/js/builtins/TransformStreamInternals.ts Outdated
Comment thread src/runtime/webcore/CompressionStreamTransformer.rs
Comment thread src/runtime/webcore/CompressionStreamTransformer.rs Outdated
Comment thread src/js/builtins/TransformStreamInternals.ts Outdated
@alii

alii commented Jun 9, 2026

Copy link
Copy Markdown
Member Author

@robobun fix review comments

@robobun

robobun commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

@Jarred-Sumner status on each point of your review, since the implementation changed substantially after it:

node:zlib internals. CompressionStream/DecompressionStream now have their own binding, CompressionStreamTransformer (src/runtime/webcore/CompressionStreamTransformer.rs). No node:zlib stream object, no Duplex, no threadpool round-trips, no JS drive loop: the builtins construct only this class and transform(chunk, isFinish) runs the whole consume/produce loop in Rust. It shares the low-level zlib/brotli/zstd contexts so output bytes and error codes are unchanged from the previous behavior.

Buffer and tiny allocations. Gone. Output chunks are exact-size native allocations adopted by JS as Uint8Arrays (full 16KB windows are zero-copy handoffs), so the GC sees every output byte through normal ArrayBuffer accounting, and the fixed engine context footprint is reported via estimatedSize from a field computed once at construction (safe to read from the concurrent marking thread). No reportMemoryAllocated/reportMemoryVisited mutex is needed because allocations transfer ownership to JS instead of living in a native-managed Vec.

TransformStream internals. The non-compression delta is one self-contained spec feature, transformer.cancel (whatwg/streams#1283): cancelAlgorithm/finishPromise on the controller plus the spec text for the source cancel and sink abort/close algorithms. The compression teardown uses it to release the native context promptly on reader.cancel() / writer.abort(). Since WPT does not run in CI, the WPT transform-streams cancel cases are mirrored as 13 tests in streams.test.js, each verified against Node v24 (which implements the same spec text), and the three teardown races found during review each have a regression test.

Sync for large inputs. This is the one open point. A single huge chunk does compress synchronously on the JS thread now (16MB gzip measures 5.4ms compress / 3.7ms decompress in release; the old adapter paid a threadpool round-trip per 16KB of output even for tiny writes). For large writes I would add threshold-based offload reusing the node:zlib WorkPool pattern: copy the input, run the same drive loop on the worker, pending_close for cancel during flight, and a promise-returning transform so backpressure holds. That adds real concurrency surface to the new class, so I have not put it in this PR without your sign-off. Want it here, or as a follow-up?

@alii

alii commented Jun 16, 2026

Copy link
Copy Markdown
Member Author

@robobun rebase

@robobun
robobun force-pushed the ali/native-sync-compression-stream branch from d8e182a to 1f4b0b6 Compare June 16, 2026 21:33
@alii
alii force-pushed the ali/transformstream-transformer-cancel branch from b1004c9 to 800f5f5 Compare June 23, 2026 00:48
@alii
alii force-pushed the ali/native-sync-compression-stream branch from 2113e7e to 2dfdbaa Compare June 23, 2026 00:51
@alii
alii force-pushed the ali/transformstream-transformer-cancel branch from 800f5f5 to aa1f955 Compare June 23, 2026 10:31
@alii
alii force-pushed the ali/native-sync-compression-stream branch from d58e90c to 873cbdc Compare June 23, 2026 10:32
@alii

alii commented Jun 23, 2026

Copy link
Copy Markdown
Member Author

@robobun adopt

@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

@alii not sure which sense of "adopt" you mean here. The rebased/reordered stack at 873cbdc looks right (534cc2a + the tamper-proof fix now sit under the transformer.cancel commits, which matches the #32595 layering), all 34 review threads are resolved, and build 64204 is running with 0 failures so far. If you mean take over driving this to merge: the remaining blockers are Jarred's changes-requested from Jun 2 (every point since addressed, summary in #31728 (comment)) and the #32595/#32601 base landing. If you mean something more specific, say the word.

alii and others added 17 commits June 23, 2026 17:23
Adds the transformer.cancel(reason) lifecycle hook from whatwg/streams#1283:
fires on reader.cancel()/writer.abort(), mutually exclusive with flush, gates
the teardown promise on its return. Wires [[cancelAlgorithm]]/[[finishPromise]]
through the controller and rewrites the sink-abort/source-cancel algorithms to
the post-#1283 spec text. Guards three teardown races where the spec reference
implementation crashes (write-vs-cancel, terminate-then-cancel, abort during a
failing transform) so user-facing promises settle with the correct reason.

Carved out of #31728 so that PR's TransformStream-internals delta drops to zero.

Claude-Session: https://claude.ai/code/session_01Qn8dDrArUZxc4towq11qzu
…iming

Adds test/js/third_party/wpt-streams/ with the upstream
streams/transform-streams/cancel.any.js (byte-identical, vendored at
wpt@e4a4672e9e) driven by the existing testharness shim, so the actual WPT
suite for transformer.cancel runs in CI.

Running it found one spec-timing bug not caught by the hand-mirrored tests:
the constructor resolved startPromise via an extra Promise.resolve().then()
hop instead of resolving it directly with the start() result (spec step 14).
That delayed the writable's [[started]] flip by one microtask, so a
controller.error() inside transformer.cancel() left the writable in
"erroring" (not yet "errored") when the source-cancel fulfill reaction
checked it — readable.cancel() then fulfilled instead of rejecting with the
controller error. WPT "readable.cancel() and a parallel writable.close()
should reject if a transformer.cancel() calls controller.error()" pins this.

11/11 WPT cancel tests now pass.

Claude-Session: https://claude.ai/code/session_01Qn8dDrArUZxc4towq11qzu
8a2d38b resolved startPromise synchronously to make one cancel.any.js
case pass, but vendoring the rest of the transform-streams WPT suite showed
that change shifts [[started]] one microtask too early for three other tests
(errors.any.js / general.any.js readable.cancel()-then-controller.error()
ordering). The original extra hop was compensating for a deeper divergence:
Web IDL "a promise resolved with x" is `new Promise(r => r(x))` (always a
fresh promise — the spec ref-impl explicitly avoids Promise.resolve for this
reason), but writableStreamDefaultControllerStart uses Promise.$resolve which
returns the input promise unchanged. Fixing that is a WritableStream change
outside this PR's scope; for now revert the TransformStream constructor to
its previous form and mark the one timing-dependent cancel.any.js case as a
documented known failure (10/11 WPT cancel tests pass).

Claude-Session: https://claude.ai/code/session_01Qn8dDrArUZxc4towq11qzu
… test

With the Web IDL promise-resolved-with fix underneath (the writable's
[[started]] reaction now queues at the spec hop), all 11 cancel.any.js WPT
cases pass. The hand-mirrored "failing flush racing reader.cancel()" test
asserted the old hop-count's outcome (close runs flush before cancel joins);
with spec timing, cancel reaches the source-cancel algorithm first and close
joins its finishPromise — flush is never invoked, matching Node. Rewrite the
test to trigger reader.cancel() from inside flush(), which is the scenario
the SinkCloseAlgorithm reject-with-r change actually guards.

Claude-Session: https://claude.ai/code/session_01Qn8dDrArUZxc4towq11qzu
Replaces the node:zlib-adapter implementation with a dedicated
CompressionStreamTransformer native class (the TextEncoderStreamEncoder
pattern) owning the same streaming zlib/brotli/zstd contexts node:zlib
uses, and the whole drive loop: transform(chunk, isFinish) runs the
consume/produce loop in Rust and returns exact-size adopted output
Uint8Arrays (full output windows handed to JS with no copy). The JS
builtin is only type coercion, error wrapping, and enqueue; no
node:zlib stream object, no Duplex, no threadpool, no JS drive loop.

Also implements the spec transformer.cancel hook (whatwg/streams#1283):
cancelAlgorithm/finishPromise on the controller and the spec text for
the source cancel and sink abort/close algorithms, with the WPT
transform-streams cancel cases mirrored as bun tests. The compression
builtin uses it to release the native context promptly on
reader.cancel() / writer.abort().

Rebased onto main with the Node v26 chunk-type semantics from #31991:
plain ArrayBuffer is now accepted (wrapped as a Uint8Array for the
native transform), SharedArrayBuffer and SAB-backed views reject with
ERR_INVALID_ARG_TYPE.

Co-authored-by: robobun <117481402+robobun@users.noreply.github.com>
format in modes walks the prototype chain, so Object.prototype keys
like "toString" passed the check and reached the native constructor's
number guard with the wrong error code. __proto__: null restricts the
in check to own keys.
constructor() returning Err after Box<Self> is built runs Drop, which
calls Engine::close() on a context whose init failed with state:
None. brotli's close() unwraps that and zstd's passes null to
ZSTD_CCtx_reset. Set Engine::Closed inside the init closure on error
so Drop is a no-op and the intended JS exception propagates.
Chunks past 64KB (4× the 16KB output granularity) take a new
transformAsync path: input is copied, the same drive loop runs on the
work pool via AnyTaskJob, and the write resolves once the worker
completes. Small chunks stay synchronous (no copy, no thread hop, no
promise allocation). The engine stays in place — z_stream is
self-referential and must not move — guarded by write_in_progress /
pending_close flags exactly as NativeZlib does, with close() deferring
until the in-flight job returns. Output is byte-identical to the
synchronous path and to node:zlib's defaults.

Addresses the remaining review point on this PR: synchronous compression
of a single huge chunk no longer holds the JS thread.

Claude-Session: https://claude.ai/code/session_01Qn8dDrArUZxc4towq11qzu
The input-size threshold is the right axis for compression, but for
decompression a sub-threshold compressed chunk can expand to arbitrary
output and stall the JS thread for the whole drive loop. Route the five
decode modes through transformAsync regardless of input size; the
pre-PR node:zlib adapter was always async here too. Keeps the encode
sync fast path.

Also: settle the transformAsync promise when building the output array
throws instead of leaving the awaiting write hung, and delete the
now-uncalled newBufferSourceTransformPairFromDuplex adapter.
Quarantine + shadow memory push absolute RSS past any fixed budget on the
ASAN debug build (~1.4GB observed vs the 700MB ASAN budget). Skip rather
than keep widening the budget; the relative-growth property is asserted in
release CI. Drop the now-dead ASAN budget branches.

Claude-Session: https://claude.ai/code/session_01Qn8dDrArUZxc4towq11qzu
newBufferSourceTransformPairFromDuplex was the only thing that ever set
these options; with CompressionStream now native the symbol definitions,
option reads, writableOptions passthrough, try/catch wrapper, exports and
the test comment referencing them are all dead.
brotli at the default quality 11 only buffers input during
BROTLI_OPERATION_PROCESS; the residual block (up to ~256KB) is encoded at
BROTLI_OPERATION_FINISH. flush() passes a 0-byte chunk which was always
below the async threshold, so a 200KB brotli stream's finish ran ~330ms of
q11 entropy coding on the JS thread. The pre-PR adapter ran this on the
threadpool.

flush() now calls transformAsync directly and chains close() on its
settlement. The one thread hop is noise for zlib/zstd whose finish is just
a trailer.
reader.cancel() arriving while the finish-flush worker is in flight
closes the readable and short-circuits sourceCancelAlgorithm on the
already-set finishPromise, so the transformer's cancel() hook never
runs. The worker then resolves and enqueueOutputs throws on the closed
readable, which skipped close() (engine lingered until GC) and rejected
both writer.close() and reader.cancel() with the internal 'cannot close
or enqueue' TypeError.

Close first (the engine is done once the outputs are extracted) and
swallow the enqueue throw: the outputs have nowhere to go and
sinkCloseAlgorithm resolves the close promise cleanly since the
readable is closed, not errored.
At the default quality 11, BROTLI_OPERATION_PROCESS only buffers input
until the encoder's ring buffer reaches input_block_size (~256KB) and
then entropy-codes the whole metablock in that call. A stream of
sub-64KB writes (the common pipeThrough case from network or file
sources) took the synchronous path on every chunk and ran the ~300ms
q11 encode on the JS thread each time cumulative input crossed a 256KB
boundary. zlib and zstd encode compress incrementally per PROCESS call
so their input-size threshold stays.

Also fixes the 'reader.cancel() while a large write is in flight' test:
it called writer.write(big) before the writable controller's started
flag was set (a microtask after construction), so the chunk was only
queued and transformAsync never ran; pending_close was never exercised.
Await writer.ready first so the work-pool job is actually in flight
when cancel lands.
@alii
alii force-pushed the ali/transformstream-transformer-cancel branch from aa1f955 to 2caa760 Compare June 23, 2026 16:25
@alii
alii force-pushed the ali/native-sync-compression-stream branch from 873cbdc to 0e0affc Compare June 23, 2026 16:25
robobun pushed a commit that referenced this pull request Jun 24, 2026
Adds the transformer.cancel(reason) lifecycle hook from whatwg/streams#1283:
fires on reader.cancel()/writer.abort(), mutually exclusive with flush, gates
the teardown promise on its return. Wires [[cancelAlgorithm]]/[[finishPromise]]
through the controller and rewrites the sink-abort/source-cancel algorithms to
the post-#1283 spec text. Guards three teardown races where the spec reference
implementation crashes (write-vs-cancel, terminate-then-cancel, abort during a
failing transform) so user-facing promises settle with the correct reason.

Carved out of #31728 so that PR's TransformStream-internals delta drops to zero.

Claude-Session: https://claude.ai/code/session_01Qn8dDrArUZxc4towq11qzu
@robobun
robobun force-pushed the ali/transformstream-transformer-cancel branch from 6aeb0d8 to 40f5bac Compare June 24, 2026 15:12
@alii
alii marked this pull request as draft July 1, 2026 21:09
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.

3 participants