Skip to content

node:fs: keep ArrayBuffer storage alive across worker.terminate() during async write - #36818

Open
robobun wants to merge 7 commits into
mainfrom
claude/c51f3b14/fs-write-worker-terminate-buffer-lifetime
Open

node:fs: keep ArrayBuffer storage alive across worker.terminate() during async write#36818
robobun wants to merge 7 commits into
mainfrom
claude/c51f3b14/fs-write-worker-terminate-buffer-lifetime

Conversation

@robobun

@robobun robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Problem

worker.terminate() while an async node:fs write of a user Buffer (fs.write / writev / writeFile / fs.promises.writeFile / FileHandle.write / createWriteStream) is still inside write(2) on a pool thread:

VM::~VM -> Heap::lastChanceToFinalize -> ~ArrayBufferContents

frees the backing store while the kernel is still copying from it. The fd then receives whatever those pages now hold instead of the bytes the writer actually held. ASAN cannot flag this (the reader is the kernel), so the test below uses a content oracle.

Cause

The async path's buffer borrow (JSC__JSValue__pinArrayBuffer / borrowBytesForOffThread / Bun__JSArray__collectBufferSpans) relies on ArrayBuffer::pin() plus JSValue::protect(). pin() only blocks transfer(), and protect() is a GC root; Heap::lastChanceToFinalize honours neither. It runs GCIncomingRefCountedSet::lastChanceToFinalize, which drops the heap's deferred flag on every ArrayBuffer and deletes at refcount 0.

Fix

Take a native RefCounted +1 on the JSC::ArrayBuffer alongside the pin, and release it in unpinArrayBuffer. The ref survives lastChanceToFinalize (setIsDeferred(false) deletes only when the count is zero), so the pool thread's write(2) reads valid bytes for as long as the async task holds the pin. Both ref and deref run on the JS thread (DeferrableRefCounted is not atomic).

This covers the node:fs Write / WriteFile / AppendFile / Read args and the writev/readv iovec collector through their existing pin/unpin balance, and the other off-thread consumers of the same helpers (zlib, Bun.Image, shell stdio, MySQL blob binds, NodeHTTPResponse chunked writes) at no extra cost. Blocking worker shutdown on the pool job (the node:zlib approach in #35155) is not appropriate here: a write parked on a full pipe would make terminate() wait as long as the peer stalls.

The pool-thread completion-into-dead-VM crash that follows once the write drains is the separate #34154 work; this change only addresses the buffer lifetime, so terminate() latency is unchanged.

Test

test/js/node/fs/fs-write-worker-terminate.test.ts (POSIX, ASAN-gated): a worker fills an 8 MiB buffer with one known byte and streams it to a FIFO via each of fs.write / fs.writev / fs.writeFile; the parent terminates the worker mid-write, drains, and flags any byte the writer never held. Malloc=1 routes the ArrayBuffer backing through system malloc so ASAN free-fills it on release.

Fail-before / pass-after
# without src/ changes (bun bd, ASAN), 3/3 runs each
FOREIGN fifo byte 0x55 run=8192 writer-held=0x42
FOREIGN fifo byte 0x55 run=8192 writer-held=0x42
FAIL foreign-chunks=47
(fail) ... fs.write
(fail) ... fs.writev
(fail) ... fs.writeFile

# with src/ changes, 5/5 runs each
(pass) ... fs.write
(pass) ... fs.writev
(pass) ... fs.writeFile

no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/fs/fs-read-worker-terminate.test.ts test/js/node/fs/fs-write-worker-terminate.test.ts

…ing async write

When worker.terminate() lands while an async node:fs write (fs.write,
fs.writev, fs.writeFile, FileHandle.write, createWriteStream) of a user
Buffer is still inside write(2) on a pool thread, the worker's
VM::~VM -> Heap::lastChanceToFinalize -> ~ArrayBufferContents frees the
backing store while the kernel is still copying from it. The fd then
receives freed-heap bytes (zero pages or another allocation's contents)
instead of the bytes the writer held.

Bun's off-thread buffer borrow (pinArrayBuffer / borrowBytesForOffThread /
collectBufferSpans) previously relied on ArrayBuffer::pin() plus
JSValue::protect(). pin() only blocks transfer() and protect() is a GC
root, neither of which Heap::lastChanceToFinalize honours: it drops the
heap's deferred flag on every ArrayBuffer and deletes at refcount 0.

Take a native RefCounted +1 on the JSC::ArrayBuffer alongside the pin,
and release it in unpinArrayBuffer. The ref survives lastChanceToFinalize
(GCIncomingRefCountedSet::lastChanceToFinalize only clears the deferred
bit; setIsDeferred(false) deletes only when the count is zero), so the
pool thread's write(2) reads valid bytes for as long as the async task
holds the pin. Both ref and deref run on the JS thread
(DeferrableRefCounted is not atomic).

This covers the node:fs Write/WriteFile/Read paths and the fs.writev/
readv iovec collector via their existing pin/unpin balance, and the
other off-thread consumers (zlib, Bun.Image, shell stdio, MySQL blob
binds, NodeHTTPResponse chunked writes) for free.

The pool-thread completion-into-dead-VM crash that follows is the
separate #34154/#2880 work; this change only addresses the buffer
lifetime, so terminate() latency is unchanged.
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The change retains ArrayBuffer backing storage during native and off-thread borrows. New ASAN-gated FIFO tests cover worker termination during asynchronous filesystem reads and writes.

Changes

ArrayBuffer borrow lifetime

Layer / File(s) Summary
Native borrow retention
src/jsc/bindings/bindings.cpp
Pinning, off-thread borrowing, and buffer-span collection retain backing buffers. Unpinning releases the matching reference.
Worker filesystem I/O regressions
test/js/node/fs/fs-read-worker-terminate.test.ts, test/js/node/fs/fs-write-worker-terminate.test.ts
FIFO-backed ASAN tests cover worker termination during blocked fs.read, fs.readv, fs.write, fs.writev, and fs.writeFile operations.

Possibly related PRs

  • oven-sh/bun#36535: Both PRs modify ArrayBuffer lifetime management for native and off-thread borrowed data.
  • oven-sh/bun#36568: Both PRs modify ArrayBuffer pinning and borrow ownership in src/jsc/bindings/bindings.cpp.
  • oven-sh/bun#36819: Both PRs address worker termination and native buffer lifetime races with ASAN regression tests.
🚥 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 node:fs ArrayBuffer lifetime fix during worker termination and async writes.
Description check ✅ Passed The description explains the problem, cause, fix, affected APIs, tests, and verification evidence in sufficient detail.

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

@github-actions github-actions Bot added the claude label Aug 3, 2026
@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for maintainer review. Bot reviews clean; CI green on this diff.

Reproduced with: bun bd test test/js/node/fs/fs-write-worker-terminate.test.ts test/js/node/fs/fs-read-worker-terminate.test.ts on a POSIX ASAN build. Without the src/ change, 9/9 write cases receive FOREIGN fifo byte 0x55 (ASAN free-fill) and 4/4 read cases abort with heap-use-after-free at the ffi probe; with it, 15/15 write + 6/6 read clean. Also verified FileHandle.write/writev, fs.promises.writeFile, createWriteStream, and the process.exit() / uncaught-throw worker-shutdown triggers route through the patched helpers (11/11 extra API×trigger combos clean with the fix, foreign without).

CI (build 88107): both new tests pass on every ASAN lane. Remaining red lanes are unrelated to this diff: worker-transfer-terminate-stress.test.ts SIGABRT on x64-asan is the pre-existing intermittent !exception() in MessagePort serialization documented in that test file, and bun-install-registry.test.ts on Windows is a stale bun 1.3.14 shim SIGILL that also fails on main (build 88038, reported to main-break triage).

Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread src/jsc/bindings/bindings.cpp
Comment thread test/js/node/fs/fs-write-worker-terminate.test.ts Outdated
Comment thread test/js/node/fs/fs-write-worker-terminate.test.ts

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

My earlier concerns (vacuous test assertion, timeout justification) are resolved and I don't see anything further, but this is JSC DeferrableRefCounted lifetime management across VM teardown — worth a maintainer's eyes.

What was reviewed:

  • ref/deref balance at all four pin sites vs the single unpinArrayBuffer release, including the isShared() branch (pin skipped, ref still taken — matches unpin side).
  • collectBufferSpans mid-loop early returns (!view, !buf) leave earlier elements ref'd — same shape as the pre-existing pin(), so not a new leak class.
  • Intentional leak-on-terminate (nobody derefs once the worker VM is gone) is scoped to the #34154 completion path the PR already calls out.
Extended reasoning...

Overview

Adds a native ref() alongside every pin() in the four ArrayBuffer-borrow helpers in bindings.cpp (pinArrayBuffer, two borrowBytesForOffThread branches, collectBufferSpans) and the matching deref() in unpinArrayBuffer. Adds an ASAN-gated POSIX content-oracle test that terminates a worker mid-write(2) to a FIFO and asserts no freed-heap bytes reach the fd.

Security risks

None new. The change strictly extends buffer lifetime; the failure mode it removes (kernel copying from freed pages into a user fd) is itself a data-integrity/potential-info-leak hazard that this closes.

Level of scrutiny

High. This is native memory-lifetime management at the JSC GC boundary, interacting with GCIncomingRefCountedSet::lastChanceToFinalize during worker VM teardown, and it fans out to every off-thread borrow consumer (node:fs read/write/writev, zlib, Bun.Image, shell stdio, MySQL blob binds, NodeHTTPResponse). REVIEW.md flags native memory safety as the most-blocked category. The mechanism (DeferrableRefCounted count > 0 survives setIsDeferred(false)) checks out against the WebKit semantics described, and ~ArrayBufferContents needs no VM state, so a post-teardown deref() (or leak) is safe — but I'd want a maintainer to confirm the intentional leak-until-#34154 tradeoff and that no caller derefs from the pool thread.

Other factors

Both of my prior findings were addressed in 8cc9b96 (unconditional PASS reads=[1-9] assertion, negative-string check dropped) and the 30s timeout was justified with measured wall-clock. Fail-before/pass-after evidence is 9/9 → 9/9 on the revised assertions. The ref/deref pairing is symmetric across shared/non-shared branches. The pre-existing mid-loop-error pin leak in collectBufferSpans now also leaks a ref, but that's the same class, not new.

Read-direction coverage for the same pinArrayBuffer ref fix. The kernel's
copy_to_user into the destination is invisible to ASAN, so the oracle is a
direct address probe: the worker reports ptr(buf) before its fs.read/readv
parks on an empty FIFO, the parent terminates it, and bun:ffi read.u8 at
that address either sees the worker's fill byte (storage alive) or trips
ASAN heap-use-after-free via GCIncomingRefCountedSet::lastChanceToFinalize
(storage freed mid-read). Linux+ASAN only; the FIFO is never written so the
pool thread stays in read(2) and the separate #34154 completion crash is
not reached.
@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

Added test/js/node/fs/fs-read-worker-terminate.test.ts to cover the read direction (fs.read / fs.readv) the PR body already claims.

The kernel's copy_to_user into the destination buffer is invisible to ASAN, so the oracle here is a direct address probe: the worker reports ptr(buf) before its read parks on an empty FIFO, the parent terminates it, and bun:ffi's read.u8 at that address either observes the worker's fill byte (storage alive) or trips ASAN heap-use-after-free via GCIncomingRefCountedSet<ArrayBuffer>::lastChanceToFinalize. The FIFO is never written, so the pool thread stays inside read(2) and the separate #34154 completion path is not reached.

Fail-before / pass-after
# without src/ changes (bun bd, ASAN), 1/1 each
ERROR: AddressSanitizer: heap-use-after-free on address 0x7435151fe800
  freed by thread (Worker): ~ArrayBufferContents <- GCIncomingRefCountedSet::lastChanceToFinalize
                            <- Heap::lastChanceToFinalize <- VM::~VM <- WebWorker::shutdown
(fail) ... fs.read
(fail) ... fs.readv

# with src/ changes, 3/3
(pass) ... fs.read
(pass) ... fs.readv

@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 `@test/js/node/fs/fs-read-worker-terminate.test.ts`:
- Around line 81-85: Capture the resolved process exit value from the concurrent
Promise.all call in the subprocess test, while continuing to drain stdout,
stderr, and proc.exited together. Add an assertion that the captured exitCode
indicates successful termination, alongside the existing output and stage
checks.
🪄 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: c0525d6b-a565-4c9a-96a2-5a39ca51190f

📥 Commits

Reviewing files that changed from the base of the PR and between 8cc9b96 and 0946896.

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

Comment thread test/js/node/fs/fs-read-worker-terminate.test.ts Outdated
Comment thread test/js/node/fs/fs-read-worker-terminate.test.ts Outdated
… assert

Malloc=1 routes every WebKit allocation through system malloc so LSan
flags ~1 KiB of unrelated pre-existing worker-teardown leaks at
process.exit(), tripping the not.toContain('AddressSanitizer') check on
the release-asan lane. The oracle here is the ffi probe (PASS line or
heap-use-after-free abort), not leak accounting; detect_leaks=0 keeps
stderr clean and the unconditional toMatch(/^PASS.../m) carries the
fail-before signal on its own.

@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

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/fs/fs-read-worker-terminate.test.ts (1)

40-42: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Wait until the pool thread enters read(2).

parentPort.postMessage() confirms only that fs.read() or fs.readv() accepted the request. It does not confirm that the pool task started. w.terminate() can therefore run while the request is still queued.

In that case, this test validates a queued borrow, not the lifetime while the kernel holds the destination address. Gate termination on an observable parked-read condition. For this Linux-only fixture, poll the fixture process's /proc/self/task/*/wchan with a bounded deadline until a thread is blocked in pipe read.

As per coding guidelines, tests must await observable conditions and exercise actual production preconditions.

🤖 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/fs/fs-read-worker-terminate.test.ts` around lines 40 - 42,
Update the worker-termination test around the read request and
parentPort.postMessage flow to wait for an observable parked-read state before
calling w.terminate(). For this Linux-only fixture, poll /proc/self/task/*/wchan
with a bounded deadline until a thread is blocked in pipe read, then proceed
with termination; do not treat the request callback or parentPort.postMessage as
evidence that the pool task entered read(2).

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.

Inline comments:
In `@test/js/node/fs/fs-read-worker-terminate.test.ts`:
- Line 85: Remove the explicit 20_000 timeout argument from the test
configuration in fs-read-worker-terminate.test.ts, allowing the repository
runner’s default timeout to apply. Do not add or retain any per-test timeout in
this Bun test.

---

Outside diff comments:
In `@test/js/node/fs/fs-read-worker-terminate.test.ts`:
- Around line 40-42: Update the worker-termination test around the read request
and parentPort.postMessage flow to wait for an observable parked-read state
before calling w.terminate(). For this Linux-only fixture, poll
/proc/self/task/*/wchan with a bounded deadline until a thread is blocked in
pipe read, then proceed with termination; do not treat the request callback or
parentPort.postMessage as evidence that the pool task entered read(2).
🪄 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: 8cd5c220-1a60-45d9-b612-59cf9126fc0d

📥 Commits

Reviewing files that changed from the base of the PR and between 0946896 and ddbe327.

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

Comment thread test/js/node/fs/fs-read-worker-terminate.test.ts

@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 issues found this pass, but this is a JSC GC-lifetime / cross-thread memory-safety change (DeferrableRefCounted ref across Heap::lastChanceToFinalize), so it's worth a human look before merge.

What was reviewed:

  • ref/deref balance across all four pin sites and unpinArrayBuffer: VectorArrayBuffer::release() derefs every appended view on both success and every error return of collectBufferSpans; borrowBytesForOffThread's return-1 (FastTypedArray) path takes no ref, matching callers that skip unpin.
  • Confirmed the terminated-worker path intentionally leaks the ref rather than deref'ing off-thread (DeferrableRefCounted is non-atomic; unpin is JS-thread-only) — leak-over-UAF tradeoff, deferred to #34154.
  • Both new tests now carry unconditional positive assertions; earlier feedback on vacuous/negative-string checks was addressed in 8cc9b96 and ddbe327.
Extended reasoning...

Overview

The PR adds a native RefCounted +1 on JSC::ArrayBuffer at four existing pin sites in src/jsc/bindings/bindings.cpp (pinArrayBuffer, both branches of borrowBytesForOffThread, and the per-element loop in Bun__JSArray__collectBufferSpans), balanced by a deref() in unpinArrayBuffer. Two new ASAN-gated POSIX tests use FIFOs to park a pool-thread write(2)/read(2) mid-syscall, terminate the worker VM, and probe the buffer contents/address afterward.

Security risks

None beyond the memory-safety concern the PR itself fixes. The change adds a ref, not removes one; the failure mode of getting the balance wrong is a leak (over-ref) or the pre-existing UAF (under-ref), not a new attack surface.

Level of scrutiny

High. This is squarely in REVIEW.md's "most-blocked category": cross-thread buffer lifetime across a GC finalize boundary, with correctness depending on DeferrableRefCounted semantics under GCIncomingRefCountedSet::lastChanceToFinalize. The diff is ~5 production lines but the reasoning is subtle — pin() vs native ref, JS-thread-only deref, the intentional leak on the terminated-worker path pending #34154, and whether possiblySharedBuffer() returns the same ArrayBuffer* at pin and unpin time (it does; pin-time adoption is idempotent).

Other factors

I traced every caller of the four modified helpers (src/runtime/node/types.rs VectorArrayBuffer, src/jsc/array_buffer.rs, MySQLValue, Image, NodeHTTPResponse, node_zlib_binding) and each releases through unpinArrayBuffer on both success and error paths, including collectBufferSpans's partial-failure returns (Rust side sets pinned = true before checking status, then release() on every non-zero branch). All prior inline review threads on this PR are resolved. The bug-hunting pass found nothing new. Given the category, a maintainer sign-off on the lastChanceToFinalize reasoning and the leak-until-#34154 tradeoff is appropriate rather than a bot approval.

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