Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
9 changes: 8 additions & 1 deletion src/bundler/Graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ pub struct Graph<'a> {
/// is "moved" into this counter (pending_items -= 1; deferred_pending += 1)
///
/// When `pending_items` hits zero and there are deferred pending tasks, those
/// tasks will be run, and the count is "moved" back to `pending_items`
/// tasks will be run, and the count is "moved" back to `pending_items`;
/// a load answered before then moves its own back (`BundleV2::on_load`).
Comment thread
robobun marked this conversation as resolved.
pub(crate) deferred_pending: u32,

/// onResolve / onLoad requests a plugin currently holds (dispatched to its
Expand Down Expand Up @@ -274,6 +275,12 @@ impl<T> Default for OutstandingLink<T> {
}
}
}
impl<T> OutstandingLink<T> {
/// Dispatched to the plugin and not answered yet.
pub(crate) fn is_linked(&self) -> bool {
self.linked
}
}
pub trait OutstandingNode: Sized {
fn link(&mut self) -> &mut OutstandingLink<Self>;
}
Expand Down
165 changes: 99 additions & 66 deletions src/bundler/bundle_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1199,15 +1199,17 @@ pub mod bv2_impl {
pub parse_task: bun_ptr::BackRef<ParseTask, bun_ptr::Mut>,
/// Faster path: skip the extra threadpool dispatch when the file is not found.
pub was_file: bool,
/// Defer may only be called once.
/// Defer may only be called once (JS thread).
pub called_defer: bool,
/// `.defer()`ed and not yet drained: its scan-counter unit sits in
/// `Graph::deferred_pending` (bundle thread only).
/// Its scan-counter unit currently sits in `Graph::deferred_pending`
/// (bundle thread only).
Comment thread
robobun marked this conversation as resolved.
pub(crate) deferred: bool,
/// `jsc.AnyEventLoop.Task` — intrusive node for the Mini-loop queue
/// (used by `onDefer` to notify the bundler thread when it runs
/// under a `MiniEventLoop`).
/// Mini-loop queue node for the onLoad answer (`on_load_async`).
pub task: bun_event_loop::AnyTaskWithExtraContext::AnyTaskWithExtraContext,
/// Mini-loop queue node for the `.defer()` notification
/// (`on_defer_async`), which may still be queued when the answer is posted.
Comment thread
robobun marked this conversation as resolved.
pub(crate) defer_task:
bun_event_loop::AnyTaskWithExtraContext::AnyTaskWithExtraContext,
/// Links in `Graph::outstanding_loads`; bundle thread only.
pub(crate) outstanding: crate::Graph::OutstandingLink<Load>,
}
Expand All @@ -1229,6 +1231,7 @@ pub mod bv2_impl {
called_defer: false,
deferred: false,
task: bun_event_loop::AnyTaskWithExtraContext::AnyTaskWithExtraContext::default(),
defer_task: bun_event_loop::AnyTaskWithExtraContext::AnyTaskWithExtraContext::default(),
outstanding: Default::default(),
}
}
Expand Down Expand Up @@ -2120,14 +2123,6 @@ pub mod bv2_impl {
while let Some(load) = self.graph.outstanding_loads.pop() {
// SAFETY: as above.
let load = unsafe { &mut *load };
if load.deferred {
// Its unit is parked in `deferred_pending`, not `pending_items`.
load.deferred = false;
self.graph.deferred_pending -= 1;
drop(core::mem::take(&mut load.path));
drop(core::mem::take(&mut load.namespace));
continue;
}
load.value = jsc_api::JSBundler::LoadValue::Err(cancelled_msg(&load.path));
Self::on_load(load, self);
}
Expand Down Expand Up @@ -4316,16 +4311,23 @@ pub mod bv2_impl {
Ok(())
}

pub fn on_load_async(&mut self, load: &mut jsc_api::JSBundler::Load) {
// Dispatch to the loop that *owns* `BundleV2`.
// For `Bun.build` this is a Mini loop running on the bundler thread, so
// `on_load` must land there — not on the JS plugin loop — or it will
// mutate `graph` / allocate from `graph.heap` off-thread.
/// Plugin host's thread: run a callback on the loop that owns this pass
/// (for `Bun.build`, the bundle thread's Mini loop; `graph` is only touched there).
///
/// # Safety
/// `task_offset` locates an `AnyTaskWithExtraContext` field of `*request` that is
/// not queued anywhere else, and `*request` outlives the hop.
Comment thread
robobun marked this conversation as resolved.
unsafe fn post_to_own_loop<C>(
&mut self,
request: *mut C,
on_js_loop: fn(*mut C) -> bun_event_loop::JsResult<()>,
on_mini_loop: fn(*mut C, *mut BundleV2<'static>),
task_offset: usize,
) {
match self.any_loop_mut() {
bun_event_loop::AnyEventLoop::Js { .. } => {
let ct = bun_event_loop::ConcurrentTask::ConcurrentTask::from_callback(
std::ptr::from_mut(load),
on_load_from_js_loop_raw,
request, on_js_loop,
);
let poster = self
.js_poster
Expand All @@ -4340,50 +4342,57 @@ pub mod bv2_impl {
}
}
bun_event_loop::AnyEventLoop::Mini(mini) => {
// SAFETY: `load` is a valid &mut for the duration of the enqueue;
// the mini loop dispatches `on_load_mini` on the bundler thread.
// SAFETY: fn contract; the tick passes this `BundleV2` as the extra ctx.
unsafe {
mini.enqueue_task_concurrent_with_extra_ctx::<jsc_api::JSBundler::Load, BundleV2<'static>>(
std::ptr::from_mut(load),
on_load_mini,
core::mem::offset_of!(jsc_api::JSBundler::Load, task),
mini.enqueue_task_concurrent_with_extra_ctx::<C, BundleV2<'static>>(
request,
on_mini_loop,
task_offset,
);
}
}
}
}

/// The onLoad answer is in `load.value`; run `on_load` on the owning loop.
pub fn on_load_async(&mut self, load: &mut jsc_api::JSBundler::Load) {
// SAFETY: the plugin glue answers a load once, so `task` is queued once;
// `load` is arena-owned by this pass.
unsafe {
self.post_to_own_loop(
std::ptr::from_mut(load),
on_load_from_js_loop_raw,
on_load_mini,
core::mem::offset_of!(jsc_api::JSBundler::Load, task),
);
}
}

/// The onLoad callback called `.defer()`; run `on_notify_defer` on the owning
/// loop. Shares the answer's queue, so the two arrive in the order they were issued.
Comment thread
robobun marked this conversation as resolved.
pub fn on_defer_async(&mut self, load: &mut jsc_api::JSBundler::Load) {
// SAFETY: `called_defer` allows one `.defer()`, so `defer_task` is queued once;
// `load` is arena-owned by this pass.
unsafe {
self.post_to_own_loop(
std::ptr::from_mut(load),
on_notify_defer_from_js_loop_raw,
on_notify_defer_mini,
core::mem::offset_of!(jsc_api::JSBundler::Load, defer_task),
);
}
}

/// The onResolve answer is in `resolve.value`; run `on_resolve` on the owning loop.
pub fn on_resolve_async(&mut self, resolve: &mut jsc_api::JSBundler::Resolve) {
// See `on_load_async` — must dispatch on the bundler's own loop.
match self.any_loop_mut() {
bun_event_loop::AnyEventLoop::Js { .. } => {
let ct = bun_event_loop::ConcurrentTask::ConcurrentTask::from_callback(
std::ptr::from_mut(resolve),
on_resolve_from_js_loop_raw,
);
let poster = self
.js_poster
.as_ref()
.expect("JS-owned bundle has a poster");
if let bun_event_loop::Posted::Refused(ct) = poster.post(ct) {
// Owning JS VM torn down mid-bundle: the hop never runs.
// SAFETY: refused ⇒ we own the task.
unsafe {
bun_event_loop::ConcurrentTask::ConcurrentTask::release_refused(ct)
};
}
}
bun_event_loop::AnyEventLoop::Mini(mini) => {
// SAFETY: `resolve` is a valid &mut for the duration of the enqueue;
// the mini loop dispatches `on_resolve_mini` on the bundler thread.
unsafe {
mini.enqueue_task_concurrent_with_extra_ctx::<jsc_api::JSBundler::Resolve, BundleV2<'static>>(
std::ptr::from_mut(resolve),
on_resolve_mini,
core::mem::offset_of!(jsc_api::JSBundler::Resolve, task),
);
}
}
// SAFETY: as `on_load_async`.
unsafe {
self.post_to_own_loop(
std::ptr::from_mut(resolve),
on_resolve_from_js_loop_raw,
on_resolve_mini,
core::mem::offset_of!(jsc_api::JSBundler::Resolve, task),
);
}
}
}
Expand Down Expand Up @@ -4414,10 +4423,31 @@ pub mod bv2_impl {
Ok(())
}

fn on_notify_defer_mini(load: *mut jsc_api::JSBundler::Load, this: *mut BundleV2<'static>) {
// SAFETY: see `on_load_mini`.
BundleV2::on_notify_defer(unsafe { &mut *load }, unsafe { &mut *this });
}

fn on_notify_defer_from_js_loop_raw(
load: *mut jsc_api::JSBundler::Load,
) -> bun_event_loop::JsResult<()> {
// SAFETY: `load` is a valid pointer set up by `from_callback`.
let load = unsafe { &mut *load };
// SAFETY: `bv2` is a live backref set in `Load::init`.
let bv2 = unsafe { &mut *load.bv2 };
BundleV2::on_notify_defer(load, bv2);
Ok(())
}

impl<'a> BundleV2<'a> {
pub(crate) fn on_load(load: &mut jsc_api::JSBundler::Load, this: &mut BundleV2) {
this.graph.outstanding_loads.unlink(load);
load.deferred = false;
if load.deferred {
// Answered before the drain: the paths below expect the unit in `pending_items`.
load.deferred = false;
this.graph.deferred_pending -= 1;
this.increment_scan_counter();
}
// `Load` is arena-allocated (no Drop); free its owned heap fields on every exit path.
struct LoadDeinitGuard(*mut jsc_api::JSBundler::Load);
impl Drop for LoadDeinitGuard {
Expand Down Expand Up @@ -6938,15 +6968,18 @@ pub mod bv2_impl {
}

impl<'a> BundleV2<'a> {
pub fn on_notify_defer(&mut self) {
self.thread_lock.assert_locked();
self.graph.deferred_pending += 1;
self.decrement_scan_counter();
}

pub fn on_notify_defer_mini(load: &mut jsc_api::JSBundler::Load, this: &mut BundleV2) {
/// Owning loop: `load`'s onLoad callback called `.defer()`, so its scan-counter
/// unit moves to `deferred_pending` until `drain_deferred_tasks` (or `on_load`).
Comment thread
robobun marked this conversation as resolved.
pub(crate) fn on_notify_defer(load: &mut jsc_api::JSBundler::Load, this: &mut BundleV2) {
this.thread_lock.assert_locked();
// `.defer()` called after answering: `on_load` already disposed of the unit.
if !load.outstanding.is_linked() {
return;
}
debug_assert!(!load.deferred, "Load::called_defer allows one .defer()");
load.deferred = true;
this.on_notify_defer();
this.graph.deferred_pending += 1;
this.decrement_scan_counter();
}

pub(crate) fn on_parse_task_complete(
Expand Down
61 changes: 5 additions & 56 deletions src/runtime/api/JSBundler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ use bun_collections::{StringMap, StringSet};
use bun_core::MutableString;
use bun_core::Output;
use bun_core::{String as BunString, ZigString};
use bun_jsc::ConcurrentTask::ConcurrentTask;
use bun_jsc::{self as jsc, CallFrame, JSGlobalObject, JSValue, JsError, JsResult};
use bun_options_types::compile_target::CompileTarget;
use bun_options_types::schema::api; // bun.schema.api
Expand Down Expand Up @@ -1409,7 +1408,7 @@ pub mod js_bundler {
///
/// Centralises the `*mut BundleV2 → &mut` deref so the C++-called thunks
/// (`JSBundlerPlugin__onResolveAsync`, `on_defer`, `…__onLoadAsync`,
/// `…__addError`, `on_notify_defer_raw`) stay safe at the call site. `bv2`
/// `…__addError`) stay safe at the call site. `bv2`
/// is the back-reference set in `Resolve::init`/`Load::init`; the
/// `BundleV2` heap allocation outlives every plugin callback (owner-
/// creates-child, single-JS-thread). The `BundleV2` storage is heap-
Expand Down Expand Up @@ -1503,63 +1502,13 @@ pub mod js_bundler {
bstr::BStr::new(&self.path)
);

// Notify the *bundler thread* about the deferral. This will
// decrement the pending item counter and increment the deferred
// counter. Must land on `parse_task.ctx.loop()` (the loop running
// BundleV2), which is distinct from `js_loop_for_plugins()` (the
// plugin host's JS loop) when `Bun.build` runs the bundler on its
// own Mini event loop.
// SAFETY: parse_task.ctx and bv2 are valid backrefs; `r#loop()`
// points at a live `AnyEventLoop` owned by the bundle thread /
// runtime for the duration of the bundle.
unsafe {
let ctx = (*self.parse_task).ctx.expect("ParseTask.ctx unset");
// SAFETY: write provenance from `ParseTask::init`; bundle outlives plugin.
let any_loop = ctx
.assume_mut()
.r#loop()
.expect("BundleV2.linker.loop must be set before plugins run");
match &mut *any_loop.as_ptr() {
bun_event_loop::AnyEventLoop::Js { .. } => {
let ct =
ConcurrentTask::from_callback(ctx.as_mut_ptr(), on_notify_defer_raw);
let poster = (*ctx.as_mut_ptr())
.js_poster
.as_ref()
.expect("JS-owned bundle has a poster");
if let bun_event_loop::Posted::Refused(ct) = poster.post(ct) {
// Owning JS VM torn down mid-bundle: the notify never runs.
bun_event_loop::ConcurrentTask::ConcurrentTask::release_refused(ct);
}
}
bun_event_loop::AnyEventLoop::Mini(mini) => {
// `mini.enqueueTaskConcurrentWithExtraCtx(
// Load, BundleV2, this, BundleV2.onNotifyDeferMini, .task)`
mini.enqueue_task_concurrent_with_extra_ctx::<Load, BundleV2<'static>>(
std::ptr::from_mut::<Load>(self),
on_notify_defer_mini_wrap,
core::mem::offset_of!(Load, task),
);
}
}

Ok(bv2_plugin(self.bv2).append_defer_promise())
}
// Read before posting: the owning loop may write to the `Load` once it has it.
let bv2 = self.bv2;
bv2_mut(bv2).on_defer_async(self);
Ok(bv2_plugin(bv2).append_defer_promise())
}
}

fn on_notify_defer_raw(ctx: *mut BundleV2<'static>) -> bun_event_loop::JsResult<()> {
bv2_mut(ctx).on_notify_defer();
Ok(())
}

fn on_notify_defer_mini_wrap(load: *mut Load, ctx: *mut BundleV2<'static>) {
// SAFETY: callback contract — `load` was passed as the `Context` arg to
// `enqueue_task_concurrent_with_extra_ctx`; `ctx` is the bundle-thread
// `BundleV2` backref the mini loop's tick supplies as `ParentContext`.
BundleV2::on_notify_defer_mini(unsafe { &mut *load }, unsafe { &mut *ctx });
}

/// # Safety
/// `load` must be the live `*mut Load` previously handed to C++ via
/// `Load::dispatch`, and `global` must be the plugin's owning
Expand Down
47 changes: 47 additions & 0 deletions test/bake/dev/plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,53 @@ devTest("onResolve + onLoad virtual file", {
]);
},
});
// The dev server runs the bundle on the JS thread itself (the other arm of the
// plugin hops exercised by test/bundler/bundler_defer.test.ts). A `defer()` issued
// after the callback already answered must not count against the scan: y's pending
// unit already belongs to the parse of its answer, and parking it again made the
// scan look finished before z (imported by that answer) was loaded, so x's
// `defer()` resolved early.
devTest("onLoad defer() after answering does not resolve other loads' defer() early", {
framework: minimalFramework,
pluginFile: `
let zLoaded = false;
export default [
{
name: 'late-defer',
setup(build) {
build.onLoad({ filter: /[\\\\/]x\\.ts$/ }, async ({ defer }) => {
await defer();
return { contents: 'export const zLoadedBeforeXResumed = ' + zLoaded + ';', loader: 'ts' };
});
build.onLoad({ filter: /[\\\\/]y\\.ts$/ }, ({ defer }) => {
queueMicrotask(() => void defer());
return { contents: 'import "./z.ts";', loader: 'ts' };
});
build.onLoad({ filter: /[\\\\/]z\\.ts$/ }, () => {
zLoaded = true;
return { contents: 'export const z = 1;', loader: 'ts' };
});
},
},
];
`,
files: {
"x.ts": `throw new Error('disk contents of x were bundled');`,
"y.ts": `throw new Error('disk contents of y were bundled');`,
"z.ts": `throw new Error('disk contents of z were bundled');`,
"routes/index.ts": `
import { zLoadedBeforeXResumed } from '../x.ts';
import '../y.ts';

export default function (req, meta) {
return new Response('z loaded before x resumed: ' + zLoadedBeforeXResumed);
}
`,
},
async test(dev) {
await dev.fetch("/").equals("z loaded before x resumed: true");
},
});
// devTest("onLoad with watchFile", {
// framework: minimalFramework,
// pluginFile: `
Expand Down
Loading
Loading