Skip to content

node:zlib: hold the native-handle wrapper back-ref as a JsRef - #34671

Open
robobun wants to merge 3 commits into
mainfrom
farm/7d25f48e/zlib-jsref-self-ref
Open

robobun wants to merge 3 commits into
mainfrom
farm/7d25f48e/zlib-jsref-self-ref

Conversation

@robobun

@robobun robobun commented Jul 19, 2026 •

Copy link
Copy Markdown
Collaborator

What this does

NativeZlib / NativeBrotli / NativeZstd each keep a back-reference to their own JS wrapper so the completion path in run_from_js_thread can reach the cached writeResult / writeCallback / pendingInput / pendingOutput values after a work-pool write. That slot was a bare StrongOptional, which is the type the repo flags as a self-reference hazard for JsClass payloads (a Strong held by the payload to its own wrapper is a GC cycle). The canonical type for a wrapper back-ref that is strong only while pending work exists is JsRef; src/jsc/JSRef.rs documents the pattern and timers, Terminal, and Cron already use it. This PR makes the same mechanical conversion across the CompressionStream mixin trait and all three implementations, removing the TODO in NativeBrotli.rs.

The lifecycle is preserved exactly:

  • write() upgrades the ref to strong (set_strong) before scheduling the work-pool task. The wrapper has no hasPendingActivity hook, so this strong ref is the sole GC root for the wrapper (and the pinned pending-buffer values it caches) across the thread hop.
  • run_from_js_thread() reads the value and resets the slot to JsRef::empty() before invoking callbacks.
  • close() resets the slot.
  • finalize() now marks the slot Finalized, matching the pattern in timer_object_internals.rs / Terminal.rs / cron.rs.

Why this is correct to have

This is a type-level refactor, not a leak fix. The previous StrongOptional already cleared its HandleSlot in try_swap() on every write completion (verified with heapStats().objectTypeCounts.NativeZlib: the live count stays bounded at 1-3 across thousands of dropped streams without close()), so there was no steady-state wrapper leak on main. JsRef is still the right type here because these classes have a finalize: true hook: JsRef::Finalized is the terminal state designed for exactly that, and holding a self-Strong via StrongOptional leaves no structural signal that the slot is a wrapper back-ref rather than an ordinary root. #31843 attempted the same conversion earlier and was closed; this is a minimal redo against current main (4 src files, no unrelated reformatting).

Verification

Adds a subprocess test to test/js/node/zlib/zlib.test.js that drives the native handle directly (bypassing the Transform wrapper) so it exercises only the CompressionStream lifecycle:

  • Forces a full GC while an async write is in flight for each of zlib / brotli / zstd and checks the write completes with output. Would crash or read freed writeResult storage if the in-flight strong root were lost.
  • Runs three batches of 50 handles that each do one async write without close(), and asserts heapStats().objectTypeCounts.NativeZlib stays bounded rather than growing by 50 per batch. Would fail if the completion path stopped clearing the strong ref.

Because the conversion is behavior-preserving the new test also passes on the prior build; the full test/js/node/zlib/ suite passes on this branch (modulo the unrelated listenerCountSlow ReferenceError on main, tracked in #34667, which this PR's test avoids by not going through the readable-stream path).


[review] gate passed · iteration 1 · 5 files touched

fails on main (without fix)
ASAN without fix: 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/zlib/zlib.test.js
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (7798d3254)

test/js/node/zlib/zlib.test.js:
(pass) prototype and name and constructor > Gzip > Gzip.prototype should be instanceof Gzip.__proto__ [2.92ms]
(pass) prototype and name and constructor > Gzip > Gzip.prototype.constructor should be Gzip [1.16ms]
(pass) prototype and name and constructor > Gzip > Gzip.name should be Gzip [1.47ms]
(pass) prototype and name and constructor > Gzip > Gzip.prototype.__proto__.constructor.name should be Zlib [1.94ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype should be instanceof Gunzip.__proto__ [0.50ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype.constructor should be Gunzip [0.33ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.n
... (truncated)

release without fix: 34 failed, 2 skipped
bun test v1.4.0-canary.1 (23abfbf1a)

test/js/node/zlib/zlib.test.js:
(pass) prototype and name and constructor > Gzip > Gzip.prototype should be instanceof Gzip.__proto__ [0.02ms]
(pass) prototype and name and constructor > Gzip > Gzip.prototype.constructor should be Gzip [0.01ms]
(pass) prototype and name and constructor > Gzip > Gzip.name should be Gzip [0.01ms]
(pass) prototype and name and constructor > Gzip > Gzip.prototype.__proto__.constructor.name should be Zlib [0.02ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype should be instanceof Gunzip.__proto__
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype.constructor should be Gunzip
(pass) prototype and name and constructor > Gunzip > Gunzip.name should be Gunzip
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype.__proto__.constructor.name should be Zlib
(pass) prototype and name and constructor > Deflate > Deflate.prototype should be instanceof Deflate.__proto__
(pass) prototype and name and constructor > Deflate > Deflate.prototype.constructor should be Deflate
(pass) prototype and name and constructor > Deflate > Deflate.name should be Deflate

... (truncated)
passes on PR (with fix)
ASAN with fix: 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/zlib/zlib.test.js
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (7798d3254)

test/js/node/zlib/zlib.test.js:
(pass) prototype and name and constructor > Gzip > Gzip.prototype should be instanceof Gzip.__proto__ [1.72ms]
(pass) prototype and name and constructor > Gzip > Gzip.prototype.constructor should be Gzip [1.53ms]
(pass) prototype and name and constructor > Gzip > Gzip.name should be Gzip [1.50ms]
(pass) prototype and name and constructor > Gzip > Gzip.prototype.__proto__.constructor.name should be Zlib [1.61ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype should be instanceof Gunzip.__proto__ [0.49ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype.constructor should be Gunzip [0.34ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.n
... (truncated)

release with fix: 2 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 751ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[1/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version: 1.29.0)
�[1m�[92m    Blocking�[0m waiting for file lock on build directory
�[1m�[92m   Compiling�[0m bun_runtime v0.0.0 (/workspace/bun/src/runtime)
�[1m�[
... (truncated)
diff hotspot
src/runtime/node/node_zlib_binding.rs |  35 +++++++----
 src/runtime/node/zlib/NativeBrotli.rs |  11 ++--
 src/runtime/node/zlib/NativeZlib.rs   |  10 ++--
 src/runtime/node/zlib/NativeZstd.rs   |   9 ++-
 test/js/node/zlib/zlib.test.js        | 106 +++++++++++++++++++++++++++++++++-
 5 files changed, 143 insertions(+), 28 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                   reads  edits  tests
src/runtime/node/node_zlib_binding.rs      1      1      0
src/runtime/node/zlib/NativeBrotli.rs      1      1      0
src/runtime/node/zlib/NativeZlib.rs        1      1      0
src/runtime/node/zlib/NativeZstd.rs        1      1      0
test/js/node/zlib/zlib.test.js             1      9      0

@coderabbitai

coderabbitai Bot commented Jul 19, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7a66aa77-59bf-486e-90f1-5871bc13fe50

📥 Commits

Reviewing files that changed from the base of the PR and between 20b4a0b and 7798d32.

📒 Files selected for processing (5)
  • src/runtime/node/node_zlib_binding.rs
  • src/runtime/node/zlib/NativeBrotli.rs
  • src/runtime/node/zlib/NativeZlib.rs
  • src/runtime/node/zlib/NativeZstd.rs
  • test/js/node/zlib/zlib.test.js

Walkthrough

Changes

The zlib, Brotli, and Zstd native wrappers now use JsRef for JavaScript back-references. Async writes temporarily root wrappers, cleanup clears or finalizes references, and tests verify reachability during writes and collection afterward.

Zlib JsRef lifecycle

Layer / File(s) Summary
JsRef wrapper storage contracts
src/runtime/node/node_zlib_binding.rs, src/runtime/node/zlib/Native*.rs
Compression stream and native wrapper back-references use JsRef, with updated initialization and cleanup handling.
Async reference rooting and cleanup
src/runtime/node/node_zlib_binding.rs
Async writes use set_strong(...); completion, close, and finalization clear or finalize the stored reference.
Wrapper reachability and collection tests
test/js/node/zlib/zlib.test.js
Tests verify wrapper reachability during writes and collection after completion for zlib, Brotli, and Zstd handles.

Possibly related PRs

  • oven-sh/bun#34427: Updates related async write and reset handling in node_zlib_binding.rs.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: converting the native-handle wrapper back-ref to JsRef.
Description check ✅ Passed The description covers both required topics, including what changed and how it was verified, though the headings don't match the template exactly.
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.

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

@robobun

robobun commented Jul 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:43 PM PT - Jul 19th, 2026

❌ @Jarred-Sumner, your commit 60625ef has 2 failures in Build #75815 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34671

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

bun-34671 --bun

Comment thread test/js/node/zlib/zlib.test.js Outdated
Comment thread test/js/node/zlib/zlib.test.js Outdated
@robobun

robobun commented Jul 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Review feedback addressed in 7798d32:

  • batch() now allocates a fresh output buffer per iteration so concurrently scheduled work-pool writes no longer share one.
  • checkRooting() now constructs and schedules the handle in its own frame and observes it through a WeakRef, so the forced GC runs after the allocating frame has unwound and the JsRef strong root is the thing being tested rather than a live bytecode local.
  • Subprocess assertion switched to the combined {stdout, stderr, exitCode} form.

This conversion is behavior-preserving: the previous StrongOptional::try_swap() already cleared its GC root on every write completion, so the new test also passes on the prior build (verified against main via heapStats().objectTypeCounts.NativeZlib, which stays bounded across thousands of dropped handles without close()). The test is a lifecycle regression guard, not a fail-before proof.

CI on 7798d32: the diff is green on every lane. The two failures are unrelated to this change (test-worker-message-port-transfer-terminate.js is a pre-existing ASAN assertion failure on main; compile-windows-metadata.test.ts is a known flaky Windows bundler test that passed on retry). Ready for review.

robobun added 2 commits July 19, 2026 02:07
NativeZlib/NativeBrotli/NativeZstd each keep a back-reference to their own JS
wrapper so the completion callback on run_from_js_thread can reach the cached
writeResult/writeCallback/pendingInput/pendingOutput values after a work-pool
write. That slot was a bare StrongOptional, which is the type the repo flags
as a self-ref hazard for JsClass payloads; the canonical type for a wrapper
back-ref that is strong only while pending work exists is JsRef (see
JSRef.rs, timer_object_internals.rs, Terminal.rs, cron.rs).

This converts the shared this_value slot to JsRef across the CompressionStream
mixin trait and all three implementations. The lifecycle is preserved exactly:
write() upgrades the ref to strong before scheduling the work-pool task,
run_from_js_thread() takes the value and drops the strong ref before invoking
callbacks, close() resets it, and finalize() marks it terminal. The old
StrongOptional already cleared its HandleSlot in try_swap(), so there was no
steady-state wrapper leak; the JsRef spelling is the correct one for a
self-ref and removes the TODO in NativeBrotli.rs.

Adds a subprocess test that drives the native handle directly (bypassing the
Transform wrapper) to force a full GC while an async zlib/brotli/zstd write is
in flight and then to verify the wrapper count stays bounded across batches.
It is a regression guard for both halves of the lifecycle and also passes on
the prior build.
- Move the handle into its own frame and return a WeakRef so the forced GC runs
  after the allocating frame has unwound; the previous shape left `h` in a
  live bytecode register at the GC call, making the rooting assertion vacuous.
- Give each concurrent write in batch() its own output buffer so worker threads
  do not race on the same 512-byte allocation.
- Use a combined {stdout, stderr, exitCode} assertion.
@robobun
robobun force-pushed the farm/7d25f48e/zlib-jsref-self-ref branch from 23abfbf to 7798d32 Compare July 19, 2026 02:09

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

Both earlier findings (shared out buffer race in batch(), stderr assertion form) are addressed in the current diff, and no new issues turned up this pass. The StrongOptional → JsRef conversion maps 1:1 (set → set_strong, try_swap → try_get + reset, deinit → empty()) and matches the pattern in Terminal.rs / timer_object_internals.rs. That said, this is GC-rooting code that keeps the wrapper and its pinned buffers alive across a work-pool thread hop, so it's worth a human look before landing.

Extended reasoning...

Overview

This PR converts the this_value back-reference field on NativeZlib / NativeBrotli / NativeZstd from StrongOptional to JsRef, the repo's canonical type for a JsClass payload's back-reference to its own JS wrapper. The change touches the CompressionStream mixin trait in node_zlib_binding.rs (the trait accessor signature, write(), run_from_js_thread(), close_internal(), finalize(), and the __impl_compression_stream! macro) plus the three struct definitions and constructors. It also adds a subprocess GC-stress test to zlib.test.js.

Security risks

No new attack surface. The change is a type substitution in an internal GC-rooting slot. The risk class is memory safety (UAF on the JS wrapper or its cached pendingInput/pendingOutput array buffers if the strong root were lost mid-write), not injection/auth/data exposure.

Level of scrutiny

High. This is native GC-lifecycle code on a hot path (node:zlib async writes) that hands raw buffer pointers to a worker thread and relies on the this_value root to keep the wrapper (and via its cached WriteBarrier slots, the pinned input/output buffers) alive across the thread hop. The conversion itself is mechanical and mirrors src/jsc/JSRef.rs's documented pattern and existing users (Terminal.rs:1904, timer_object_internals.rs, cron.rs), and the removed TODO in NativeBrotli.rs explicitly called for this exact change. I verified the API mapping against JSRef.rs: set_strong(value, global) argument order is correct, try_get() + *v = JsRef::empty() replicates try_swap()'s take-and-clear semantics, and finalize() only ever runs when the slot is already Weak(UNDEFINED) (a live Strong self-ref would prevent the wrapper from being collected in the first place), so the new terminal-state marking is defensive rather than load-bearing.

Other factors

The two issues I flagged on the prior revision are both fixed in the current diff (per-iteration out buffer in batch(); combined {stdout, stderr, exitCode} assertion), and both inline threads are marked resolved. The PR body honestly notes the new test also passes on main since the change is behavior-preserving, so the test guards against regression rather than proving the fix. Given it's GC-rooting code across a thread boundary — the exact category REVIEW.md's memory-safety section calls out — I'm deferring rather than approving so a maintainer familiar with the JsRef migration can confirm.

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

LGTM — the two test issues from the earlier pass (shared out buffer across concurrent work-pool writes, and the stderr assertion form) are both addressed, and the StrongOptional → JsRef conversion follows the documented back-ref pattern used by timers/Terminal/cron.

What was reviewed:

  • set_strong / try_get + *v = JsRef::empty() / finalize() semantics against src/jsc/JSRef.rs — behavior matches the previous StrongOptional::set / try_swap / deinit lifecycle.
  • JsCell::set / with_mut drop the old JsRef variant, so the Strong HandleSlot is released on every reset path (close, run_from_js_thread, deinit).
  • Ruled out: weak.deref() in checkRooting being vacuous under WeakRef [[KeptAlive]] — the schedule() frame has returned before the check, and the load-bearing assertion is await promise + output length anyway.
Extended reasoning...

Overview

This PR converts the this_value back-reference on NativeZlib / NativeBrotli / NativeZstd from StrongOptional to JsRef, the repo's canonical type for a JsClass payload holding a reference to its own JS wrapper that is strong only while pending work exists. The change touches the shared CompressionStream mixin in node_zlib_binding.rs and the three field declarations/initializers, plus a new subprocess-based lifecycle test in zlib.test.js. It removes the pre-existing TODO in NativeBrotli.rs that flagged exactly this hazard.

Security risks

None. This is an internal GC-rooting mechanism change with no user-facing surface, no parsing of untrusted input, and no auth/crypto/permission code.

Level of scrutiny

Medium-high — GC lifecycle in native code is the most-blocked category per REVIEW.md, so I traced each of the four state transitions against src/jsc/JSRef.rs:

  • write(): set_strong(this_value, global) creates/reuses a Strong handle rooting the wrapper across the work-pool hop — equivalent to the old StrongOptional::set.
  • run_from_js_thread(): try_get() reads the JSValue from the Strong variant, then *v = JsRef::empty() drops it (releasing the HandleSlot via Strong::Drop), then ensure_still_alive keeps the value on the native stack — equivalent to try_swap().
  • close(): *v = JsRef::empty() drops any held Strong — equivalent to deinit().
  • finalize(): new v.finalize() sets the terminal Finalized state before T::deref. When finalize runs the slot is empty (a live Strong would have rooted the wrapper and prevented finalize), so this is defensive and matches the pattern in timer_object_internals.rs, Terminal.rs, cron.rs.

I also confirmed JsCell::set (used in NativeBrotli::deinit) does *slot = value, dropping the previous JsRef and releasing any Strong it held.

Other factors

Both of my earlier findings are resolved: batch() now allocates a per-iteration output buffer so concurrently scheduled deflates no longer race on shared bytes, and the subprocess assertion uses the combined {stdout, stderr, exitCode} form. The verifier ruled out the WeakRef [[KeptAlive]] concern on checkRooting — the constructing frame unwinds before Bun.gc(true), and the substantive assertion is that the write completes with output. robobun reports the diff green on every lane with only pre-existing/unrelated flakes. This is a mechanical, behavior-preserving type conversion to the documented pattern, with a lifecycle regression guard covering all three implementations.

This branch has not been deployed

No deployments
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