Skip to content

node:zlib: block worker shutdown on in-flight async compression (UAF) - #35155

Closed
robobun wants to merge 8 commits into
mainfrom
claude/farm/82629685/zlib-worker-terminate-uaf
Closed

node:zlib: block worker shutdown on in-flight async compression (UAF)#35155
robobun wants to merge 8 commits into
mainfrom
claude/farm/82629685/zlib-worker-terminate-uaf

Conversation

@robobun

@robobun robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Problem

worker.terminate() while an async node:zlib operation (gzip / brotliCompress / deflate / zstd) is running on the thread pool crashes with a cross-thread heap-use-after-free:

heap-use-after-free  READ of size 8  thread T16 (Bun Pool 2)
  #0 VirtualMachine::event_loop                 src/jsc/VirtualMachine.rs:716
  #1 CompressionStream<NativeBrotli>::async_job_run
                                                src/runtime/node/node_zlib_binding.rs:492
freed by thread (Worker): WebWorker::shutdown   src/jsc/web_worker.rs:1390

The pool-thread completion callback walks global_this -> bun_vm_concurrently() -> event_loop() to post its result after WebWorker::shutdown has already raw-dealloc'd the VirtualMachine box (the EventLoop is a value field of it). do_work() itself also reads/writes the pinned JSC-heap input/output buffers that teardownJSCVM frees. Under a release build this is a plain SIGSEGV.

Worker process.exit() and an uncaught throw take the same shutdown path and hit the same race.

Fix

Add a small shutdown barrier on EventLoop:

  • work_pool_pending: AtomicU32 counts WorkPool jobs scheduled from this VM's JS thread whose pool-thread callback has not yet made its last EventLoop/VM access.
  • CompressionStream::write() calls work_pool_task_ref() immediately before WorkPool::schedule; async_job_run calls work_pool_task_unref() (Release) as its final VM access, after enqueue_task_concurrent.
  • WebWorker::shutdown spins on work_pool_pending == 0 (Acquire) after stopping the cross-thread CppTask posters and before release_queued_tasks_for_shutdown / teardownJSCVM / the VM dealloc. The Release/Acquire pair guarantees the VM box and JSC heap are live for the pool thread's whole callback, and the completion each job posts is then reclaimed by the existing drain.

Each pending job is one bounded compression step, so terminate() latency is bounded by the slowest in-flight chunk (the same model Node's uv_run drain gives).

Scope

This is a targeted fix for the node:zlib site only. #34154 is the general ShutdownGate that covers every WorkPool/HTTP-thread producer (fetch, S3, node:fs async, dns, napi, crypto, bundler, ...); the counter here is the minimal subset of that design and will be subsumed when it lands. #32073 tracks the generation-token follow-up.

Test

test/js/node/zlib/zlib-worker-terminate.test.ts spawns a worker that keeps 5 lanes of gzip/brotli/deflate in flight and terminates it mid-compression, 4 rounds under ASAN / 10 otherwise.

Fail-before / pass-after
# without src/ changes (bun bd, ASAN)
error: expect(received).not.toContain(expected)
Expected to not contain: "heap-use-after-free"
  #1 async_job_run<NativeBrotli> src/runtime/node/node_zlib_binding.rs:492
  freed by WebWorker::shutdown   src/jsc/web_worker.rs:1390

# with src/ changes
(pass) worker.terminate() during in-flight node:zlib async compression does not UAF [11039ms]

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

fails on main (without fix)
ASAN without fix: 1 FAILED
$ 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-worker-terminate.test.ts
bun test v1.4.0 (12f4d3972)

test/js/node/zlib/zlib-worker-terminate.test.ts:
52 |     stdout: "pipe",
53 |     stderr: "pipe",
54 |   });
55 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
56 | 
57 |   expect(stderr).not.toContain("heap-use-after-free");
                          ^
error: expect(received).not.toContain(expected)

Expected to not contain: "heap-use-after-free"
Received: "=================================================================\n==126269==ERROR: AddressSanitizer: heap-use-after-free on address 0x74afe687b4b8 at pc 0x00000c72d56d bp 0x71df499ee9f0 sp 0x71df499ee9e8\nREAD of size 8 at 0x74afe687b4b8 thread T7 (Bun Pool 2)\n    #0 0x00000c72d56c in <bun_jsc::virtual_machine::VirtualMachine>::event_loop /workspace/bun/src/jsc/VirtualMachine.rs:716:9\n    #1 0x00000c72d56c in <bun_runtime::node::node_zlib_binding::CompressionStream<bun_runtime::node::native_brotli_impl::_impl::NativeBrotli>>::async_job_ru
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (a157772e8)

test/js/node/zlib/zlib-worker-terminate.test.ts:
(pass) worker.terminate() during in-flight node:zlib async compression does not UAF [1082.89ms]

 1 pass
 0 fail
 3 expect() calls
Ran 1 test across 1 file. [1266.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ 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-worker-terminate.test.ts
bun test v1.4.0 (12f4d3972)

test/js/node/zlib/zlib-worker-terminate.test.ts:
(pass) worker.terminate() during in-flight node:zlib async compression does not UAF [11080.79ms]

 1 pass
 0 fail
 3 expect() calls
Ran 1 test across 1 file. [13.20s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 754ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 237 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_output v
... (truncated)
diff hotspot
src/jsc/event_loop.rs                           | 44 ++++++++++++++++-
 src/jsc/web_worker.rs                           |  8 ++++
 src/runtime/dispatch.rs                         | 26 ++++++++++
 src/runtime/node/node_zlib_binding.rs           | 52 +++++++++++++++++++-
 test/js/node/zlib/zlib-worker-terminate.test.ts | 63 +++++++++++++++++++++++++
 5 files changed, 191 insertions(+), 2 deletions(-)

gate history · 2 passed · 0 rejected · iteration 0

evidence per changed file
file                                             reads  edits  tests
src/jsc/event_loop.rs                                5      4      0
src/jsc/web_worker.rs                                3      3      0
src/runtime/dispatch.rs                              3      1      0
src/runtime/node/node_zlib_binding.rs                5      3      0
test/js/node/zlib/zlib-worker-terminate.test.ts      2      8      0

worker.terminate() while an async zlib/brotli/zstd write is running on
the thread pool crashed with a cross-thread heap-use-after-free: the
pool-thread completion (CompressionStream::async_job_run) dereferenced
global_this -> bun_vm_concurrently() -> event_loop() after
WebWorker::shutdown had already dealloc'd the VirtualMachine box that
the EventLoop is a field of. do_work() itself was also reading/writing
JSC-heap-backed input/output buffers that teardownJSCVM freed.

Fix: add an EventLoop::work_pool_pending counter. zlib's write()
brackets the WorkPool hop with work_pool_task_ref()/unref() (unref is
the pool thread's last VM access, Release-ordered). WebWorker::shutdown
spins on the counter reaching zero (Acquire) after stopping cross-thread
CppTask posters and before release_queued_tasks_for_shutdown /
teardownJSCVM / VM dealloc, so the JSC heap and VM box are live for the
pool thread's whole callback.

This is a targeted fix for the zlib site; #34154 is the general
ShutdownGate that covers every WorkPool/HTTP producer.
@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:03 PM PT - Jul 22nd, 2026

@robobun, your commit 12f4d39 has 1 failures in Build #77985 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35155

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

bun-35155 --bun

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

WorkPool shutdown synchronization

Layer / File(s) Summary
EventLoop pending-work barrier
src/jsc/event_loop.rs
Adds an atomic pending-work counter and methods to reference, release, and wait for WorkPool tasks.
Zlib scheduling and worker teardown
src/runtime/node/node_zlib_binding.rs, src/jsc/web_worker.rs
Compression work holds the shutdown barrier until completion dispatch, while worker shutdown waits before reclaiming queued tasks.
Worker termination regression coverage
test/js/node/zlib/zlib-worker-terminate.test.ts
Tests repeated worker termination during concurrent zlib compression and checks subprocess output for heap-use-after-free and sanitizer failures.

Possibly related PRs

  • oven-sh/bun#34154: Adjusts worker shutdown ordering during cross-thread work.
  • oven-sh/bun#34671: Changes CompressionStream rooting during asynchronous completion.
  • oven-sh/bun#34751: Updates the CompressionStream threadpool workflow before work is queued.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly summarizes the zlib worker-shutdown UAF fix and matches the main change.
Description check ✅ Passed The description covers the problem, fix, scope, and verification, which satisfies the template content.

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 `@test/js/node/zlib/zlib-worker-terminate.test.ts`:
- Around line 4-16: Remove the entire regression narrative comment above the
test, including the heap-use-after-free explanation, stack traces, and
implementation-specific details. Leave the test code unchanged and do not add a
replacement comment unless a confirmed issue URL is available.
- Around line 47-49: Update the worker lifecycle in the test around the message
wait and w.terminate() so termination happens immediately when the "up" message
arrives, removing the randomized sleep. Make the readiness promise reject on
worker error or an exit occurring before "up", while preserving successful
resolution once "up" is received.
🪄 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: 6118a14a-0f6d-4470-aaef-84dd41b98edd

📥 Commits

Reviewing files that changed from the base of the PR and between 47597ab and dd2e822.

📒 Files selected for processing (4)
  • src/jsc/event_loop.rs
  • src/jsc/web_worker.rs
  • src/runtime/node/node_zlib_binding.rs
  • test/js/node/zlib/zlib-worker-terminate.test.ts

Comment thread test/js/node/zlib/zlib-worker-terminate.test.ts Outdated
Comment thread test/js/node/zlib/zlib-worker-terminate.test.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix use-after-free when worker.terminate() races in-flight fetch/work-pool completions #34154 - Superset fix that covers the same zlib async compression UAF on worker terminate (modifies the same files: event_loop.rs, web_worker.rs, node_zlib_binding.rs) plus all other WorkPool/HTTP-thread producers via ShutdownGate

🤖 Generated with Claude Code

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/js/node/zlib/zlib-worker-terminate.test.ts (1)

33-40: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not swallow all compression failures.

Line 35 retries forever after every failure, while Line 40 reports "up" unconditionally. A broken or unsupported zlib path can therefore still produce stdout: "ok" without exercising successful asynchronous compression. Propagate the first error or make readiness depend on a successful initial operation.

As per coding guidelines: tests must prove they fail for the intended reason, and failures must not be swallowed.

Suggested failure propagation
-        (async () => { for (;;) { try { await f(); } catch {} } })();
+        (async () => {
+          for (;;) await f();
+        })().catch(error => {
+          console.error(error);
+          process.exit(1);
+        });
🤖 Prompt for 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.

In `@test/js/node/zlib/zlib-worker-terminate.test.ts` around lines 33 - 40, Update
the lanes helper and readiness signaling around gz, br, and df so compression
errors are not silently swallowed: propagate the first failure or require each
lane to complete an initial successful compression before posting "up". Ensure
parentPort.postMessage("up") only occurs after asynchronous compression
succeeds, while preserving the ongoing retry behavior only for subsequent
operations if needed.

Source: Coding guidelines

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

Outside diff comments:
In `@test/js/node/zlib/zlib-worker-terminate.test.ts`:
- Around line 33-40: Update the lanes helper and readiness signaling around gz,
br, and df so compression errors are not silently swallowed: propagate the first
failure or require each lane to complete an initial successful compression
before posting "up". Ensure parentPort.postMessage("up") only occurs after
asynchronous compression succeeds, while preserving the ongoing retry behavior
only for subsequent operations if needed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: dc7f47fa-91b7-4a9c-ad63-4634d98f710d

📥 Commits

Reviewing files that changed from the base of the PR and between dd2e822 and 392105e.

📒 Files selected for processing (1)
  • test/js/node/zlib/zlib-worker-terminate.test.ts

The sleep was not load-bearing: 5 in-flight compressions are already on
the pool by the time 'up' arrives (verified 3/3 UAF on unfixed ASAN
build with no sleep).
@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed in bb6a570:

  • Header comment: trimmed to a short note on what's being exercised; the stack trace lives in the PR body.
  • Randomized sleep: dropped. Verified the no-sleep variant still hits the UAF 3/3 under ASAN on the unfixed build (five pool jobs are already in flight by the time "up" is delivered, so terminate() lands mid-do_work() without any delay). The readiness wait now rejects on worker error/early exit.
  • Swallowed compression errors in lanes(): leaving the try { await f(); } catch {} as-is. The lanes exist to keep work queued on the pool until terminate() interrupts the worker's JS thread, at which point the in-flight promises reject and the loop is discarded. Propagating the rejection would race terminate() against the worker's own exit-on-error and change what the test covers. Correctness of the zlib codecs themselves is covered by test/js/node/zlib/zlib.test.js.

On the duplicate-PR bot: #34154 is the general ShutdownGate that subsumes this. That PR is currently conflicting and under rework; this one is the minimal barrier for the node:zlib site so the SIGSEGV stops shipping in the meantime.

Comment thread src/jsc/web_worker.rs
The barrier guarantees every in-flight async write posts its completion
before release_queued_tasks_for_shutdown runs, but
__bun_release_task_at_shutdown had no NativeZlib/Brotli/Zstd arm, so the
payload was re-queued and the CompressionStream box (with its Strong
this_value, ref'd poll_ref, pinned buffers, and write()'s +1) leaked
when the worker VM box was raw-dealloc'd.

Add CompressionStream::release_unrun (the resource-release subset of
run_from_js_thread, no JS callbacks) and wire it into the shutdown
drain for all three tags. LSan with detect_leaks=1 now shows no
CompressionStream/Native* allocations in the repro.

Also tighten the test's sanitizer check to 'ERROR: AddressSanitizer' so
unrelated pre-existing LSan summaries (WebWorker box, fs Binding) can't
trip it.
Comment thread test/js/node/zlib/zlib-worker-terminate.test.ts
Comment thread test/js/node/zlib/zlib-worker-terminate.test.ts
@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: zlib-worker-terminate.test.ts is green on all lanes including debian x64-asan across builds 77973 and 77985. Remaining red is unrelated to this diff:

  • test/js/node/test/parallel/test-net-connect-memleak.js (ubuntu 25.04 x64): GC-timing assertion, fails the same way on both builds; this diff does not touch net/socket/GC. Reported for main-break triage.
  • test/cli/install/migration/complex-workspace.test.ts, test/cli/install/bun-security-scanner-matrix-with-node-modules.test.ts: marked flaky, install-path only.

Diff is ready for review.

@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

Verified this barrier also closes the sibling face where Heap::lastChanceToFinalize frees a readFileSync-backed input ArrayBuffer's contents while do_work() is still memcpy'ing from it on a pool thread (brotli's CopyInputToRingBuffer, 256 KiB READ). The wait_for_pending_work_pool_tasks() call sits before teardownJSCVM, so VM::~VM cannot run until the pool callback (do_work + enqueue) has returned.

Repro: a worker that readFileSyncs a 6 MiB file and keeps brotli/gzip lanes in flight, terminated mid-compression, 3 workers × 30 rounds. On main 52af8327 that UAFs in round 1 under ASAN; with this diff applied it runs 3×30 rounds clean. The diff applies without conflict on current main.

The existing test's Buffer.alloc input is Gigacage-backed, so ASAN only surfaces the VM-deref face there; a readFileSync-sourced buffer (mimalloc-backed) makes the ArrayBuffer-contents face visible too. Happy to add that lane to the test if wanted.

@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #36983, which generalizes this shutdown fence to every off-thread job family (the counter lives on EventLoop as outstanding_offthread) and carries this PR's zlib bracketing, release_unrun arms, and test verbatim. Closing in favor of that PR.

@robobun robobun closed this Aug 5, 2026
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