Skip to content

bundler: keep BundlerPlugin filter data alive across Worker.terminate() - #36807

Open
robobun wants to merge 10 commits into
mainfrom
farm/1aec9fb8/bundler-plugin-filter-worker-terminate
Open

bundler: keep BundlerPlugin filter data alive across Worker.terminate()#36807
robobun wants to merge 10 commits into
mainfrom
farm/1aec9fb8/bundler-plugin-filter-worker-terminate

Conversation

@robobun

@robobun robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Problem

Terminating a Worker while a Bun.build() with an onLoad/onResolve plugin is running on the singleton bundle thread trips a null-pointer dereference reading the plugin's filter regex list:

JSBundlerPlugin.cpp:96:23: runtime error: reference binding to null pointer of type
'Bun::BundlerPlugin::FilterRegExp'

With Malloc=1 the same read is a heap-use-after-free in JSBundlerPlugin__anyMatches (JSBundlerPlugin.cpp:512, pluginObject->vm()).

Cause

BundlerPlugin (the filter vectors plus callbacks) was an inline member of the JSBundlerPlugin GC cell. The Rust-side Plugin opaque that JSBundleCompletionTask / BundleV2 carry across to the bundle thread was a raw pointer into that cell. gcProtect keeps the cell alive through a normal GC, but WebWorker::shutdown runs ~VM(), which tears down the JSC heap regardless. The bundle thread then reads freed cell bytes from JSBundlerPlugin__anyMatches, JSBundlerPlugin__hasOnBeforeParsePlugins and JSBundlerPlugin__callOnBeforeParsePlugins.

Fix

BundlerPlugin is now ThreadSafeRefCounted and heap-allocated separately from the GC cell:

  • JSBundlerPlugin holds a Ref<BundlerPlugin>.
  • JSBundlerPlugin__create gcProtects the cell, takes a +1 on the BundlerPlugin, and returns the BundlerPlugin* (what the Rust Plugin opaque now points at).
  • The cross-thread extern-C entry points (anyMatches, hasOnBeforeParsePlugins, callOnBeforeParsePlugins) operate on the BundlerPlugin directly and never touch the cell.
  • The JS-thread entry points (matchOnLoad, drainDeferred, runSetupFunction, ...) go through plugin->cell() for vm()/globalObject()/LazyProperty access; these only run while the JS thread (and hence the VM) is alive.
  • New JSBundlerPlugin__destroy does tombstone() + gcUnprotect(cell) + deref(); PluginJscExt::create/destroy forward straight to the C++ side.

FilterRegExp::match and NativePluginList::call no longer take VM&: Yarr::RegularExpression::match runs the bytecode interpreter and the MatchingContextHolder that was constructed around it was never consulted.

Verification

New ASAN-gated test in test/bundler/bun-build-api.test.ts spawns a subprocess that repeatedly starts Workers which loop Bun.build (300-module graph, synchronous setup registering an onLoad filter) and terminates them mid-build. The test runs up to eight subprocess attempts and asserts no sanitizer frame lands in JSBundlerPlugin.cpp.

  • Without this change: fails with JSBundlerPlugin__anyMatches in the stack on 2 of the first 4 attempts.
  • With this change: passes; no attempt faults inside JSBundlerPlugin.cpp.

bun-build-api.test.ts (50 pass, 1 skip, 1 todo), bundler_plugin.test.ts, bundler_plugin_chain.test.ts, bundler_defer.test.ts and native-plugin.test.ts all pass.

Related

The same terminate() race also hits the pre-existing, plugin-independent complete_on_bundle_thread UAF (posting the build result to the freed worker event loop). That reproduces on current main with or without this change and is covered by #35158 / #35767; this PR is scoped to the plugin-data read and the test is written accordingly.


no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/bundler/bun-build-api.test.ts

The bundle thread reads a plugin's onLoad/onResolve filter regex lists
(via JSBundlerPlugin__anyMatches / hasOnBeforeParsePlugins /
callOnBeforeParsePlugins) while a build is in flight. Those lists lived
inline in the JSBundlerPlugin GC cell, so terminating a Worker that had
kicked off a Bun.build() with a plugin freed them out from under the
bundle thread:

  JSBundlerPlugin.cpp:96: runtime error: reference binding to null
  pointer of type 'Bun::BundlerPlugin::FilterRegExp'

BundlerPlugin is now ThreadSafeRefCounted and heap-allocated separately
from the GC cell. The cell holds one ref, and the Rust-side Plugin
handle (what JSBundleCompletionTask / BundleV2 carry across threads)
holds another, so the filter data outlives VM teardown. The Rust opaque
now points at the BundlerPlugin heap allocation directly; every extern
C entry point routes JS-thread work through plugin->cell() and the
cross-thread ones no longer touch the cell at all.

FilterRegExp::match dropped its VM& parameter: Yarr::RegularExpression
uses the bytecode interpreter and the MatchingContextHolder it
constructed was unused.
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a2ed40e3-5738-4dff-acdf-747677cd8698

📥 Commits

Reviewing files that changed from the base of the PR and between bfeb7d6 and d53dca0.

📒 Files selected for processing (2)
  • src/runtime/api/js_bundle_completion_task.rs
  • src/runtime/server/server_body.rs

Walkthrough

The plugin bridge now uses a reference-counted BundlerPlugin linked to its JavaScript cell. Cross-thread matching and callback storage avoid VM and NAPI external access. FFI cleanup, server cleanup, completion cleanup, and ASAN coverage were updated.

Changes

BundlerPlugin ownership migration

Layer / File(s) Summary
Plugin ownership and lifecycle contract
src/jsc/bindings/JSBundlerPlugin.h, src/jsc/bindings/JSBundlerPlugin.cpp, src/runtime/api/JSBundler.rs, src/runtime/api/js_bundle_completion_task.rs, src/runtime/bake/*, src/runtime/server/server_body.rs, src/bundler/bundle_v2.rs
BundlerPlugin is independently reference-counted and stores its owning JS cell. Creation, destruction, completion cleanup, and server cleanup now use the C++ object lifecycle. Documentation identifies the Bun::BundlerPlugin handle.
Cross-thread matching and callback data
src/jsc/bindings/JSBundlerPlugin.h, src/jsc/bindings/JSBundlerPlugin.cpp
Filter matching no longer receives a VM. Native callbacks store captured external values and retain their NAPI externals for GC.
JavaScript bridge state access and validation
src/jsc/bindings/JSBundlerPlugin.cpp, test/bundler/bun-build-api.test.ts
Load, resolve, setup, serve, promise, configuration, and callback bridges obtain JavaScript state from the owning cell. An ASAN-only test covers worker termination during concurrent filtered builds.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary fix: keeping BundlerPlugin filter data alive during Worker.terminate().
Description check ✅ Passed The description explains the problem, cause, fix, verification, test results, and scope, although it uses different headings than the template.
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.

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

@github-actions github-actions Bot added the claude label Aug 3, 2026
Comment thread src/jsc/bindings/JSBundlerPlugin.cpp Outdated
Comment thread src/jsc/bindings/JSBundlerPlugin.h Outdated
Comment thread src/runtime/api/js_bundle_completion_task.rs
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Segmentation fault during build #23773 - Segfault during build with JSBundlerPlugin__matchOnResolve in the stack trace — the crash site is exactly the matchOnResolve path whose filter data is freed when the Worker VM tears down; keeping BundlerPlugin alive via ThreadSafeRefCounted removes the freed-memory read this crash reports.

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

Fixes #23773

🤖 Generated with Claude Code

Comment thread src/jsc/bindings/JSBundlerPlugin.cpp Outdated
Comment thread src/runtime/api/JSBundler.rs
Comment thread src/jsc/bindings/JSBundlerPlugin.h
Comment thread src/jsc/bindings/JSBundlerPlugin.h Outdated
Comment thread src/jsc/bindings/JSBundlerPlugin.h
Comment thread src/jsc/bindings/JSBundlerPlugin.cpp
Comment thread src/bundler/bundle_v2.rs
Comment thread src/runtime/api/JSBundler.rs
Comment thread src/runtime/bake/mod.rs

@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: 5

🤖 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/bindings/JSBundlerPlugin.h`:
- Around line 134-137: Invalidate the plugin back-pointer in
JSBundlerPlugin::tombstone() by clearing m_cell, and update cell() callers to
handle a null result. In src/jsc/bindings/JSBundlerPlugin.h lines 134-137, apply
the invalidation; in src/jsc/bindings/JSBundlerPlugin.cpp lines 611-616, capture
the cell pointer before tombstoning and skip gcUnprotect when that pointer is
null.
- Around line 80-84: Ensure NativePluginCallback::externalValue remains valid
until every detached parse callback has completed, even during Worker::shutdown.
Synchronize JSC VM teardown with outstanding parse work before releasing
onBeforeParseExternals, or transfer the payload and its finalizer ownership to a
VM-independent lifetime owner; update the shutdown/callback coordination rather
than relying only on JSBundlerPlugin rooting.

In `@src/runtime/api/JSBundler.rs`:
- Line 1638: Update the `JSBundlerPlugin__destroy` declaration and its
corresponding dispatch paths around the additional referenced lines to accept
and forward `*mut Plugin` rather than `&Plugin`; keep the value as a raw pointer
through the destruction call so no shared reference remains while `deref()` may
free the allocation.

In `@test/bundler/bun-build-api.test.ts`:
- Line 1611: Normalize stderr before the clean-exit check in the test’s
execution loop, removing benign ASAN/debug output while preserving meaningful
diagnostics. Use the normalized value in the condition that sets sawCleanExit,
so successful runs with only recognized benign stderr still reach the existing
meaningfulness assertion.
- Around line 1600-1624: Update the subprocess loop around Bun.spawn to record
each attempt’s proc.exitCode and proc.signalCode after awaiting proc.exited,
along with the relevant stdout/stderr outcome. Include this per-attempt
diagnostic data in the meaningfulness assertion for sawSanitizerReport ||
sawCleanExit, while preserving the required stdout/stderr assertions before any
exit-code assertion and the existing frames check.
🪄 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: 90b9d955-749f-482a-8454-665e2c8f239c

📥 Commits

Reviewing files that changed from the base of the PR and between 52af832 and 6c34a4f.

📒 Files selected for processing (8)
  • src/bundler/bundle_v2.rs
  • src/jsc/bindings/JSBundlerPlugin.cpp
  • src/jsc/bindings/JSBundlerPlugin.h
  • src/runtime/api/JSBundler.rs
  • src/runtime/api/js_bundle_completion_task.rs
  • src/runtime/bake/bake_body.rs
  • src/runtime/bake/mod.rs
  • test/bundler/bun-build-api.test.ts

Comment thread src/jsc/bindings/JSBundlerPlugin.h
Comment thread src/jsc/bindings/JSBundlerPlugin.h
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread test/bundler/bun-build-api.test.ts
Comment thread test/bundler/bun-build-api.test.ts Outdated
@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 85687ea (build #87998): both failures are unrelated to this diff.

  • test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts SIGABRT on debian x64-asan is a pre-existing JSC !exception() assertion flake (the test does not touch Bun.build or bundler plugins).
  • test/cli/install/bun-install-lifecycle-scripts.test.ts passed on retry.

The new ASAN-gated test in bun-build-api.test.ts passed on the ASAN lane. Ready for review.

Comment thread src/runtime/api/JSBundler.rs
Comment thread src/runtime/api/JSBundler.rs
Comment thread src/runtime/api/JSBundler.rs
@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:52 PM PT - Aug 2nd, 2026

@robobun, your commit d53dca045b12e9e4f690629ed150fa1f473aaf56 passed in Build #88013! 🎉


🧪   To try this PR locally:

bunx bun-pr 36807

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

bun-36807 --bun

@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 `@src/runtime/server/server_body.rs`:
- Around line 1048-1052: The native JSBundler::Plugin handle must be explicitly
destroyed on every lifecycle exit. In src/runtime/server/server_body.rs lines
1048-1052, add a cleanup guard immediately after heap::take and disarm it only
after ownership is assigned to Pending; in src/runtime/server/server_body.rs
lines 1230-1235, bind the pending plugin and call JSBundler::Plugin::destroy
before the debug assertion.

In `@test/bundler/bun-build-api.test.ts`:
- Line 1617: Update the sawCleanExit assignment in the subprocess handling logic
to require proc.exitCode === 0 and proc.signalCode === null in addition to the
existing stdout and stderr checks, so it is only set after a clean successful
exit.
🪄 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: 6e9afdb0-8aac-4f2e-8781-72b5ace99c1a

📥 Commits

Reviewing files that changed from the base of the PR and between 6c34a4f and 4f281e1.

📒 Files selected for processing (3)
  • src/runtime/api/JSBundler.rs
  • src/runtime/server/server_body.rs
  • test/bundler/bun-build-api.test.ts

Comment thread src/runtime/server/server_body.rs
Comment thread test/bundler/bun-build-api.test.ts Outdated
Comment thread src/runtime/server/server_body.rs
Comment thread src/runtime/server/server_body.rs
…deinit does not double-destroy the ServePlugins-owned handle
Comment thread src/runtime/api/js_bundle_completion_task.rs

@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 d53dca0 — the earlier double-destroy on the cancelled HTML-bundle path is fixed by hoisting this.plugins = None above the cancelled early-return, and the ServePlugins create/destroy pairing now covers all arms. That said, this is a cross-thread memory-lifetime refactor of a JSC GC cell (inline → ThreadSafeRefCounted heap split, new m_cell back-pointer, changed FFI ownership contract on Plugin::create/destroy) with knock-on changes to three separate callers, so it warrants a human look.

What was reviewed:

  • BundlerPlugin refcount balance across __create/__destroy and cell finalization; m_cell only dereferenced on the JS thread.
  • All three Plugin::create callers (JSBundleCompletionTask, bake UserOptions, ServePlugins) now pair with Plugin::destroy on every terminal path including the scopeguard-covered error paths.
  • The direct HTMLBundle::State::deinitJSBundleCompletionTask::deinit path for a still-borrowed plugin pointer — ruled out (Route self-ref keeps it alive until on_complete_anytask has cleared plugins).
  • NativePluginCallback now stores external->value() instead of the NapiExternal* cell so the parse thread never reads GC memory.
Extended reasoning...

Overview

This PR fixes a use-after-free where the bundle thread reads BundlerPlugin filter vectors after Worker.terminate() has torn down the owning VM's heap. The fix separates BundlerPlugin from the JSBundlerPlugin GC cell: it becomes a ThreadSafeRefCounted heap allocation, the cell holds one Ref<>, and the Rust-side opaque handle holds a second +1 taken in JSBundlerPlugin__create and released in the new JSBundlerPlugin__destroy. All extern-C entry points are re-typed from JSBundlerPlugin* (cell) to BundlerPlugin* (heap); cross-thread ones (anyMatches, hasOnBeforeParsePlugins, callOnBeforeParsePlugins) no longer touch the cell or VM&, and JS-thread ones go through a new m_cell back-pointer. NativePluginCallback stores the raw void* externalValue captured at append time instead of a NapiExternal* cell pointer. On the Rust side, Plugin::create/destroy forward directly to C++ (no more Rust-side protect()/unprotect()), and every caller of Plugin::create was audited: JSBundleCompletionTask::deinit, bake UserOptions::drop, and ServePlugins (Drop, handle_on_reject, plus a scopeguard around the fallible setup path).

The PR went through several review iterations that surfaced and fixed real issues: a missing destroy in the ServePlugins path (4f281e1), a double-destroy on the cancelled HTML-bundle path where JSBundleCompletionTask::deinit would destroy a borrowed handle that ServePlugins also destroys (d53dca0), stale trait docs, and test-assertion tightening. All threads are resolved.

Security risks

None user-facing beyond memory safety. The change is entirely about cross-thread lifetime of native filter data; no new attack surface, input parsing, or auth logic.

Level of scrutiny

High. This is squarely in REVIEW.md's most-blocked category — native memory safety across threads with a GC-cell back-pointer, a new refcounted allocation whose refs must balance on every terminal path (success, error, cancellation, VM teardown), and a changed FFI ownership contract that touched three independent callers. The fact that the review process itself found a double-destroy that was only reachable via Bun.serve bunfig plugins + HTML route + server.stop() mid-bundle demonstrates the interaction surface is non-trivial. The test is ASAN-gated and deliberately tolerates a different pre-existing UAF (complete_on_bundle_thread, tracked in #35158/#35767), asserting only that no crash frame lands in JSBundlerPlugin.cpp — reasonable given the scope, but it means the fix is verified indirectly.

Other factors

  • Yarr::MatchingContextHolder was removed from FilterRegExp::match on the basis that Yarr::RegularExpression::match runs the bytecode interpreter and never consults it — this looks correct but is a behaviour claim about JSC internals a human familiar with Yarr should confirm.
  • WriteBarrierList<> members (deferredPromises, onBeforeParseExternals) now live on the heap-allocated BundlerPlugin rather than inline in the cell, but are still visited via plugin->deferredPromises.visit(this, visitor) from the cell's visitChildren — this is fine while the cell holds a Ref<> but worth a second pair of eyes on the GC-visitor contract.
  • CI on the last pre-fix commit (85687ea) was green on the ASAN lane; d53dca0 build #88013 was still running per the last robobun update.

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.

1 participant