Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions src/bundler/bundle_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ impl<'a> BundleV2<'a> {
self.client_transpiler.as_deref()
}

/// Safe projection of the `plugins` backref (opaque C++ `BunPlugin`).
/// Safe projection of the `plugins` backref (opaque C++ `Bun::BundlerPlugin`).
/// Set once in `init` from `BakeOptions` / completion config; live for the
/// bundle pass.
#[inline]
Expand Down Expand Up @@ -694,12 +694,13 @@ pub mod bv2_impl {
use bun_core::String as BunString;
use bun_resolver::fs::PathResolverExt as _;

// `Plugin = opaque {}` — backed by C++ `BunPlugin`. The bundler calls
// `has_any_matches` / `match_on_load` / `match_on_resolve` directly
// (no JSC types needed — only `BunString` / raw context ptrs). The
// JSC-aware methods (`create`, `add_plugin`, `global_object`, …) are
// added by `bun_runtime` via the `PluginJscExt` extension trait so
// this crate stays free of `JSValue` / `JSGlobalObject`.
// `Plugin = opaque {}` — backed by C++ `Bun::BundlerPlugin` (heap,
// ThreadSafeRefCounted). The bundler calls `has_any_matches` /
// `match_on_load` / `match_on_resolve` directly (no JSC types
// needed — only `BunString` / raw context ptrs). The JSC-aware
// methods (`create`, `add_plugin`, `global_object`, …) are added by
// `bun_runtime` via the `PluginJscExt` extension trait so this
// crate stays free of `JSValue` / `JSGlobalObject`.
Comment thread
robobun marked this conversation as resolved.
bun_opaque::opaque_ffi! { pub struct Plugin; }
unsafe extern "C" {
// The three `safe fn`s below take only Rust references / by-value
Expand Down
192 changes: 100 additions & 92 deletions src/jsc/bindings/JSBundlerPlugin.cpp

Large diffs are not rendered by default.

50 changes: 34 additions & 16 deletions src/jsc/bindings/JSBundlerPlugin.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <JavaScriptCore/RegularExpression.h>
#include "napi_external.h"
#include <JavaScriptCore/Yarr.h>
#include <wtf/ThreadSafeRefCounted.h>
#include "WriteBarrierList.h"

typedef void (*JSBundlerPluginAddErrorCallback)(void*, void*, JSC::EncodedJSValue, JSC::EncodedJSValue);
Expand All @@ -18,7 +19,11 @@ namespace Bun {

using namespace JSC;

class BundlerPlugin final {
class JSBundlerPlugin;

// Ref-counted separately from the JSBundlerPlugin GC cell so the bundle thread can
// read filter lists after a Worker's VM is torn down. Cell holds one ref, Rust another.
Comment thread
robobun marked this conversation as resolved.
class BundlerPlugin final : public ThreadSafeRefCounted<BundlerPlugin> {
public:
/// In native plugins, the regular expression could be called concurrently on multiple threads.
/// Therefore, we need a mutex to synchronize access.
Expand All @@ -41,7 +46,7 @@ class BundlerPlugin final {
{
}

bool match(JSC::VM& vm, const String& path);
bool match(const String& path);
};

class NamespaceList {
Expand Down Expand Up @@ -74,7 +79,9 @@ class BundlerPlugin final {

struct NativePluginCallback {
JSBundlerPluginNativeOnBeforeParseCallback callback;
Bun::NapiExternal* external;
// NapiExternal::value() captured at append time so the parse thread never
// reads the GC cell; onBeforeParseExternals keeps the cell alive for GC.
Comment thread
robobun marked this conversation as resolved.
void* externalValue;
Comment thread
robobun marked this conversation as resolved.
/// This refers to the string exported in the native plugin under
/// the symbol BUN_PLUGIN_NAME
///
Expand All @@ -95,8 +102,8 @@ class BundlerPlugin final {
PerNamespaceCallbackList fileCallbacks = {};
Vector<PerNamespaceCallbackList> namespaceCallbacks = {};

int call(JSC::VM& vm, BundlerPlugin* plugin, int* shouldContinue, void* bunContextPtr, const BunString* namespaceStr, const BunString* pathString, OnBeforeParseArguments* onBeforeParseArgs, OnBeforeParseResult* onBeforeParseResult);
void append(JSC::VM& vm, JSC::RegExp* filter, String& namespaceString, JSBundlerPluginNativeOnBeforeParseCallback callback, const char* name, NapiExternal* external);
int call(BundlerPlugin* plugin, int* shouldContinue, void* bunContextPtr, const BunString* namespaceStr, const BunString* pathString, OnBeforeParseArguments* onBeforeParseArgs, OnBeforeParseResult* onBeforeParseResult);
void append(JSC::VM& vm, JSC::RegExp* filter, String& namespaceString, JSBundlerPluginNativeOnBeforeParseCallback callback, const char* name, void* externalValue);

Vector<FilterRegExp>* group(const String& namespaceStr, unsigned& index)
{
Expand All @@ -118,33 +125,44 @@ class BundlerPlugin final {
};

public:
bool anyMatchesCrossThread(JSC::VM&, BunString* namespaceStr, BunString* path, bool isOnLoad);
void tombstone() { tombstoned = true; }

BundlerPlugin(void* config, BunPluginTarget target, JSBundlerPluginAddErrorCallback addError, JSBundlerPluginOnLoadAsyncCallback onLoadAsync, JSBundlerPluginOnResolveAsyncCallback onResolveAsync)
: addError(addError)
, onLoadAsync(onLoadAsync)
, onResolveAsync(onResolveAsync)
static Ref<BundlerPlugin> create(void* config, BunPluginTarget target, JSBundlerPluginAddErrorCallback addError, JSBundlerPluginOnLoadAsyncCallback onLoadAsync, JSBundlerPluginOnResolveAsyncCallback onResolveAsync)
{
this->target = target;
this->config = config;
return adoptRef(*new BundlerPlugin(config, target, addError, onLoadAsync, onResolveAsync));
}

bool anyMatchesCrossThread(BunString* namespaceStr, BunString* path, bool isOnLoad);
void tombstone() { tombstoned = true; }

JSBundlerPlugin* cell() const { return m_cell; }
void setCell(JSBundlerPlugin* cell) { m_cell = cell; }
Comment thread
coderabbitai[bot] marked this conversation as resolved.

NamespaceList onLoad = {};
NamespaceList onResolve = {};
NativePluginList onBeforeParse = {};
BunPluginTarget target { BunPluginTargetBrowser };

WriteBarrierList<JSC::JSPromise> deferredPromises = {};
// The raw `NapiExternal*` stored in `NativePluginCallback` is dereferenced
// off the JS thread; this list keeps those cells alive for GC.
// Roots each registered NapiExternal against normal GC while the build runs.
WriteBarrierList<NapiExternal> onBeforeParseExternals = {};

JSBundlerPluginAddErrorCallback addError;
JSBundlerPluginOnLoadAsyncCallback onLoadAsync;
JSBundlerPluginOnResolveAsyncCallback onResolveAsync;
void* config { nullptr };
bool tombstoned { false };

private:
BundlerPlugin(void* config, BunPluginTarget target, JSBundlerPluginAddErrorCallback addError, JSBundlerPluginOnLoadAsyncCallback onLoadAsync, JSBundlerPluginOnResolveAsyncCallback onResolveAsync)
: addError(addError)
, onLoadAsync(onLoadAsync)
, onResolveAsync(onResolveAsync)
{
this->target = target;
this->config = config;
}

// Back-pointer into the owning VM's GC heap. Only dereferenced on the JS thread.
JSBundlerPlugin* m_cell { nullptr };
};

} // namespace Zig
20 changes: 10 additions & 10 deletions src/runtime/api/JSBundler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1620,7 +1620,7 @@
bv2_mut(this.bv2).on_load_async(this);
}

/// Opaque FFI handle for the C++ `JSBundlerPlugin`. The opaque type and
/// Opaque FFI handle for the C++ `Bun::BundlerPlugin`. The opaque type and
/// `has_any_matches` (the one method `bun_bundler` needs) live in the
/// lower-tier crate; JSC-aware methods are added here via `PluginJscExt`.
pub use bun_bundler::bundle_v2::api::JSBundler::Plugin;
Expand All @@ -1635,7 +1635,7 @@
global: &JSGlobalObject,
target: jsc::BunPluginTarget,
) -> *mut Plugin;
safe fn JSBundlerPlugin__tombstone(plugin: &Plugin);
fn JSBundlerPlugin__destroy(plugin: *mut Plugin);
safe fn JSBundlerPlugin__runOnEndCallbacks(
plugin: &mut Plugin,
build_promise: JSValue,
Expand Down Expand Up @@ -1663,9 +1663,9 @@
) -> JSValue;
}

/// JSC-aware methods on the C++ `JSBundlerPlugin` opaque. The opaque type
/// itself is owned by `bun_bundler` (lower tier, no JSC dep), so these are
/// added as an extension trait rather than an inherent `impl`.
/// JSC-aware methods on the C++ `Bun::BundlerPlugin` opaque. The opaque
/// type itself is owned by `bun_bundler` (lower tier, no JSC dep), so these
/// are added as an extension trait rather than an inherent `impl`.
Comment thread
robobun marked this conversation as resolved.
pub trait PluginJscExt {
fn create(global: &JSGlobalObject, target: jsc::BunPluginTarget) -> *mut Plugin;
fn run_on_end_callbacks(
Expand Down Expand Up @@ -1699,12 +1699,10 @@
}

impl PluginJscExt for Plugin {
fn create(global: &JSGlobalObject, target: jsc::BunPluginTarget) -> *mut Plugin {
jsc::mark_binding();
let plugin = JSBundlerPlugin__create(global, target);
JSValue::from_cell(plugin).protect();
plugin
JSBundlerPlugin__create(global, target)
}

Check warning on line 1705 in src/runtime/api/JSBundler.rs

View check run for this annotation

Claude / Claude Code Review

Sibling Plugin::create caller in server_body.rs not audited — new +1 BundlerPlugin ref leaks

There's a third `Plugin::create` caller — `ServePlugins::load_and_resolve_plugins` in `src/runtime/server/server_body.rs:1047-1049` — that wasn't included in this audit. It wraps the returned pointer in `Box<JSBundler::Plugin>` and relies on `Box` drop (a no-op for the `opaque_ffi!` ZST); `Plugin::destroy` is never called anywhere in that file, so the new +1 `BundlerPlugin` ref leaks past VM teardown. The missing-destroy itself is pre-existing and the incremental leak is tiny (one `BundlerPlugin
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.

fn run_on_end_callbacks(
&mut self,
Expand Down Expand Up @@ -1737,10 +1735,12 @@
Ok(value)
}

fn destroy(this: *mut Plugin) {
jsc::mark_binding();
JSBundlerPlugin__tombstone(Plugin::opaque_ref(this));
JSValue::from_cell(this).unprotect();
debug_assert!(!this.is_null());
// SAFETY: `this` is the +1 handle returned by `JSBundlerPlugin__create`;
// the callee releases it and may free the allocation.
unsafe { JSBundlerPlugin__destroy(this) };

Check failure on line 1743 in src/runtime/api/JSBundler.rs

View workflow job for this annotation

GitHub Actions / cargo clippy

this public function might dereference a raw pointer but is not marked `unsafe`
}

Check warning on line 1744 in src/runtime/api/JSBundler.rs

View check run for this annotation

Claude / Claude Code Review

Stale trait doc: PluginJscExt::destroy no longer checks null via opaque_ref

The trait doc on `PluginJscExt::destroy` (a few lines up, on `fn destroy(this: *mut Plugin);`) still says "non-null is checked via `Plugin::opaque_ref` (panics on null)", but 85687ea9 changed this impl to `debug_assert!(!this.is_null()); unsafe { JSBundlerPlugin__destroy(this) }` — it no longer routes through `opaque_ref`, and the null check is debug-only. All callers pass `NonNull::as_ptr()` so this is doc-only; the trait doc just needs to be updated to match ("debug-asserted non-null; caller m
Comment thread
robobun marked this conversation as resolved.

fn global_object(&self) -> &JSGlobalObject {
Expand Down
10 changes: 5 additions & 5 deletions src/runtime/api/js_bundle_completion_task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,11 @@ impl JSBundleCompletionTask {
///
/// Centralises the `Option<NonNull> → Option<&mut T>` deref so callers
/// (`to_js_error` / `on_complete_anytask`) stay safe. The plugin is a C++
/// `JSBundlerPlugin` opaque created by [`PluginJscExt::create`] and
/// `protect()`-ed for the task's lifetime; it is freed only via
/// `Plugin::destroy` in `deinit` *after* `take()` clears `self.plugins`.
/// While the field is `Some` the pointee is therefore live, pinned, and
/// disjoint from `*self` (separate C++-heap allocation).
/// `BundlerPlugin` heap allocation created by [`PluginJscExt::create`]
/// (which also `gcProtect`s the owning `JSBundlerPlugin` GC cell and takes
/// a +1 ref for this task); it is released only via `Plugin::destroy` in
/// `deinit` *after* `take()` clears `self.plugins`. While the field is
/// `Some` the pointee is therefore live, pinned, and disjoint from `*self`.
Comment thread
robobun marked this conversation as resolved.
#[inline]
fn plugins_mut(&mut self) -> Option<&mut Plugin> {
// SAFETY: see fn doc — C++-heap opaque, live while `self.plugins` is
Expand Down
8 changes: 3 additions & 5 deletions src/runtime/bake/bake_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,7 @@ impl Drop for UserOptions {
if let Some(p) = self.bundler_options.plugin {
// `p` is the FFI handle returned by `Plugin::create` in
// `parse_plugin_array`; `PluginJscExt::destroy` is its paired
// (safe) destructor — it null-checks via `opaque_ref` and
// unprotect()s the JSCell / tombstones the C++ object.
// destructor (tombstones + gcUnprotects the cell + drops the ref).
Plugin::destroy(p.as_ptr());
}
}
Expand Down Expand Up @@ -321,8 +320,7 @@ impl SplitBundlerOptions {
Some(p) => p,
None => {
let p = Plugin::create(global, bun_jsc::BunPluginTarget::Bun);
let p = NonNull::new(p)
.expect("JSBundlerPlugin__create returns a non-null protected JSCell");
let p = NonNull::new(p).expect("JSBundlerPlugin__create returns non-null");
self.plugin = Some(p);
p
}
Expand Down Expand Up @@ -360,7 +358,7 @@ impl SplitBundlerOptions {
};

// `Plugin` is an `opaque_ffi!` ZST — `opaque_mut` is the safe
// deref. Handle held live in `self.plugin` (protected JSCell).
// deref. Handle held live in `self.plugin` (ref-counted C++ heap).
let plugin_result = Plugin::opaque_mut(plugin.as_ptr()).add_plugin(
function,
empty_object,
Expand Down
10 changes: 5 additions & 5 deletions src/runtime/bake/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@ pub use bake_body::{PatternBuffer, UserOptions, print_warning};

/// All bake JSC references go through this re-export of `bun_jsc`.
pub mod jsc {
/// `jsc.API.JSBundler.Plugin` — the C++ `BunPlugin` FFI handle. The
/// canonical opaque struct lives in `bun_bundler::bundle_v2::api::JSBundler`
/// (T5) and is re-exported through `crate::api::js_bundler` so the
/// JSC-aware `PluginJscExt` methods are in scope; both paths name the same
/// nominal type.
/// `jsc.API.JSBundler.Plugin` — the C++ `Bun::BundlerPlugin` FFI handle.
/// The canonical opaque struct lives in
/// `bun_bundler::bundle_v2::api::JSBundler` (T5) and is re-exported through
/// `crate::api::js_bundler` so the JSC-aware `PluginJscExt` methods are in
/// scope; both paths name the same nominal type.
Comment thread
robobun marked this conversation as resolved.
pub(crate) use crate::api::js_bundler::Plugin;
pub(crate) use crate::jsc::*;
}
Expand Down
106 changes: 106 additions & 0 deletions test/bundler/bun-build-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1525,3 +1525,109 @@ test("Bun.build can be called thousands of times in one process without crashing
expect(stdout.trim()).toBe("OK 400");
expect(exitCode).toBe(0);
}, 180_000);

// The bundle thread calls JSBundlerPlugin__anyMatches / hasOnBeforeParsePlugins against
// the plugin's filter vectors while a build is in flight. Those vectors used to live
// inline in the JSBundlerPlugin GC cell, so worker.terminate() (which tears down the
// worker's JSC heap) freed them out from under the bundle thread:
//
// JSBundlerPlugin.cpp:96: runtime error: reference binding to null pointer of type
// 'Bun::BundlerPlugin::FilterRegExp'
//
// The filter data is now a ThreadSafeRefCounted heap allocation that outlives the cell,
// so the cross-thread read stays valid after the worker's VM is gone.
//
// This test cannot assert "exit 0": the same terminate() race also trips the
// pre-existing, plugin-independent complete_on_bundle_thread use-after-free (posting the
// build result to the dead worker's event loop), which reproduces on current main with
// or without this change. So we run the race repeatedly and assert only that no attempt
// faults inside JSBundlerPlugin.cpp. Malloc=1 routes JSC allocations through system
// malloc so ASAN can see writes to the freed cell.
test.skipIf(!isASAN)(
"terminating a Worker mid-Bun.build() does not read freed plugin filter data on the bundle thread",
async () => {
const MODULES = 300;
const files: Record<string, string> = {};
let entry = "";
for (let i = 0; i < MODULES; i++) {
files[`m${i}.js`] = `export const v${i} = ${i};\n`;
entry += `import { v${i} } from "./m${i}.js";\n`;
}
entry += `export default 0;\n`;
files["entry.js"] = entry;
files["w.cjs"] = `
const { parentPort, workerData: d } = require("node:worker_threads");
async function lane(k) {
for (;;) {
await Bun.build({
entrypoints: [d.dir + "/entry.js"],
plugins: [{ name: "p" + k, setup(b) { b.onLoad({ filter: /never-matches-anything/ }, () => undefined); } }],
}).catch(() => {});
}
}
parentPort.postMessage("up");
for (let k = 0; k < 2; k++) lane(k);
`;
files["parent.mjs"] = `
import { Worker } from "node:worker_threads";
const dir = process.argv[2];
const t0 = performance.now();
for (let r = 0; r < 40 && performance.now() - t0 < 8000; r++) {
const ws = [];
for (let i = 0; i < 2; i++) {
const w = new Worker(dir + "/w.cjs", { workerData: { dir } });
w.on("error", () => {});
ws.push(w);
}
await Promise.all(ws.map(w => new Promise(res => { w.once("message", res); setTimeout(res, 3000); })));
await Bun.sleep(5 + (r * 7) % 30);
await Promise.all(ws.map(w => w.terminate()));
}
console.log("ok");
`;

using dir = tempDir("bun-build-worker-plugin-terminate", files);

// The subprocess is expected to crash (the unrelated complete_on_bundle_thread UAF
// still exists on main), and on a given crash it may land in either site. Run enough
// attempts that, without the fix, at least one lands in JSBundlerPlugin.cpp. Once
// #35158 / #35767 land this can become a plain expect(stdout).toContain("ok") +
// expect(exitCode).toBe(0).
const ATTEMPTS = 8;
const frames: string[] = [];
const outcomes: Array<{ exitCode: number | null; signalCode: string | null; stderrTail: string }> = [];
let sawSanitizerReport = false;
let sawCleanExit = false;
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
await using proc = Bun.spawn({
cmd: [bunExe(), join(String(dir), "parent.mjs"), String(dir)],
env: { ...bunEnv, Malloc: "1" },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text()]);
await proc.exited;
outcomes.push({
exitCode: proc.exitCode,
signalCode: proc.signalCode,
stderrTail: stderr.split("\n").slice(-20).join("\n"),
});

if (/AddressSanitizer|runtime error:|SUMMARY: /.test(stderr)) sawSanitizerReport = true;
if (stdout.includes("ok") && stderr.trim() === "") sawCleanExit = true;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const pluginFrames = stderr.split("\n").filter(l => /JSBundlerPlugin\.cpp|BundlerPlugin::|FilterRegExp/.test(l));
if (pluginFrames.length > 0) {
frames.push(`attempt ${attempt}:\n${pluginFrames.join("\n")}`);
break;
}
if (sawCleanExit) break;
}

// Prove the race actually produced symbolicated sanitizer output (or ran clean
// end-to-end) so the absence check below is meaningful.
expect({ meaningful: sawSanitizerReport || sawCleanExit, outcomes }).toMatchObject({ meaningful: true });
expect(frames).toEqual([]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
180_000,
);
Loading