Skip to content

node:zlib: convert compression-stream wrapper self-ref from StrongOptional to JsRef - #31843

Closed
Jarred-Sumner wants to merge 8 commits into
mainfrom
claude/complex-11-node-zlib-convert-compression-stream-wra
Closed

Jarred-Sumner wants to merge 8 commits into
mainfrom
claude/complex-11-node-zlib-convert-compression-stream-wra

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

The native NativeZlib/NativeBrotli/NativeZstd compression streams held their JS wrapper back-reference as a bare jsc Strong.Optional, the pattern flagged as a leak hazard for self-refs to a class's own wrapper. This converts the shared this_value slot to JsRef across the CompressionStream mixin trait and all three implementations. The lifecycle is preserved exactly: the ref is upgraded to strong only while an async write is in flight on the work pool (the wrapper has no pending-activity hook, so this strong ref is the sole GC liveness source for the wrapper and its cached writeCallback/pendingInput/pendingOutput values mid-write), taken and cleared when the write completes on the JS thread, cleared on close, and marked Finalized at native teardown as advisory terminal state (try_get/set_weak treat it as dead; the actual protection against late access is that the struct is freed immediately after refcount-zero deinit). All three implementations finalize the slot identically in teardown. NativeZstd shares the same mixin macro, so its field is converted in the same mechanical way. Tested with new subprocess tests in test/js/node/zlib/zlib.test.js that drive async deflate/gzip/brotli/zstd round-trips with Bun.gc(true) forced between scheduling and completion (would crash or corrupt output if in-flight rooting were lost), plus destroy-with-in-flight-write loops under GC pressure covering the pending_close path. The new tests are regression-only guards: the conversion intentionally preserves GC behavior, so they also pass on the pre-change build; the verify phase should run the full test/js/node/zlib/ suite (and ideally ASAN) as the actual verification.

Verification

Implemented and verified on a unified integration branch: full debug build (linux-x64, ASAN), cargo check across the workspace, and the affected test files run against the debug build (failures cross-checked against main's build to exclude pre-existing issues). Each change was reviewed twice (compile/API correctness and GC/concurrency/semantics lenses) with findings repaired before landing.

Jarred-Sumner and others added 6 commits June 4, 2026 00:46
Drain the Rust port's marker backlog: every audited TODO(port)/TODO(b2-*)
marker and PORT NOTE comment was individually resolved - fixed, deleted as
stale, untagged to a plain comment, or split into a tracked follow-up work
order. ~495 real code fixes, each citing the Zig sibling for intended
semantics; the 101 markers that remain are claimed by written work orders
for changes too risky to land in a bulk branch (GC rooting, arena lifetime
threading, cross-thread ownership restructures).

Verified: full debug build + smoke tests on linux-x64, cross-target cargo
check on windows/macos/linux x64/aarch64.
The codebase has moved from a port to a standalone Rust implementation;
comments citing the prior Zig implementation (file/line cross-references,
std.* stdlib mentions, quoted Zig syntax, divergence notes) no longer aid
maintenance. Delete them, keeping SAFETY justifications rewritten in terms
of the Rust code's own invariants. References to current identifiers
(ZigString etc.) are unchanged.
NewServer::init moves the config into the server, so the hot-map
registration that runs after init read an empty id from the stale local
and registered every server under the same key, panicking on the second
Bun.serve in --hot processes. Read allow_hot and id from the server's own
config instead. This path was unreachable until hot_map() gained its
lazy-init accessor.

Also update parsePackedFeaturesList() for the new define_features! shape
(internal @storage recursion and core = IDENT aliases).
- define_features!: drop the allow(non_upper_case_globals) from the pub use
  re-export arm where the lint cannot apply (useless_attribute x47)
- HardcodedModule: alias maps use bun_collections::HashMap per the
  disallowed-types policy (wyhash instead of SipHash)
- shell_parser, js_printer: byte paths/text Display via bstr::BStr instead
  of String::from_utf8_lossy
- shell_parser: drop_in_place(&raw mut ...) per borrow_as_ptr
- Loader: derive Default instead of the manual impl
- Yarn printer: JSON debug env check via bun_core::getenv_z
- pnpm: remove empty is_prod() placeholder branch
- WebSocketUpgradeClient: SslCtxOwned::into_raw via ManuallyDrop instead of
  mem::forget
- PostgresSQLConnection: remove redundant AnyPostgresError::from
- streams: return result instead of Ok(result?)
…ional to JsRef

The native NativeZlib/NativeBrotli/NativeZstd compression streams held their JS wrapper back-reference as a bare jsc Strong.Optional, the pattern flagged as a leak hazard for self-refs to a class's own wrapper. This converts the shared this_value slot to JsRef across the CompressionStream mixin trait and all three implementations. The lifecycle is preserved exactly: the ref is upgraded to strong only while an async write is in flight on the work pool (the wrapper has no pending-activity hook, so this strong ref is the sole GC liveness source for the wrapper and its cached writeCallback/pendingInput/pendingOutput values mid-write), taken and cleared when the write completes on the JS thread, cleared on close, and marked Finalized at native teardown as advisory terminal state (try_get/set_weak treat it as dead; the actual protection against late access is that the struct is freed immediately after refcount-zero deinit). All three implementations finalize the slot identically in teardown. NativeZstd shares the same mixin macro, so its field is converted in the same mechanical way. Tested with new subprocess tests in test/js/node/zlib/zlib.test.js that drive async deflate/gzip/brotli/zstd round-trips with Bun.gc(true) forced between scheduling and completion (would crash or corrupt output if in-flight rooting were lost), plus destroy-with-in-flight-write loops under GC pressure covering the pending_close path. The new tests are regression-only guards: the conversion intentionally preserves GC behavior, so they also pass on the pre-change build; the verify phase should run the full test/js/node/zlib/ suite (and ideally ASAN) as the actual verification.
@robobun

robobun commented Jun 4, 2026 •

Copy link
Copy Markdown
Collaborator

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

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

Thanks for addressing both nits. No bugs found, but the core StrongOptional → JsRef conversion changes GC-rooting semantics across the work-pool thread boundary, so I'd like a human to sign off on the lifecycle (set_strong → try_get/clear → finalize) rather than auto-approving.

Extended reasoning...

Overview

The substantive change is confined to four files: node_zlib_binding.rs (the CompressionStream mixin trait) and the three Native{Zlib,Brotli,Zstd}.rs implementations, plus two new regression tests in zlib.test.js. The remaining ~200 files in the diff are pure autofix.ci formatting noise — trailing-newline removal, import reordering, derive consolidation, blank-line trimming — with no semantic impact.

The core change swaps the this_value slot type from StrongOptional to JsRef and adjusts the four lifecycle touchpoints accordingly: set_strong() on async-write start, try_get() + reset-to-empty() on completion, reset on close_internal, and finalize() in each struct's teardown path. The PR description states behavior is preserved exactly, and the new subprocess tests force GC between scheduling and completion to guard against regressions.

Security risks

No injection, auth, or data-exposure surface. The risk class here is memory safety: this is the GC-rooting mechanism that keeps the JS wrapper (and its cached pending input/output buffers) alive while a worker thread holds raw pointers into them. A mistake in the strong/weak transition would manifest as use-after-free under GC pressure, not as a security boundary bypass — but UAF in native code is still a correctness/stability concern that merits careful review.

Level of scrutiny

High. Even though the conversion is described as mechanical and behavior-preserving, it sits at the intersection of (a) JSC GC rooting, (b) cross-thread lifetime management via the work pool, and (c) intrusive refcounting with explicit finalize() calls in three slightly different teardown paths (destroy_on_zero for Brotli, deinit for Zlib, Drop for Zstd). The JsRef API surface (set_strong, try_get, empty, finalize) has subtly different semantics from StrongOptional (set, try_swap, deinit), and verifying the mapping is correct for each path is exactly the kind of thing a human reviewer familiar with the JsRef contract should confirm.

Other factors

  • Both prior review nits (assert on stderr, drop explicit timeouts) were addressed in 3cbecef.
  • The bug-hunting pass found nothing.
  • ASAN + debug-build verification is documented in the PR description.
  • The autofix formatting churn is large but inert; it does make the diff harder to skim, which is another reason a human should look at the four substantive files directly rather than trusting the file count.

@Jarred-Sumner
Jarred-Sumner changed the base branch from claude/todo-audit-fixes to main June 5, 2026 02:53
@Jarred-Sumner
Jarred-Sumner requested a review from alii as a code owner June 5, 2026 02:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants