Skip to content
Open
19 changes: 15 additions & 4 deletions src/jsc/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1101,23 +1101,34 @@ impl EventLoop {
/// `JsResult` function crosses explicitly with [`jsc::Stopped::throw`] (which, with
/// the termination already pending, is just `Thrown`).
pub fn wait_for_promise(&mut self, promise: jsc::AnyPromise) -> Result<(), jsc::Stopped> {
self.wait_for_promise_or_give_up(promise, || false)
.map(|_settled| ())
}

/// [`Self::wait_for_promise`] that also returns `Ok(false)` once `give_up`,
/// asked before each tick, says so; `Ok(true)` means the promise settled.
Comment thread
robobun marked this conversation as resolved.
Resolved
pub fn wait_for_promise_or_give_up(
&mut self,
promise: jsc::AnyPromise,
mut give_up: impl FnMut() -> bool,
) -> Result<bool, jsc::Stopped> {
let jsc_vm = self.vm_ref().jsc_vm();
if promise.status() != PromiseStatus::Pending {
return Ok(());
}
while promise.status() == PromiseStatus::Pending {
if jsc_vm.execution_forbidden()
|| !self.vm_ref().script_allowed()
|| self.global_ref().has_pending_termination_exception()
{
return Err(jsc::Stopped);
}
if give_up() {
return Ok(false);
}
self.tick();
if promise.status() == PromiseStatus::Pending {
self.auto_tick();
}
}
Ok(())
Ok(true)
}

pub fn wakeup(&self) {
Expand Down
4 changes: 4 additions & 0 deletions src/runtime/test_runner/Execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ pub struct Execution {
/// `t.skip()`/`t.todo()` mark lands there before the DoneCallback is
/// stamped).
pub(crate) on_stack_entry_data: core::cell::Cell<Option<super::bun_test::EntryData>>,
/// `bun_test::WaitingEntry`s on the stack holding `on_stack_entry`'s callback blocked,
/// so that while non-zero, JS reaching a matcher is a continuation, not that callback.
Comment thread
robobun marked this conversation as resolved.
Resolved
pub(crate) blocking_matcher_waits: core::cell::Cell<u32>,
}

pub struct ConcurrentGroup {
Expand Down Expand Up @@ -282,6 +285,7 @@ impl Execution {
group_index: 0,
on_stack_entry: core::cell::Cell::new(None),
on_stack_entry_data: core::cell::Cell::new(None),
blocking_matcher_waits: core::cell::Cell::new(0),
}
}

Expand Down
173 changes: 172 additions & 1 deletion src/runtime/test_runner/bun_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1517,6 +1517,172 @@ impl RefData {
}
}

/// What ends a matcher's wait for a promise (`.resolves`, `toThrow()` on an async function, an async
/// `expect.extend` matcher) short of it settling; polled between the wait's ticks, since a promise
/// that never settles has to fail the entry with its timeout. Attributed the way `expect()` itself is.
Comment thread
robobun marked this conversation as resolved.
Resolved
pub(crate) struct WaitingEntry {
buntest: BunTestPtr,
ends_when: EndsWhen,
}

/// The first two hold a callback on the stack, which `BunTest::run` (re-entrancy guarded) cannot give
/// up, so they watch deadlines themselves; the others give up once the runner has moved past the
/// entry, and step the runner's timeout handling themselves when its deadline has passed. Every
/// variant watches the clock: the file timer may not fire while the wait ticks (it fires only from a
/// poll that wakes, and on Windows not from a tick nested inside a timer callback at all).
Comment thread
robobun marked this conversation as resolved.
Resolved
enum EndsWhen {
/// The on-stack callback's own code; once the matcher throws, the runner evaluates its overrun as usual.
EntryTimedOut(NonNull<ExecutionEntry>),
/// The on-stack callback is itself blocked in a matcher, so this is some continuation of the group,
/// which one unknown: done once every entry still executing in it has timed out.
Comment thread
robobun marked this conversation as resolved.
Resolved
GroupTimedOut,
/// A continuation of this entry: done once [`RefDataValue::entry`] no longer finds it (at once after `Done`).
RunnerGaveEntryUp(RefDataValue),
/// A continuation of some entry of this concurrent group.
GroupOver(usize),
}

impl WaitingEntry {
/// `None` outside a test file's execution phase (plain scripts, preloads, describe callbacks): no timeouts there.
pub(crate) fn current() -> Option<Self> {
let buntest = clone_active_strong()?;
let execution = &buntest.execution;
let ends_when = match execution.on_stack_entry.get() {
Some(entry) if execution.blocking_matcher_waits.get() == 0 => EndsWhen::EntryTimedOut(entry),
Some(_) => EndsWhen::GroupTimedOut,
None => match buntest.get_current_state_data() {
RefDataValue::Execution { group_index, entry_data: None } => EndsWhen::GroupOver(group_index),
RefDataValue::Collection { .. } => return None,
data @ (RefDataValue::Execution { entry_data: Some(_), .. }
| RefDataValue::Done
| RefDataValue::Start) => EndsWhen::RunnerGaveEntryUp(data),
},
};
let waiting = WaitingEntry { buntest, ends_when };
if waiting.blocks_callback() {
let counter = &waiting.buntest.execution.blocking_matcher_waits;
counter.set(counter.get() + 1);
}
Some(waiting)
}

/// Whether this wait holds `Execution::on_stack_entry`'s callback blocked underneath it.
fn blocks_callback(&self) -> bool {
matches!(self.ends_when, EndsWhen::EntryTimedOut(_) | EndsWhen::GroupTimedOut)
}

pub(crate) fn timed_out(&self, global_this: &JSGlobalObject) -> bool {
let now = Timespec::now_force_real_time();
match &self.ends_when {
EndsWhen::EntryTimedOut(entry) => {
// SAFETY: entries live as long as the BunTest, which `self.buntest` keeps alive.
let entry = unsafe { entry.as_ref() };
if entry.has_timed_out(&now) {
return true;
}
self.wake_at(global_this, &entry.timespec);
false
}
EndsWhen::GroupTimedOut => {
let buntest = self.buntest.get();
let mut soonest = Timespec::EPOCH;
let group_timed_out = buntest.execution.active_group_ref().is_none_or(|group| {
group
.sequences(&buntest.execution)
.iter()
.filter(|sequence| sequence.executing)
.all(|sequence| {
sequence.active_entry.is_some_and(|entry| {
// SAFETY: as above.
let entry = unsafe { entry.as_ref() };
if entry.has_timed_out(&now) {
return true;
}
soonest = soonest.min_ignore_epoch(entry.timespec);
false
})
})
});
if group_timed_out {
// What the timer's `handle_timeout` queues (it may not have fired yet), ahead of the
// failure the matcher's error is about to produce, so the entry reports as timed out.
Comment thread
robobun marked this conversation as resolved.
Resolved
buntest.add_result(RefDataValue::Start);
return true;
}
self.wake_at(global_this, &soonest);
false
}
EndsWhen::RunnerGaveEntryUp(data) => {
let Some(entry) = data.entry(self.buntest.get()) else {
return true;
};
if !entry.has_timed_out(&now) {
return false;
}
self.step_timed_out_entries(global_this);
data.entry(self.buntest.get()).is_none()
}
EndsWhen::GroupOver(group_index) => {
let buntest = self.buntest.get();
if *group_index != buntest.execution.group_index {
return true;
}
let any_timed_out = buntest.execution.active_group_ref().is_some_and(|group| {
group
.sequences(&buntest.execution)
.iter()
.filter(|sequence| sequence.executing)
.any(|sequence| {
sequence
.active_entry
// SAFETY: as above.
.is_some_and(|entry| unsafe { entry.as_ref() }.has_timed_out(&now))
})
});
if any_timed_out {
self.step_timed_out_entries(global_this);
}
*group_index != self.buntest.execution.group_index
}
}
}

/// The wait's next poll must wake by `deadline`. The file timer is armed for it when the entry
/// starts, but an earlier entry's timer firing underneath this wait disarms it, and `BunTest::run`,
/// which re-arms, is blocked behind the callback this wait holds on the stack.
Comment thread
robobun marked this conversation as resolved.
Resolved
fn wake_at(&self, global_this: &JSGlobalObject, deadline: &Timespec) {
if deadline.eql(&Timespec::EPOCH) {
return;
}
self.buntest.get().update_min_timeout(global_this, deadline);
}

/// What `bun_test_timeout_callback` does for an entry the runner is not blocked on: report the
/// timeout and step on (nested in this wait). The timer does the same when it fires first.
Comment thread
robobun marked this conversation as resolved.
Resolved
fn step_timed_out_entries(&self, global_this: &JSGlobalObject) {
// Both calls re-enter this BunTest: no `&mut` is held across them (see `BunTestCell::get`).
if let Err(e) = self.buntest.get().execution.handle_timeout(global_this) {
self.buntest
.get()
.on_uncaught_exception(global_this, Some(global_this.take_exception(e)), false, &RefDataValue::Done);
}
if let Err(e) = BunTest::run(&self.buntest, global_this) {
self.buntest
.get()
.on_uncaught_exception(global_this, Some(global_this.take_exception(e)), false, &RefDataValue::Done);
}
}
}

impl Drop for WaitingEntry {
fn drop(&mut self) {
if self.blocks_callback() {
let counter = &self.buntest.execution.blocking_matcher_waits;
counter.set(counter.get() - 1);
}
}
}

pub struct RunTestsTask {
pub(crate) weak: BunTestPtrWeak,
// `GlobalRef` (not a borrow): the JSGlobalObject is stored across the task
Expand Down Expand Up @@ -1931,12 +2097,17 @@ impl ExecutionEntry {
entry
}

/// Never true for an entry running without a timeout.
pub(crate) fn has_timed_out(&self, now: &Timespec) -> bool {
!self.timespec.eql(&Timespec::EPOCH) && self.timespec.order(now) == core::cmp::Ordering::Less
}

pub(crate) fn evaluate_timeout(
&self,
sequence: &mut Execution::ExecutionSequence,
now: &Timespec,
) -> bool {
if !self.timespec.eql(&Timespec::EPOCH) && self.timespec.order(now) == core::cmp::Ordering::Less {
if self.has_timed_out(now) {
// timed out
// SAFETY: pointer-identity comparison only — no deref, no provenance laundering.
let is_test_entry = sequence
Expand Down
61 changes: 46 additions & 15 deletions src/runtime/test_runner/expect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@ impl Expect {
)
}

/// Shared failure path for the three `.resolves`/`.rejects` mismatch cases
/// Shared failure path for the `.resolves`/`.rejects` failure cases
/// in `process_promise`. The body template's only markup is `<red>…<r>`
/// around `received`, so it's applied here; `expected`/`label`/`received`
/// are emitted verbatim.
Expand Down Expand Up @@ -471,6 +471,20 @@ impl Expect {
}
}

/// `VirtualMachine::wait_for_promise` that returns `false` instead once the entry the matcher runs
/// in is over ([`bun_test::WaitingEntry`]), so a promise that never settles fails the test instead
/// of hanging `bun test`. Throws only for a VM stop.
Comment thread
robobun marked this conversation as resolved.
Resolved
fn wait_for_promise(global_this: &JSGlobalObject, promise: bun_jsc::AnyPromise) -> JsResult<bool> {
let waiting_entry = bun_test::WaitingEntry::current();
global_this
.bun_vm()
.event_loop_mut()
.wait_for_promise_or_give_up(promise, || {
waiting_entry.as_ref().is_some_and(|waiting| waiting.timed_out(global_this))
})
.map_err(|stopped| stopped.throw(global_this))
}

/// Processes the async flags (resolves/rejects), waiting for the async value if needed.
/// If no flags, returns the original value
/// If either flag is set, waits for the result, and returns either it as a JSValue, or null if the expectation failed (in which case if silent is false, also throws a js exception)
Expand All @@ -489,12 +503,22 @@ impl Expect {
let vm = global_this.vm();
promise.set_handled(vm);

// SAFETY: bun_vm() returns the live thread-local VirtualMachine.
global_this
.bun_vm()
.as_mut()
.wait_for_promise(promise)
.map_err(|stopped| stopped.throw(global_this))?;
if !Self::wait_for_promise(global_this, promise)? {
if !silent {
let mut formatter = ConsoleObject::Formatter::new(global_this).with_quote_strings(true);
return Err(Self::throw_promise_matcher_error(
global_this, custom_label, matcher_name, matcher_params, flags,
if resolution == Promise::Resolves {
"Expected promise that resolves"
} else {
"Expected promise that rejects"
},
"Received promise that was still pending when the test timed out: ",
value.to_fmt(&mut formatter),
));
}
return Err(JsError::Thrown);
}

let new_value = promise.result(vm);
match promise.status() {
Expand Down Expand Up @@ -869,9 +893,16 @@ impl Expect {
}

if let Some(promise) = return_value.as_any_promise() {
let waited = vm.wait_for_promise(promise);
// As in `process_promise`: the matcher owns this promise's outcome, including a rejection
// that arrives after the wait gave up on it.
Comment thread
robobun marked this conversation as resolved.
Resolved
promise.set_handled(global_this.vm());
let settled = Self::wait_for_promise(global_this, promise);
scope.apply(vm);
waited.map_err(|stopped| stopped.throw(global_this))?;
if !settled? {
return Err(global_this.throw(format_args!(
"Received function returned a promise that was still pending when the test timed out"
)));
}
match promise.unwrap(global_this.vm(), js_promise::UnwrapMode::MarkHandled) {
js_promise::Unwrapped::Fulfilled(_) => {
return Ok((None, return_value_from_function));
Expand Down Expand Up @@ -1458,12 +1489,12 @@ impl Expect {
let vm = global_this.vm();
promise.set_handled(vm);

// SAFETY: bun_vm() returns the live thread-local VirtualMachine.
global_this
.bun_vm()
.as_mut()
.wait_for_promise(promise)
.map_err(|stopped| stopped.throw(global_this))?;
if !Self::wait_for_promise(global_this, promise)? {
return Err(global_this.throw(format_args!(
"Matcher `{}` returned a promise that was still pending when the test timed out",
matcher_name,
)));
}

result = promise.result(vm);
result.ensure_still_alive();
Expand Down
Loading
Loading