Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cd04ba7
bun test: report a test that completes after its timeout as timed out
robobun Aug 17, 2026
6aa52dc
test: drain the fixture's stdout and include it in the snapshots
robobun Aug 17, 2026
f96ea1a
shorten the evaluate_timeout doc comment
robobun Aug 17, 2026
89f8407
bun test: judge a late completion when it arrives, not when it is pro…
robobun Aug 17, 2026
4400766
shorten the handle_callback_completed doc comment
robobun Aug 17, 2026
b19797f
Merge remote-tracking branch 'origin/main' into farm/6ecf5d6d/late-co…
robobun Oct 23, 2026
0967ad3
test(napi): give the bundle tests a budget for debug builds
robobun Oct 23, 2026
40edfad
test(napi): run the bundle tests serially instead of raising their ti…
robobun Oct 23, 2026
b899f69
test: cover a late hook and a late test.failing, widen one margin
robobun Oct 23, 2026
6ac7d25
Merge remote-tracking branch 'origin/main' into farm/6ecf5d6d/late-co…
robobun Oct 24, 2026
cc9d319
bun test: stamp a completion when it is queued and judge it in Execut…
robobun Oct 24, 2026
03e8a87
bun test: take a sequence's duration from the time its last entry fin…
robobun Oct 24, 2026
5d038d1
test: fixtures for completions that arrive after the timeout
robobun Oct 24, 2026
c1f2006
docs(test): the timeout is wall clock, also for code that blocks
robobun Oct 24, 2026
c2d6996
bun test: say when a sequence's end time can be before its start
robobun Oct 24, 2026
e6e668a
test: make the late-completion fixtures independent of how fast a tes…
robobun Oct 24, 2026
66dadfb
test: remove explicit timeouts that a debug build only met by passing…
robobun Oct 24, 2026
560f4c5
docs(test): a timeout does not interrupt running JavaScript, and test…
robobun Oct 24, 2026
22ae0f6
Merge remote-tracking branch 'origin/main' into farm/6ecf5d6d/late-co…
robobun Oct 24, 2026
8d3a6f2
bun test: shorten the comment on the duration clamp
robobun Oct 24, 2026
b0cb6f4
test(node:test): use module-scope imports in the late-completion fixt…
robobun Oct 24, 2026
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
2 changes: 2 additions & 0 deletions docs/test/parallel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ test.serial("resets the database", async () => {

Concurrent tests share a thread and a global; this is cooperative concurrency for I/O-bound tests, not extra CPU cores. `expect.assertions()` and other per-test global state need care under concurrency — see [Concurrent test execution](/test/index#concurrent-test-execution).

Each test's [timeout](/test/runtime-behavior#test-timeouts) counts wall-clock time from its own start. A concurrent test that blocks the thread, for example with `Bun.spawnSync()` or `execSync()`, also uses up the time of every other test that is running, and those tests can time out. Mark tests that block with `test.serial`.

## Splitting a suite across CI machines with `--shard`

```sh terminal icon="terminal"
Expand Down
4 changes: 4 additions & 0 deletions docs/test/runtime-behavior.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ TZ=America/New_York bun test

Each test has a default timeout of 5000ms (5 seconds). Tests that exceed it fail.

The timeout is wall-clock time from the moment the test starts. Bun does not interrupt JavaScript that is running. A test that blocks the thread past its timeout, for example with a busy loop or `Bun.sleepSync()`, runs until its callback returns, and then fails.

Time in which the test does not run also counts: a callback that an earlier test left behind, or a machine too busy to schedule the test process. Leave room between a timeout and the time the test needs.

### Global Timeout

Change the timeout globally with the `--timeout` flag:
Expand Down
2 changes: 1 addition & 1 deletion docs/test/writing-tests.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ test("wat", async () => {
}, 500); // test must run in <500ms
```

In `bun:test`, a timeout throws an uncatchable exception to force the test to stop running and fail. Bun also kills any child processes spawned in the test, so they don't linger as zombie processes.
In `bun:test`, a test that exceeds its timeout fails, and Bun starts the next test. Bun does not interrupt JavaScript that is running: a test that blocks the thread runs until its callback returns (see [Test Timeouts](/test/runtime-behavior#test-timeouts)). Bun also kills any child processes spawned in the test, so they don't linger as zombie processes.

The default timeout for each test is 5000ms (5 seconds) unless you override it with this timeout option or `jest.setTimeout()`.

Expand Down
50 changes: 38 additions & 12 deletions src/runtime/test_runner/Execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,19 +336,21 @@ impl Execution {
}
}

/// `arrived_at` is when `data` was queued (`BunTest::add_result`).
pub(crate) fn step(
buntest_strong: &BunTestPtr,
global_this: &JSGlobalObject,
data: &RefDataValue,
arrived_at: Timespec,
) -> JsResult<StepResult> {
let _g = group_begin!();
let buntest = buntest_strong.get();
let buntest_ptr = NonNull::from(&mut *buntest);
let this = &mut buntest.execution;
let mut now = Timespec::now_force_real_time();

match data {
RefDataValue::Start => {
let mut now = Timespec::now_force_real_time();
return step_group(buntest_strong, global_this, &mut now);
}
_ => {
Expand All @@ -371,10 +373,22 @@ impl Execution {
_ => unreachable!(),
};

// SAFETY: sequence_ptr points into this.sequences; valid while BunTest is alive.
debug_assert!(unsafe { sequence_ptr.as_ref() }.active_entry.is_some());
Execution::advance_sequence(buntest_ptr, sequence_ptr, group_ptr);
debug_assert!(!arrived_at.eql(&Timespec::EPOCH));
// SAFETY: sequence_ptr points into this.sequences; valid while BunTest is alive. This
// `&mut` is dead before `this` is touched again.
let sequence = unsafe { &mut *sequence_ptr.as_ptr() };
debug_assert!(sequence.active_entry.is_some());
// Judged by arrival: the timer cannot fire while a callback blocks. A verdict already set stands.
if sequence.result == Result::Pending
&& let Some(entry) = sequence.active_entry
{
// SAFETY: arena-owned entry, alive for lifetime of BunTest
let _ = unsafe { entry.as_ref() }.evaluate_timeout(sequence, &arrived_at, true);
Comment thread
robobun marked this conversation as resolved.
Resolved
}
Comment thread
robobun marked this conversation as resolved.
Resolved
Execution::advance_sequence(buntest_ptr, sequence_ptr, group_ptr, Some(arrived_at));

// A stale `now` is safe: nothing below compares it before `run_test_callback` refreshes it.
let mut now = arrived_at;
let sequence_result =
step_sequence(buntest_strong, global_this, group_ptr, sequence_index, &mut now)?;
match sequence_result {
Expand Down Expand Up @@ -500,10 +514,12 @@ impl Execution {

/// `sequence` / `group` are carried as `NonNull` (raw-pointer semantics) because they point into
/// `buntest.execution.{sequences,groups}` and would otherwise alias any live `&mut Execution`.
/// `ended_at`: when the active entry finished, if the caller has that time. `None` reads the clock.
fn advance_sequence(
buntest: NonNull<BunTest>,
sequence_ptr: NonNull<ExecutionSequence>,
group_ptr: NonNull<ConcurrentGroup>,
ended_at: Option<Timespec>,
) {
let _g = group_begin!();

Expand Down Expand Up @@ -553,7 +569,7 @@ impl Execution {
}

// Only report the final result after all retries/repeats are done
Execution::on_sequence_completed(buntest, sequence);
Execution::on_sequence_completed(buntest, sequence, ended_at);

// No more retries or repeats; mark sequence as complete
// SAFETY: group_ptr points into `buntest.execution.groups`, disjoint from `sequence`.
Expand Down Expand Up @@ -628,11 +644,21 @@ impl Execution {
}
}

fn on_sequence_completed(buntest: NonNull<BunTest>, sequence: &mut ExecutionSequence) {
fn on_sequence_completed(
buntest: NonNull<BunTest>,
sequence: &mut ExecutionSequence,
ended_at: Option<Timespec>,
) {
let elapsed_ns: u64 = if sequence.started_at.eql(&Timespec::EPOCH) {
0
} else {
sequence.started_at.since_now_force_real_time()
let ended_at = ended_at.unwrap_or_else(Timespec::now_force_real_time);
// Under `retry`, a completion queued by an earlier attempt can predate `started_at`; `duration` wraps then.
if ended_at.greater(&sequence.started_at) {
ended_at.duration(&sequence.started_at).ns()
} else {
0
Comment thread
robobun marked this conversation as resolved.
Resolved
}
};
match sequence.expect_assertions {
ExpectAssertions::NotSet => {}
Expand Down Expand Up @@ -978,8 +1004,8 @@ fn step_sequence_one(
};
// SAFETY: arena-owned entry
let active_entry = unsafe { &mut *active_entry_ptr.as_ptr() };
if active_entry.evaluate_timeout(sequence, now) {
Execution::advance_sequence(buntest_ptr, sequence_ptr, group);
if active_entry.evaluate_timeout(sequence, now, false) {
Execution::advance_sequence(buntest_ptr, sequence_ptr, group, None);
return Ok(None); // run again
}
group_log::log(format_args!("runOne: can't advance; already executing"));
Expand Down Expand Up @@ -1042,10 +1068,10 @@ fn step_sequence_one(
// SAFETY: re-deref after run_test_callback; sequence_ptr still valid (sequences is a
// Box<[ExecutionSequence]>, never reallocated during execution).
let sequence = unsafe { &mut *sequence_ptr.as_ptr() };
let _ = next_item.evaluate_timeout(sequence, now);
let _ = next_item.evaluate_timeout(sequence, now, true);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

// the result is available immediately; advance the sequence and run again.
Execution::advance_sequence(buntest_ptr, sequence_ptr, group);
Execution::advance_sequence(buntest_ptr, sequence_ptr, group, Some(*now));
return Ok(None); // run again
}
return Ok(Some(AdvanceSequenceStatus::Execute {
Expand Down Expand Up @@ -1079,7 +1105,7 @@ fn step_sequence_one(
debug_assert!(false);
}
}
Execution::advance_sequence(buntest_ptr, sequence_ptr, group);
Execution::advance_sequence(buntest_ptr, sequence_ptr, group, None);
return Ok(None); // run again
}
}
28 changes: 22 additions & 6 deletions src/runtime/test_runner/bun_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -900,7 +900,12 @@ impl BunTest {
}

pub(crate) fn add_result(&mut self, result: RefDataValue) {
let _ = self.result_queue.write_item(result); // OOM/capacity: fire-and-forget
// Stamped here, not read in `Execution::step`: other callbacks may block before the queue is drained.
let arrived_at = match result {
RefDataValue::Execution { entry_data: Some(_), .. } => Timespec::now_force_real_time(),
_ => Timespec::EPOCH,
};
let _ = self.result_queue.write_item(QueuedResult { data: result, arrived_at }); // OOM/capacity: fire-and-forget
}

pub(crate) fn run(this_strong: &BunTestPtr, global_this: &JSGlobalObject) -> JsResult<()> {
Expand Down Expand Up @@ -935,8 +940,8 @@ impl BunTest {
global_this.clear_termination_exception();
// SAFETY: `UnsafeCell`-derived `*mut`; short-lived field read between re-entrant calls.
let step_result: StepResult = match unsafe { (*this).phase } {
Phase::Collection => Collection::step(this_strong, global_this, &result)?,
Phase::Execution => Execution::Execution::step(this_strong, global_this, &result)?,
Phase::Collection => Collection::step(this_strong, global_this, &result.data)?,
Phase::Execution => Execution::Execution::step(this_strong, global_this, &result.data, result.arrived_at)?,
Phase::Done => StepResult::Complete,
};
match step_result {
Expand Down Expand Up @@ -1552,7 +1557,15 @@ pub enum HandleUncaughtExceptionResult {
ShowUnhandledErrorInDescribe,
}

pub(crate) type ResultQueue = LinearFifo<RefDataValue, bun_collections::linear_fifo::DynamicBuffer<RefDataValue>>;
#[derive(Clone, Copy)]
pub(crate) struct QueuedResult {
pub(crate) data: RefDataValue,
/// When `add_result` ran. `EPOCH` unless `data` is the completion of an entry.
pub(crate) arrived_at: Timespec,
}
const _: () = assert!(core::mem::size_of::<QueuedResult>() <= 56);

pub(crate) type ResultQueue = LinearFifo<QueuedResult, bun_collections::linear_fifo::DynamicBuffer<QueuedResult>>;
// bun.LinearFifo(.Dynamic) → second generic is the buffer strategy.

pub(crate) enum StepResult {
Expand Down Expand Up @@ -1928,24 +1941,27 @@ impl ExecutionEntry {
entry
}

/// `callback_completed`: the callback already finished, so the missing-`done()` hint does not apply.
pub(crate) fn evaluate_timeout(
&self,
sequence: &mut Execution::ExecutionSequence,
now: &Timespec,
callback_completed: bool,
) -> bool {
if !self.timespec.eql(&Timespec::EPOCH) && self.timespec.order(now) == core::cmp::Ordering::Less {
// timed out
// SAFETY: pointer-identity comparison only — no deref, no provenance laundering.
let is_test_entry = sequence
.test_entry
.is_some_and(|p| core::ptr::eq(p.as_ptr().cast_const(), self));
let waiting_for_done = self.has_done_parameter && !callback_completed;
sequence.result = if is_test_entry {
if self.has_done_parameter {
if waiting_for_done {
Execution::Result::FailBecauseTimeoutWithDoneCallback
} else {
Execution::Result::FailBecauseTimeout
}
} else if self.has_done_parameter {
} else if waiting_for_done {
Execution::Result::FailBecauseHookTimeoutWithDoneCallback
} else {
Execution::Result::FailBecauseHookTimeout
Expand Down
2 changes: 1 addition & 1 deletion test/cli/test/process-kill-fixture-sync.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion test/cli/test/process-kill-fixture.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading