Skip to content

napi: block worker shutdown on in-flight napi_async_work (UAF) - #36855

Closed
robobun wants to merge 6 commits into
mainfrom
claude/farm/ca24401f/napi-async-work-worker-terminate
Closed

napi: block worker shutdown on in-flight napi_async_work (UAF)#36855
robobun wants to merge 6 commits into
mainfrom
claude/farm/ca24401f/napi-async-work-worker-terminate

Conversation

@robobun

@robobun robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Problem

worker.terminate() with a napi_async_work still running its execute callback on the thread pool frees the worker's VirtualMachine (and its JSC heap) out from under it:

  • The pool-thread completion posts into a freed EventLoop via enqueue_task_concurrent (heap-use-after-free / SIGSEGV on stock).
  • The addon's execute is still writing an ArrayBuffer backing store that teardownJSCVM's lastChanceToFinalize just freed.
heap-use-after-free READ of size 8 thread (Bun Pool)
  #0 EventLoop::vm_ref                  src/jsc/event_loop.rs
  #1 EventLoop::enqueue_task_concurrent
  #2 napi_async_work::run               src/runtime/napi/napi_body.rs
freed by thread (Worker): WebWorker::shutdown  src/jsc/web_worker.rs

process.exit() inside the worker takes the same shutdown path. A natural worker exit is clean (the KeepAlive keeps the loop alive until complete runs).

Fix

Add a small work_pool_pending shutdown barrier on EventLoop:

  • work_pool_task_ref() (JS thread, before WorkPool::schedule) / work_pool_task_unref() (pool thread, after enqueue_task_concurrent, Release) bracket the pool-thread callback's VM accesses. napi_async_work::schedule() takes the ref; run() drops it on both the cancelled and completed paths via a local copy of the BackRef taken before the enqueue (so the trailing unref does not touch self after the JS thread may have already freed it). The fetch_sub(Release) is the pool thread's last access to self.
  • WebWorker::shutdown blocks on the count reaching zero (timed Futex::wait, 1 ms re-check; the pool thread cannot Futex::wake because that would touch self after the Release) before teardownJSCVM / VM dealloc. The Release/Acquire pair keeps the EventLoop, VM box, and JSC heap live for the whole pool-thread callback.
  • The shutdown drain (__bun_release_task_at_shutdown) gains a NapiAsyncWork arm that unrefs the loop KeepAlive and frees the napi_async_work box while JSC is still live, so the Rust side is fully reclaimed.

Each pending work is one addon execute step, so terminate() latency is bounded by the slowest in-flight work (same model as Node's env-close uv_run drain).

Not in this PR

The addon's complete callback is not invoked on the terminate path; the drain arm frees the Rust-side napi_async_work box but leaves the addon's data pointer (whatever it hung off napi_create_async_work) for the process to reclaim. Running complete() on the shutdown drain was tried here and backed out: it lands after vm.on_exit() has already run NapiEnv::cleanup() (instance-data / wrap finalizers), so the addon may observe freed per-env state, and complete() may legally call napi_queue_async_work which would re-arm the pool after the barrier returned. Placing it correctly (before NapiEnv::cleanup(), as a fixpoint) is a follow-up alongside the general shutdown-gate work (#34154).

Test

test/napi/napi.test.ts ("worker.terminate() with execute callbacks in flight ...") spawns a subprocess that repeatedly creates a worker, queues four napi_async_works that sleep 300-450 ms and memset a 16 MiB ArrayBuffer on the pool thread, and terminates the worker while they are in flight. The addon (test/napi/napi-app/test_async_work_worker_terminate.c) uses only public node-api. The subprocess runs with detect_leaks=0 since the addon-side per-work calloc is intentionally leaked on the terminate path.

Fail-before / pass-after
# bun bd (ASAN) without src/ changes
error: expect(received).toEqual(expected)
+ ==109596==ERROR: AddressSanitizer: heap-use-after-free on address 0x72ac2d26a628
+ READ of size 8 thread (Bun Pool 0)
+   EventLoop::vm_ref                 src/jsc/event_loop.rs:1028
+   EventLoop::enqueue_task_concurrent
+   napi_async_work::run              src/runtime/napi/napi_body.rs
+ freed by thread (Worker): WebWorker::shutdown  src/jsc/web_worker.rs
(fail) napi > napi_async_work > worker.terminate() with execute callbacks in flight waits for them and does not UAF

# bun bd (ASAN) with src/ changes
(pass) napi > napi_async_work > worker.terminate() with execute callbacks in flight waits for them and does not UAF

rust:check-all: 10/10 target combos OK.

Relationship to open PRs

Same barrier primitive as #35155 (node:zlib) applied to the napi producer; when either lands the other becomes a one-line ref/unref addition. #34154 / #35767 are the general designs; this is the minimal subset for napi.

worker.terminate() with napi_async_work execute callbacks still running on
the thread pool freed the worker's VirtualMachine (and its JSC heap) out
from under them. The pool-thread completion then posts into a freed
EventLoop via enqueue_task_concurrent, and the addon's execute callback
writes a freed ArrayBuffer backing store.

  heap-use-after-free READ 8 thread (Bun Pool)
    EventLoop::vm_ref                 src/jsc/event_loop.rs
    EventLoop::enqueue_task_concurrent
    napi_async_work::run              src/runtime/napi/napi_body.rs
  freed by thread (Worker): WebWorker::shutdown

Add a small work_pool_pending shutdown barrier on EventLoop:
napi_async_work::schedule() refs it before WorkPool::schedule, run()
unrefs after enqueue_task_concurrent, and WebWorker::shutdown spins on it
reaching zero before teardownJSCVM / VM dealloc. The shutdown drain then
runs complete() for each joined work so the addon can free its per-work
native state, matching Node.js.
@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:05 PM PT - Aug 3rd, 2026

@autofix-ci[bot], your commit 40d869d has 1 failures in Build #88406 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36855

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

bun-36855 --bun

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The change adds an EventLoop barrier for pending WorkPool callbacks, drains queued N-API async-work completions during WebWorker shutdown, and adds regression coverage for terminating workers while native async work is active.

N-API async-work shutdown

Layer / File(s) Summary
WorkPool lifetime barrier
src/jsc/event_loop.rs, src/runtime/napi/napi_body.rs
EventLoop tracks pending WorkPool callbacks. NapiAsyncWork updates the barrier during scheduling, cancellation, and completion.
Worker shutdown drain
src/jsc/web_worker.rs, src/runtime/dispatch.rs
Worker shutdown waits for pending WorkPool jobs, then runs queued N-API completion callbacks before task reclamation.
Worker termination regression coverage
test/napi/napi-app/binding.gyp, test/napi/napi-app/test_async_work_worker_terminate.c, test/napi/napi.test.ts
Adds a native async-work addon and tests repeated worker termination during active jobs.

Possibly related PRs

  • oven-sh/bun#36817 — Addresses worker termination races involving WorkPool tasks.
  • oven-sh/bun#36831 — Modifies related N-API shutdown and dispatch lifecycle handling.
  • oven-sh/bun#36575 — Hardens worker shutdown by waiting for in-flight cross-thread tasks.

Suggested reviewers: dylan-conway

🚥 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 identifies the primary change: preventing worker shutdown from freeing resources while in-flight napi_async_work runs.
Description check ✅ Passed The description explains the problem, fix, scope, limitations, tests, and verification results, although it does not use the exact template headings.

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: 3

Caution

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

⚠️ Outside diff range comments (1)
src/runtime/napi/napi_body.rs (1)

1830-1837: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Post-publish self deref in both run() exit paths. enqueue_task_concurrent hands this work item to the JS thread, which can run complete and free the allocation through napi_delete_async_worknapi_async_work::destroy. Both new work_pool_task_unref() calls then read the event_loop field of that allocation. Copy the event_loop handle (it is Copy) into a local before the enqueue and unref through the local.

  • src/runtime/napi/napi_body.rs#L1830-L1837: bind let event_loop = self.event_loop; before the normal-completion enqueue and call event_loop.work_pool_task_unref().
  • src/runtime/napi/napi_body.rs#L1813-L1820: bind the same local before the cancelled-path enqueue and call event_loop.work_pool_task_unref().
🤖 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 `@src/runtime/napi/napi_body.rs` around lines 1830 - 1837, Avoid dereferencing
self after enqueueing the work item in both run() exit paths. In
src/runtime/napi/napi_body.rs lines 1830-1837 and 1813-1820, copy
self.event_loop into a local before each enqueue_task_concurrent call, then
invoke work_pool_task_unref() through that local in both paths.
🤖 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/jsc/event_loop.rs`:
- Around line 1023-1032: Replace the busy-wait in
`wait_for_pending_work_pool_tasks` with an existing blocking synchronization
primitive such as `Futex`, `Condition`, or `WaitGroup`, waking waiters when
`work_pool_pending` reaches zero and preserving correct task ref/unref
synchronization. Update the method’s doc comment to remove the claim that
pending work is bounded.

In `@src/runtime/dispatch.rs`:
- Around line 1334-1346: Update global_exit() to wait for the current VM’s
work_pool_pending barrier before calling release_queued_tasks_for_shutdown().
Ensure this ordering prevents pending work-pool callbacks from retaining
NapiAsyncWork while shutdown drains and reclaims queued tasks.

In `@test/napi/napi.test.ts`:
- Around line 553-561: Update the worker promise in the test loop around the
"message" and "exit" listeners so it resolves only after receiving the expected
message and rejects if the worker exits first. Wire worker "error" events to
rejection as well, ensuring addon-load or queueWork failures cannot resolve the
test early; preserve the existing error propagation and termination flow after
successful message receipt.

---

Outside diff comments:
In `@src/runtime/napi/napi_body.rs`:
- Around line 1830-1837: Avoid dereferencing self after enqueueing the work item
in both run() exit paths. In src/runtime/napi/napi_body.rs lines 1830-1837 and
1813-1820, copy self.event_loop into a local before each enqueue_task_concurrent
call, then invoke work_pool_task_unref() through that local in both paths.
🪄 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: a9b36ecb-d4ce-41d2-b062-5d6b4284e14b

📥 Commits

Reviewing files that changed from the base of the PR and between c126633 and 5a9a6cf.

📒 Files selected for processing (7)
  • src/jsc/event_loop.rs
  • src/jsc/web_worker.rs
  • src/runtime/dispatch.rs
  • src/runtime/napi/napi_body.rs
  • test/napi/napi-app/binding.gyp
  • test/napi/napi-app/test_async_work_worker_terminate.c
  • test/napi/napi.test.ts

Comment thread src/jsc/event_loop.rs Outdated
Comment thread src/runtime/dispatch.rs Outdated
Comment thread test/napi/napi.test.ts Outdated
- napi_async_work::run(): copy the event_loop BackRef to a local before
  enqueue_task_concurrent so the trailing work_pool_task_unref() cannot
  touch self after the JS thread has already run complete() and freed it.
- wait_for_pending_work_pool_tasks(): block on Futex instead of a
  yield_now spin; work_pool_task_unref() wakes on the 1->0 transition.
  Drop the doc claim that pending work is bounded (a napi execute
  callback is arbitrary addon code).
- test: resolve only on the worker's "up" message and reject on early
  error/exit so an addon-load failure cannot let the loop pass silently.
Comment thread src/jsc/event_loop.rs
Comment thread src/jsc/event_loop.rs
Comment thread src/jsc/event_loop.rs Outdated
Comment thread src/jsc/event_loop.rs
Comment thread src/jsc/web_worker.rs Outdated
Comment thread src/runtime/dispatch.rs Outdated
Comment thread src/runtime/napi/napi_body.rs
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
@github-actions

github-actions Bot commented Aug 3, 2026

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 - Fixes the same worker.terminate()-vs-in-flight-work-pool UAF in the same code region, adding a vm_pin: Option<GateGuest> to napi_async_work in napi_body.rs plus a close_and_wait() barrier at the same line in WebWorker::shutdown.
  2. node:zlib: block worker shutdown on in-flight async compression (UAF) #35155 - Introduces the identical shutdown-barrier primitive (work_pool_pending + work_pool_task_ref/unref/wait_for_pending_work_pool_tasks) in the same three files, differing only in the producer (node:zlib instead of napi_async_work).
  3. Bun.password: fix UAF when worker.terminate() lands mid argon2/bcrypt job #35156 - Competing implementation of the same mechanism: a bun_threading::ShutdownGate on VirtualMachine whose close_and_wait() is called from WebWorker::shutdown at the same line to block teardown on in-flight work-pool completions.
  4. worker: fence async crypto (AnyTaskJob) completions against terminate() freeing the VM #35154 - Competing implementation of the same mechanism: an AnyTaskGate close-and-wait fence closed from WebWorker::shutdown at the same line to protect in-flight work-pool jobs from the freed worker VM.
  5. worker: fence in-flight AnyTaskJob work against terminate() freeing the VM #36817 - Adds the same AnyTaskGate close-and-wait fence in the same files and at the same WebWorker::shutdown line, a third competing version of this shutdown barrier.

🤖 Generated with Claude Code

Comment thread src/runtime/dispatch.rs Outdated
Comment thread src/jsc/web_worker.rs Outdated
Comment thread src/runtime/napi/napi_body.rs Outdated
Running the addon's complete() from the shutdown drain landed after
NapiEnv::cleanup() (vm.on_exit() drains cleanup_hooks first), so the
addon could observe freed instance data; and complete() can legally call
napi_queue_async_work, which would re-schedule onto the WorkPool after
the barrier returned and reopen the UAF. Both are paths this PR opened.

Scope this back to the barrier alone: wait_for_pending_work_pool_tasks()
before teardownJSCVM / VM dealloc keeps the EventLoop and JSC heap live
across every in-flight execute+enqueue, closing the enqueue UAF and the
ArrayBuffer-freed-under-execute write. The completion itself is left in
the queue (re-queued by the existing default arm) and leaked at
terminate, which is the pre-PR behavior. Wiring complete() into the
env-close ordering (before NapiEnv::cleanup(), as a fixpoint) is a
follow-up.

Also trims the inline comments flagged by comment-cop.
Comment thread src/jsc/web_worker.rs
Comment thread src/jsc/event_loop.rs
Comment thread test/napi/napi-app/test_async_work_worker_terminate.c Outdated
A Futex::wake after the Release fetch_sub touched &self.work_pool_pending
after the waiter may have observed zero and freed the VirtualMachine box
this EventLoop lives in. The futex syscalls use the address as a kernel
key only, so it was not observable at runtime, but it is a dangling
reference per the Futex::wake contract.

Instead the waiter futex-waits with a 1ms timeout and re-checks; the pool
thread's last access to self is the fetch_sub itself.

Also drop the unused napi_env field from the test addon.
Comment thread src/jsc/event_loop.rs
Comment thread src/jsc/event_loop.rs
Comment thread test/napi/napi.test.ts Outdated
With the barrier letting the completion land, the work box was being
re-queued into EventLoop::deinit()'s fresh tasks buffer, which the raw
dealloc of the worker VM box never frees. Add a NapiAsyncWork arm that
unrefs the loop KeepAlive and destroys the box (no complete() call), so
the Rust side is reclaimed while JSC is still live.

The addon's per-work data pointer still leaks (complete() is not invoked
on terminate by design); the test subprocess runs with detect_leaks=0 so
the assertion is on the crash, not the bounded leak.

Also drop the stale test comment about running complete().
Comment thread src/runtime/napi/napi_body.rs
@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

CI at 40d869d: test/napi/napi.test.ts (including the new worker-terminate test) passes on every lane, including debian x64-asan where the earlier LSan leak fired.

Remaining reds are unrelated to this diff:

  • test/cli/install/bun-install-registry.test.ts "hoisting > peers > it should hoist 1.0.1 when peer *" on windows aarch64 (package-install hoisting, no overlap with event_loop/worker/napi; also flaky on ubuntu aarch64 in the same build; reported for main-break triage)
  • 8 other lanes are [flaky] (passed on retry or passed alone)

Diff is ready for review.

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

No new issues found on this pass, but this is concurrency-sensitive worker-shutdown / napi lifetime code that has already iterated through several design changes in review — worth a human sign-off on the barrier shape and the new release_for_shutdown path.

What was reviewed:

  • work_pool_task_unref now ends at the fetch_sub(Release) with no post-release self access; the timed Futex::wait waiter avoids the freed-futex-word hazard.
  • napi_async_work::run copies event_loop before enqueue_task_concurrent on both the started and cancelled branches, so no *self read follows the ownership transfer.
  • The new NapiAsyncWork arm in __bun_release_task_at_shutdown only sees entries the pool thread already posted; on the worker path the barrier precedes it, and on global_exit presence in the queue implies the pool callback finished.
Extended reasoning...

Overview

This PR fixes a heap-use-after-free when worker.terminate() races an in-flight napi_async_work execute callback. It adds a new work_pool_pending: AtomicU32 counter on EventLoop with work_pool_task_ref/unref/wait_for_pending_work_pool_tasks, wires it into napi_async_work::schedule/run, blocks WebWorker::shutdown on it before JSC teardown, and adds a NapiAsyncWork arm to __bun_release_task_at_shutdown (release_for_shutdown: unref KeepAlive + free the box, without calling the addon's complete). A new C test addon and subprocess test cover the crash.

Security risks

None in the traditional sense. The change is a memory-safety hardening in native code; the risk surface is introducing new lifetime bugs via the new synchronization primitive or the new free-at-shutdown path, not exposing user-controlled data.

Level of scrutiny

High. This is exactly the "most-blocked category" per REVIEW.md: cross-thread lifetime, Release/Acquire ordering justification, a new field on EventLoop whose liveness contract spans the pool thread and the worker's dealloc(vm_ptr), and a change to the very delicate WebWorker::shutdown step ordering. The PR already went through three rounds of bug-hunting that found real hazards (shutdown-drain complete() re-arming the pool past the barrier; post-enqueue self.event_loop read; post-fetch_sub Futex::wake on a freeable word), each of which reshaped the design. That churn is itself a signal that a maintainer should confirm the final shape.

Other factors

  • Design decisions a human should own: (1) the barrier primitive is explicitly intended to be shared with #35155 (node:zlib) and sits alongside the general #34154 design — a maintainer should confirm this is the shape they want to standardize on. (2) release_for_shutdown changes behavior from "re-queue and leak with the static-rooted VM box" to "free the napi_async_work box, leak only the addon's data"; the PR body's "Not in this PR" section is slightly stale on this point. (3) complete() is deliberately not run on the terminate path (backed out mid-review); the follow-up scope is documented but should be acknowledged.
  • Barrier placement: it sits after vm.on_exit() / NapiEnv::cleanup(). That is fine for the barrier itself (the addon execute is documented no-node-api), but it constrains where a future complete() drain can go — already noted in the PR body.
  • Test: solid fail-before/pass-after ASAN repro; sets detect_leaks=0 because the addon's per-work calloc is intentionally leaked on the terminate path. Uses fixed 300–450 ms sleeps to open the race window (not a flakiness concern here since the assertion is "did not crash", but worth noting the ~2–3 s per iteration × 3–5 iterations).
  • All prior review threads are resolved; no outstanding human reviewer comments.

@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

#36983 generalizes this PR's counter into EventLoop.outstanding_offthread, covering every off-thread job family, and includes the napi_async_work schedule/run bracketing and the NapiAsyncWork shutdown release arm. The addon-based test here is still the only direct napi coverage (the generalized PR's matrix sticks to JS-drivable families so its fail-before stays deterministic in environments that cannot build addons), so this PR is worth keeping for the test: once #36983 lands it can rebase down to the test and binding.gyp entry.

@alii

alii commented Aug 12, 2026

Copy link
Copy Markdown
Member

superseded by #37075, napi async work now goes through the per-vm handle that teardown closes. if the addon test here still fails before that commit feel free to resend it on its own

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Confirmed: with only the test applied, it still fails 3/3 at 52bf09c (the parent of the #37075 merge) with the same heap-use-after-free in EventLoop::vm_ref from the pool thread, and passes 3/3 on current main. Resent as a test-only PR: #37918.

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.

3 participants