Skip to content
Open
25 changes: 19 additions & 6 deletions src/jsc/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1101,10 +1101,19 @@ 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` says so. It is asked
/// after each tick, before the poll that may park: what the tick ran may be what ends the wait.
/// `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()
Expand All @@ -1113,11 +1122,15 @@ impl EventLoop {
return Err(jsc::Stopped);
}
self.tick();
if promise.status() == PromiseStatus::Pending {
self.auto_tick();
if promise.status() != PromiseStatus::Pending {
break;
}
if give_up() {
return Ok(false);
}
self.auto_tick();
}
Ok(())
Ok(true)
}

pub fn wakeup(&self) {
Expand Down
45 changes: 45 additions & 0 deletions src/runtime/test_runner/Execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ 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>,
/// The innermost on-stack `bun_test::WaitingEntry` reached from a continuation (the runner
/// live): the entry (or concurrent group) whose timeout it will turn into the matcher's error.
/// While the claim names the entry that is due, the timeout timer leaves that deadline to it
/// (`defers_timeout_to_matcher_wait`).
Comment thread
robobun marked this conversation as resolved.
Resolved
pub(crate) matcher_wait_claim: core::cell::Cell<Option<super::bun_test::RefDataValue>>,
}

pub struct ConcurrentGroup {
Expand Down Expand Up @@ -282,6 +290,8 @@ 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),
matcher_wait_claim: core::cell::Cell::new(None),
}
}

Expand All @@ -304,6 +314,41 @@ impl Execution {
Ok(())
}

/// `Some(deadline)` when a matcher wait on the stack claims the entry whose timeout would be
/// handled here; that wait turns the timeout into its own error, so stepping the runner past the
/// entry would only turn the failure into an outdated one (`Unhandled error between tests`, and a
/// spurious error for a test that passes on retry). Decided by identity, never by the clock: this
/// callback's clock and `step`'s are two samples, and a timer that fires inside the gap between
/// them (macOS rounds timers to the millisecond) would pass a clock check here as "not due yet"
/// and still step past the entry in `step`.
Comment thread
robobun marked this conversation as resolved.
Resolved
pub(crate) fn defers_timeout_to_matcher_wait(&mut self) -> Option<Timespec> {
use super::bun_test::RefDataValue;
let claim = self.matcher_wait_claim.get()?;
let claimed_entry = match claim {
RefDataValue::Execution { group_index, entry_data: None } => {
if group_index != self.group_index {
return None;
}
let group = self.active_group_ref()?;
let mut executing = group.sequences(self).iter().filter(|sequence| sequence.executing);
let sequence = executing.next()?;
if executing.next().is_some() {
return None;
}
sequence.active_entry
}
data @ RefDataValue::Execution { entry_data: Some(_), .. } => self
.get_current_and_valid_execution_sequence(&data)
// SAFETY: the sequence points into `self.sequences`; deref at point-of-use.
.and_then(|(sequence, _)| unsafe { sequence.as_ref() }.active_entry),
_ => return None,
};
// SAFETY: arena-owned entry, alive for the lifetime of BunTest.
let deadline = unsafe { claimed_entry?.as_ref() }.timespec;
// An entry with no deadline defers nothing: there is no timeout to leave to the wait.
if deadline.eql(&Timespec::EPOCH) { None } else { Some(deadline) }
}

/// The kill-only half of [`handle_timeout`]: reaps a timed-out test's spawned processes without touching the runner's queue, so it may run from inside `spawnSync`'s isolated loop.
pub(crate) fn kill_dangling_processes_on_timeout(&mut self, global_this: &JSGlobalObject) {
// if the concurrent group has one sequence and the sequence has an active entry that has timed out,
Expand Down
219 changes: 218 additions & 1 deletion src/runtime/test_runner/bun_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,19 @@ impl BunTest {
match (*this).phase {
Phase::Collection => {}
Phase::Execution => {
if let Some(deadline) = (*this).execution.defers_timeout_to_matcher_wait() {
// The wait fails the entry itself at its deadline. Come back for the rest of
// the timeout handling once it has.
Comment thread
robobun marked this conversation as resolved.
Resolved
(*this).execution.kill_dangling_processes_on_timeout(global);
let now = Timespec::now_force_real_time();
let wake = if deadline.order(&now) == core::cmp::Ordering::Greater {
deadline
} else {
now.add_ms(1)
};
(*this).update_min_timeout(global, &wake);
return;
}
if let Err(e) = (*this).execution.handle_timeout(global) {
(*this).on_uncaught_exception(global, Some(global.take_exception(e)), false, &RefDataValue::Done);
}
Expand Down Expand Up @@ -1517,6 +1530,205 @@ 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,
/// `Some` when this wait pushed its claim into `Execution::matcher_wait_claim`: the next-outer
/// wait's claim, restored on drop.
Comment thread
robobun marked this conversation as resolved.
Resolved
shadowed_claim: Option<Option<RefDataValue>>,
}

/// Why a [`WaitingEntry`] gave up. The matcher's error names it.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum GaveUp {
/// The entry's deadline passed. Its sequence is already marked timed out, so the completion the
/// matcher's error produces reports it like a synchronous overrun does.
Comment thread
robobun marked this conversation as resolved.
Resolved
TimedOut,
/// The runner is past the entry (or the file) for another reason; nothing will settle the promise
/// on this entry's behalf any more.
Comment thread
robobun marked this conversation as resolved.
Resolved
TestEnded,
}

impl GaveUp {
pub(crate) fn when(self) -> &'static str {
match self {
GaveUp::TimedOut => "timed out",
GaveUp::TestEnded => "ended",
}
}
}

/// Every variant watches the clock rather than the file timer: the timer fires only from a poll that
/// wakes (and on Windows not from a tick nested inside a timer callback at all), and for the first two
/// it can do nothing anyway, `BunTest::run` (re-entrancy guarded) being blocked behind the callback
/// they hold on the stack.
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, the runner live. Done at the entry's deadline, which the timer
/// callback leaves to this wait (`Execution::matcher_wait_claim`) so that the error lands
/// on the entry; if the runner moves past the entry anyway, once the file is done.
Comment thread
robobun marked this conversation as resolved.
Resolved
Comment thread
robobun marked this conversation as resolved.
Resolved
Continuation(RefDataValue),
/// A continuation of some entry of this concurrent group, which one unknown: done once every entry
/// still executing in it has timed out (the timer callback leaves the last one's deadline to this
/// wait as above), or the group is over.
Comment thread
robobun marked this conversation as resolved.
Resolved
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::Continuation(data),
},
};
let shadowed_claim = match &ends_when {
EndsWhen::EntryTimedOut(_) | EndsWhen::GroupTimedOut => {
execution.blocking_matcher_waits.set(execution.blocking_matcher_waits.get() + 1);
None
}
EndsWhen::Continuation(data) => Some(execution.matcher_wait_claim.replace(Some(*data))),
EndsWhen::GroupOver(group_index) => Some(execution.matcher_wait_claim.replace(Some(
RefDataValue::Execution { group_index: *group_index, entry_data: None },
))),
};
Some(WaitingEntry { buntest, ends_when, shadowed_claim })
}

pub(crate) fn gave_up(&self, global_this: &JSGlobalObject) -> Option<GaveUp> {
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 Some(GaveUp::TimedOut);
}
self.wake_at(global_this, &entry.timespec);
None
}
EndsWhen::GroupTimedOut => {
let mut soonest = Timespec::EPOCH;
if self.every_executing_entry_timed_out(&now, &mut soonest) {
self.mark_executing_entries_timed_out(&now);
return Some(GaveUp::TimedOut);
}
self.wake_at(global_this, &soonest);
None
}
EndsWhen::Continuation(data) => {
let buntest = self.buntest.get();
if buntest.phase == Phase::Done {
return Some(GaveUp::TestEnded);
}
// `None`: the runner is past the entry, which completed without this wait (it was
// never awaited). Its promise may still settle while the file runs; the file's end
// bounds the wait.
Comment thread
robobun marked this conversation as resolved.
Resolved
let (sequence, _) = buntest.execution.get_current_and_valid_execution_sequence(data)?;
// SAFETY: `sequence` points into `execution.sequences` and its `active_entry` is a
// live arena node; both outlive this call, which re-enters nothing.
let sequence = unsafe { &mut *sequence.as_ptr() };
// SAFETY: as above.
let entry = unsafe { sequence.active_entry?.as_ref() };
if entry.evaluate_timeout(sequence, &now) {
return Some(GaveUp::TimedOut);
}
self.wake_at(global_this, &entry.timespec);
None
}
EndsWhen::GroupOver(group_index) => {
if *group_index != self.buntest.execution.group_index {
return Some(GaveUp::TestEnded);
}
let mut soonest = Timespec::EPOCH;
if self.every_executing_entry_timed_out(&now, &mut soonest) {
self.mark_executing_entries_timed_out(&now);
return Some(GaveUp::TimedOut);
}
self.wake_at(global_this, &soonest);
None
}
}
}

/// Whether every entry still executing in the active group is past its deadline; `soonest`
/// receives the earliest deadline of those that are not.
Comment thread
robobun marked this conversation as resolved.
Resolved
fn every_executing_entry_timed_out(&self, now: &Timespec, soonest: &mut Timespec) -> bool {
let execution = &self.buntest.execution;
execution.active_group_ref().is_none_or(|group| {
group
.sequences(execution)
.iter()
.filter(|sequence| sequence.executing)
.all(|sequence| {
sequence.active_entry.is_some_and(|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;
}
*soonest = soonest.min_ignore_epoch(entry.timespec);
false
})
})
})
}

/// What `evaluate_timeout` does when the runner re-steps the group, done ahead of the failure the
/// matcher's error is about to produce so that the completion reports the entry as timed out. The
/// runner advances the sequence when that completion reaches it.
Comment thread
robobun marked this conversation as resolved.
Resolved
fn mark_executing_entries_timed_out(&self, now: &Timespec) {
let execution = &mut self.buntest.get().execution;
let Some(group) = execution.active_group_ref() else { return };
let (start, end) = (group.sequence_start, group.sequence_end);
for sequence in &mut execution.sequences[start..end] {
if !sequence.executing {
continue;
}
let Some(entry) = sequence.active_entry else { continue };
// SAFETY: as above.
let _ = unsafe { entry.as_ref() }.evaluate_timeout(sequence, now);
}
}

/// 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);
}
}

impl Drop for WaitingEntry {
fn drop(&mut self) {
let execution = &self.buntest.execution;
match self.shadowed_claim {
Some(previous) => execution.matcher_wait_claim.set(previous),
None => execution
.blocking_matcher_waits
.set(execution.blocking_matcher_waits.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 +2143,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
Loading
Loading