Skip to content

Fix four crash/correctness bugs: node:vm link(), Worker name, FFI threadsafe callbacks, sliced Bun.file - #34140

Merged
Jarred-Sumner merged 6 commits into
mainfrom
claude/1.4-cpp-bughunt-fixes
Jul 15, 2026
Merged

Fix four crash/correctness bugs: node:vm link(), Worker name, FFI threadsafe callbacks, sliced Bun.file#34140
Jarred-Sumner merged 6 commits into
mainfrom
claude/1.4-cpp-bughunt-fixes

Conversation

@sosukesuzuki

Copy link
Copy Markdown
Contributor

Four independent fixes, one commit each.

1. node:vm: SourceTextModule.link() segfaults on holey or mismatched arrays

The native link(specifiers, moduleNatives, scriptFetcher) binding is reachable from user code (the native handle is stored under an ordinary own symbol), but its length-parity and element-type checks were debug-only ASSERTs. A hole in either array surfaces as an empty JSValue from getDirectIndex, which passes isCell() with a null cell — so toWTFString() / dynamicDowncast<NodeVMModule>() performed a member call on a null JSC::JSCell* (UBSan abort in debug, null-deref segfault in release).

import * as vm from "node:vm";
const m = new vm.SourceTextModule('import { z } from "x";');
const k = Object.getOwnPropertySymbols(m).find(s => s.description === "kNative");
m[k].link(new Array(1), new Array(1), 0); // segfault

Now rejects holes, non-string specifiers, and mismatched lengths with ERR_INVALID_ARG_TYPE / ERR_INVALID_ARG_VALUE in release builds too.

2. worker_threads: options.name shares a parent-heap StringImpl with the worker thread

options.name was stored from the parent thread without isolatedCopy() (unlike its sibling env / argv / execArgv fields) and later materialized as a worker-heap JSString for threadName. Both threads then ref/deref'd one non-atomic StringImpl refcount, and for an atomized name — e.g. fn.name or a property key — the losing thread's final deref removes the impl from the wrong thread's AtomString table, leaving a dangling entry in the parent's.

const holder = { someAtomizedKey: 1 };
new Worker(src, { eval: true, name: Object.keys(holder)[0] });
// worker-side GC of threadName vs parent-side ~Worker: racy refcount on one impl

The name is now isolatedCopy'd at option parsing and again when building the worker-side threadName binding, so neither heap shares an impl.

3. bun:ffi: threadsafe JSCallback destroys JSC::Strong handles off the JS thread on teardown

FFI_Callback_threadsafe_call captured Ref { wrapper } in the task it posts to the JS thread, but ignored postTaskTo's return value. When the target context was gone or terminating (e.g. the owning Worker was terminated while a foreign thread was still invoking the callback), the never-enqueued lambda — and its Ref — was destroyed on the calling thread. If the JS side had already dropped its reference, ~FFICallbackFunctionWrapper destroyed two JSC::Strong members against a dying VM from a foreign thread.

// in a worker: new JSCallback(fn, { args: ["int"], threadsafe: true }),
// a pthread invokes cb repeatedly; main thread calls worker.terminate()
// → postTaskTo fails, last Ref dies on the pthread

The keep-alive ref is now taken inside postTaskTo's found-live window (betweenLookupAndEnqueue) and released via adoptRef inside the task, so the failure path is ref-neutral and the wrapper's last deref can only happen on the JS thread.

4. Blob: the File arm of resolve_size() widens a sliced Bun.file() to EOF

The "don't clobber a slice's concrete size" guard added for Bytes-backed stores was never applied to the File arm, which still unconditionally set size = file_size - offset:

await Bun.write("data.txt", "0123456789".repeat(10));
const s = Bun.file("data.txt").slice(0, 5);
let n = 0;
for await (const c of new Response(s).body) n += c.length;
console.log(n); // 100, expected 5
  • slice(a, b) streamed from a to EOF via .stream() / Response(slice).body.
  • A sliced Bun.file() HTTP response body advertised the full remaining file as Content-Length (HEAD and GET).
  • Serializing one (structuredClone / postMessage) widened the original blob's size in place.

Both resolve_size() and resolved_size() now share the Bytes arm's clamp (extracted into a window_size helper). Additionally, structuredClone of a file-backed slice lost the window's end entirely because the serialized record only carried the offset — the blob serialization format is bumped to v4 to carry the blob's (pre-resolve) size; unknown sizes remain deferred to the receiver, and v1–v3 payloads still deserialize.

Tests

  • vm: holey/mismatch/non-string variants (crashes on the previous release, passes here) plus the valid-link path, in vm.test.ts.
  • worker_threads: atomized-name create/terminate/GC stress in a subprocess (ASAN-visible), in worker_threads.test.ts.
  • ffi: covered by the existing threadsafe JSCallback suite in cc.test.ts (delivery under GC churn, close-while-enqueued); the failure path itself is a teardown race with no deterministic hook.
  • blob: file-backed slice stream / Content-Length / structuredClone / EOF-clamp variants in blob.test.ts (3 of 4 fail on the previous release).

…crashing

link()'s length-parity and element-type checks were debug-only ASSERTs, so a
holey array (or a specifiers/moduleNatives length mismatch) reached
toWTFString/dynamicDowncast with an empty JSValue — which passes isCell() with
a null cell — and segfaulted in release builds. Reject holes, non-string
specifiers, and mismatched lengths with the matching ERR_* TypeErrors.
…ng heap

options.name was stored as-is from the parent thread and later materialized as
a worker-heap JSString, so both threads ref/deref'd one non-atomic StringImpl
refcount — and for an atomized name (e.g. fn.name), the losing thread's final
deref removes the impl from the wrong thread's atom table. isolatedCopy the
name at option parsing (matching env/argv/execArgv) and again when building
the worker-side threadName binding so neither heap shares an impl.
…ng thread

FFI_Callback_threadsafe_call captured Ref{wrapper} in the posted task but
ignored postTaskTo's return value: when the target context was gone or
terminating, the un-enqueued lambda died on the foreign thread, and if the JS
side had already dropped its ref, ~FFICallbackFunctionWrapper destroyed two
JSC::Strong handles off the JS thread. Take the ref inside postTaskTo's
found-live window (betweenLookupAndEnqueue) and release it via adoptRef inside
the task, so the failure path is ref-neutral and the last deref can only
happen on the JS thread.
…to EOF

resolve_size()/resolved_size() got a "don't clobber a slice's concrete size"
guard for Bytes-backed stores, but the File arm still unconditionally set
size = file_size - offset, so Bun.file(p).slice(a, b) streamed to EOF, HEAD
advertised the full remaining file as Content-Length, and serializing one
widened the original in place. Apply the same guard to both functions.

structuredClone of a file-backed slice also lost the window's end because the
serialized record only carried the offset; bump the blob serialization format
to v4 and carry the blob's (pre-resolve) size, keeping unknown sizes deferred
to the receiver as before. v1-3 payloads still deserialize.
…ved_size

The same unknown-vs-concrete size clamp appeared in all four store arms; move
it into one helper. Also trim the worker-name stress test to 4 iterations.
@robobun

robobun commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator
Updated 5:37 AM PT - Jul 14th, 2026

@autofix-ci[bot], your commit 4b9b39b has 1 failures in Build #72820 (All Failures):

  • 📦 Binary size — 2 over 0.50 MB
  • targetthis build canary: main #72807
    sizeΔ
    bun-darwin-aarch6456.81 MB57.72 MB-933.8 KB
    bun-darwin-x6463.28 MB64.14 MB-882.4 KB
    bun-linux-aarch6470.17 MB69.80 MB+384.0 KB
    bun-linux-x6473.42 MB73.17 MB+256.0 KB
    bun-linux-x64-baseline72.41 MB72.11 MB+304.0 KB
    bun-linux-aarch64-musl63.61 MB63.23 MB+384.0 KB
    bun-linux-x64-musl67.45 MB67.14 MB+320.0 KB
    bun-linux-x64-musl-baseline66.73 MB66.39 MB+352.0 KB
    bun-linux-aarch64-android78.57 MB78.07 MB+512.1 KB
    bun-linux-x64-android81.57 MB81.04 MB+544.0 KB
    bun-freebsd-x6484.20 MB83.79 MB+416.1 KB
    bun-freebsd-aarch6484.98 MB84.52 MB+464.1 KB
    bun-windows-x6476.17 MB75.74 MB+443.5 KB
    bun-windows-x64-baseline75.13 MB74.70 MB+433.5 KB
    bun-windows-aarch6470.33 MB69.89 MB+457.5 KB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 34140

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

bun-34140 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. file.slice(a, b).stream() buffered consumption never resolves for ~1MB+ files #31675 - Fix 4 corrects resolve_size() widening sliced Bun.file() to EOF, which causes file.slice(a, b).stream() to never resolve for large files
  2. Segfault when native code repeatedly invokes JSCallback({ threadsafe: true }) #28113 - Fix 3 addresses the race in FFI_Callback_threadsafe_call where JSC::Strong handles are destroyed off the JS thread, matching the segfault stack traces in this issue
  3. bun:ffi JSCallback invoked from different thread crashing after a while #24529 - Fix 3 also covers threadsafe JSCallback invoked from a different thread crashing due to the same off-thread destruction of JSC::Strong handles
  4. Worker create+terminate cycle aborts process after ~100k–900k iterations on macOS arm64 #30421 - Fix 2 (isolatedCopy on options.name) prevents racy refcount corruption on atomized worker-name strings during rapid create/terminate churn

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #31675
Fixes #28113
Fixes #24529
Fixes #30421

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix Bun.file().slice() being treated as the rest of the file #32794 - Fixes the same resolve_size() bug where the File arm unconditionally widens sliced Bun.file() blobs to EOF, including the same structuredClone serialization version bump

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

An error occurred during the review process. Please try again later.


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

Caution

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

⚠️ Outside diff range comments (1)
src/runtime/webcore/Blob.rs (1)

4320-4335: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

v4 structured-clone size window isn't actually validated against EOF on the receiving side, and the new test can't detect it.

The root cause is that the new file_size wire field is written from self.size.get() before it's resolved/clamped, and on deserialize it's applied directly with no working clamp for File stores (the fallback clamp is gated on store.size() != MAX_SIZE, which is never true for a freshly-created, unstat'd File store). A Bun.file(path).slice(a, b) created against an unresolved parent can have a concrete-but-unclamped size (e.g. .slice(5, 5000) on a 10-byte file → size = 4995), and that raw value now survives structuredClone/postMessage unchecked — a regression from v1-v3, where the receiver never trusted the sender's size and always resolved it locally.

  • src/runtime/webcore/Blob.rs#L4320-L4335: replace the store.size()-gated manual clamp with an unconditional blob.resolve_size() call (it already stats + applies window_size() correctly) so File stores get properly clamped on the receiver.
  • src/runtime/webcore/Blob.rs#L772-L775: update/remove the misleading "resolve_size() clamps this... on first use" comment once the deserialize fix above makes it true, and note that the size written here is unresolved/unclamped by design.
  • test/js/web/fetch/blob.test.ts#L681-L689: add expect(clone.size).toBe(5); after structuredClone(s) so this regression is actually caught by the test suite.
🤖 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/webcore/Blob.rs` around lines 4320 - 4335, The structured-clone
receiver must validate File-backed blob size windows against EOF instead of
trusting the serialized size. In src/runtime/webcore/Blob.rs:4320-4335, replace
the store.size()-gated clamp with an unconditional blob.resolve_size() call; in
src/runtime/webcore/Blob.rs:772-775, update the comment to state that the
serialized size is intentionally unresolved/unclamped; in
test/js/web/fetch/blob.test.ts:681-689, assert clone.size equals 5 after
structuredClone(s).
🤖 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/worker_threads/worker_threads.test.ts`:
- Around line 749-751: Update the subprocess assertions around proc.stdout,
proc.stderr, and proc.exited to preserve stdout, stderr, exitCode, and
signalCode in failure diagnostics. Keep the success assertions focused on
stdout.trim() equaling "done" and exitCode equaling 0, while ensuring stderr and
signal information are included when those assertions fail.

In `@test/js/web/fetch/blob.test.ts`:
- Around line 681-689: Extend the “slice end beyond EOF clamps to the file size”
test after structured cloning to assert that clone.size equals 5. Keep the
existing clone.text() assertion and sender-size assertion unchanged, using the
clone created from s to verify EOF clamping on the receiving side.

---

Outside diff comments:
In `@src/runtime/webcore/Blob.rs`:
- Around line 4320-4335: The structured-clone receiver must validate File-backed
blob size windows against EOF instead of trusting the serialized size. In
src/runtime/webcore/Blob.rs:4320-4335, replace the store.size()-gated clamp with
an unconditional blob.resolve_size() call; in
src/runtime/webcore/Blob.rs:772-775, update the comment to state that the
serialized size is intentionally unresolved/unclamped; in
test/js/web/fetch/blob.test.ts:681-689, assert clone.size equals 5 after
structuredClone(s).
🪄 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: 7a3b1014-1898-4cdf-8e10-2dd5f94fc2f5

📥 Commits

Reviewing files that changed from the base of the PR and between c71f98b and 0705bb3.

📒 Files selected for processing (8)
  • src/jsc/bindings/JSFFIFunction.cpp
  • src/jsc/bindings/NodeVMSourceTextModule.cpp
  • src/jsc/bindings/webcore/JSWorker.cpp
  • src/jsc/bindings/webcore/Worker.cpp
  • src/runtime/webcore/Blob.rs
  • test/js/node/vm/vm.test.ts
  • test/js/node/worker_threads/worker_threads.test.ts
  • test/js/web/fetch/blob.test.ts

Comment on lines +749 to +751
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe("done");
expect(exitCode).toBe(0);

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.

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Preserve crash diagnostics from the subprocess.

The test captures stderr but discards it, so an ASAN/JSC crash may surface only as a generic stdout or exit-code mismatch. Include stderr and signal information in the failure outcome while keeping the success assertion focused on "done" and exit code 0.

Based on learnings, worker-thread crash-detection tests should preserve stdout, stderr, exitCode, and signalCode together.

🤖 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/worker_threads/worker_threads.test.ts` around lines 749 - 751,
Update the subprocess assertions around proc.stdout, proc.stderr, and
proc.exited to preserve stdout, stderr, exitCode, and signalCode in failure
diagnostics. Keep the success assertions focused on stdout.trim() equaling
"done" and exitCode equaling 0, while ensuring stderr and signal information are
included when those assertions fail.

Source: Learnings

Comment on lines +681 to +689
test("slice end beyond EOF clamps to the file size", async () => {
using dir = tempDir("blob-file-slice-eof", { "data.txt": "0123456789" });
const s = Bun.file(`${dir}/data.txt`).slice(5, 5000);
expect(await new Response(s).text()).toBe("56789");
const clone = structuredClone(s);
expect(await clone.text()).toBe("56789");
// Serializing resolves the original's size, clamping the window to EOF.
expect(s.size).toBe(5);
});

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add a clone.size assertion to catch the EOF-clamp regression on the receiving side.

This test only checks decoded content, which passes regardless of whether clone.size is correctly clamped (file reads stop at physical EOF either way). Add an explicit size assertion on the clone to actually verify the structured-clone receiver clamps the window to EOF, not just the sender.

✅ Suggested addition
     const clone = structuredClone(s);
     expect(await clone.text()).toBe("56789");
+    expect(clone.size).toBe(5);
     // Serializing resolves the original's size, clamping the window to EOF.
     expect(s.size).toBe(5);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test("slice end beyond EOF clamps to the file size", async () => {
using dir = tempDir("blob-file-slice-eof", { "data.txt": "0123456789" });
const s = Bun.file(`${dir}/data.txt`).slice(5, 5000);
expect(await new Response(s).text()).toBe("56789");
const clone = structuredClone(s);
expect(await clone.text()).toBe("56789");
// Serializing resolves the original's size, clamping the window to EOF.
expect(s.size).toBe(5);
});
test("slice end beyond EOF clamps to the file size", async () => {
using dir = tempDir("blob-file-slice-eof", { "data.txt": "0123456789" });
const s = Bun.file(`${dir}/data.txt`).slice(5, 5000);
expect(await new Response(s).text()).toBe("56789");
const clone = structuredClone(s);
expect(await clone.text()).toBe("56789");
expect(clone.size).toBe(5);
// Serializing resolves the original's size, clamping the window to EOF.
expect(s.size).toBe(5);
});
🤖 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/web/fetch/blob.test.ts` around lines 681 - 689, Extend the “slice end
beyond EOF clamps to the file size” test after structured cloning to assert that
clone.size equals 5. Keep the existing clone.text() assertion and sender-size
assertion unchanged, using the clone created from s to verify EOF clamping on
the receiving side.

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

I didn't find any bugs, but this bundles four independent memory-/thread-safety fixes in native code (JSC Strong lifetime across threads in FFI, cross-thread StringImpl refcounting, null-cell validation, and a Blob serialization format bump) — worth a human look, and the flagged overlap with #32794 on the Blob fix should be reconciled.

Checked: the FFI change reuses the existing postTaskTo(id, betweenLookupAndEnqueue, task) overload the same way Worker::dispatchExit already does; ref/adoptRef balance on both the enqueued and not-enqueued paths.
Checked: window_size is applied to all four File/Bytes arms of resolve_size/resolved_size, and the v4 deserialize path leaves v1–v3 payloads at MAX_SIZE (unknown) as before.
Checked: the link() empty-JSValue guards cover both specifierValue and moduleNativeValue, and rejected attempts leave the module unlinked.

Extended reasoning...

Overview

Four independent native-code fixes: (1) NodeVMSourceTextModule::link() promotes debug-only ASSERTs to release-build validation for holey/mismatched argument arrays, (2) options.name in the Worker constructor and createNodeWorkerThreadsBinding gain .isolatedCopy() so parent and worker threads never share a StringImpl, (3) FFI_Callback_threadsafe_call moves its keep-alive ref inside postTaskTo's found-live window and adopts it in the task so ~FFICallbackFunctionWrapper (with two JSC::Strong members) can never run on a foreign thread, and (4) the File arm of Blob::resolve_size/resolved_size now uses the same slice-window clamp as the Bytes arm, with a serialization format bump (v3→v4) so a sliced Bun.file() keeps its window across structuredClone/postMessage. Tests are added for three of the four; the FFI teardown race is covered only by the existing cc.test.ts suite.

Security risks

None new. Fix 1 closes a user-reachable null-deref segfault via the kNative symbol on vm.SourceTextModule. The Blob v4 deserialize reads an extra u64 only when version >= 4 and the resulting size is later clamped by resolve_size, so a crafted payload can't over-read.

Level of scrutiny

High. Every fix sits in the CLAUDE.md "most-blocked category": cross-thread refcounts, JSC handle lifetime off the JS thread, and a wire-format version bump. The FFI ref/adoptRef ordering and the "failure path is ref-neutral" claim are exactly the kind of reasoning a maintainer should sign off on. The isolatedCopy additions and the window_size extraction are mechanical, but the serialization version bump is a one-way door.

Other factors

The FFI change follows the precise pattern already used in Worker::dispatchExit (same betweenLookupAndEnqueue overload, same rationale comment), which raises confidence. The window_size helper is a straight extraction of the existing Bytes-arm logic. A bot flagged #32794 as a possible duplicate of the Blob fix — a human should decide which lands. Given four unrelated subsystems in one PR, deferring.

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.

3 participants