Repository navigation
bun test: Infinity means no timeout in test(), hooks, setDefaultTimeout() and jest.setTimeout()
#48225
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
bun test: Infinity means no timeout in test(), hooks, setDefaultTimeout() and jest.setTimeout()
#48225
Changes from all commits
3736c36
f2b6eb6
4d21baa
a53dd37
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2539,8 +2539,8 @@ function bunTestOptions(options: TestOptions) { | |
| // about timeouts that extend past its 5s default. | ||
| const { timeout } = options; | ||
| if (timeout === Infinity) { | ||
| // Node's "no timeout" must override bun:test's default (bun saturates it). | ||
| return { timeout }; | ||
| // Node waits without end. bun:test gets the longest timeout, because it fails a stuck test that has none. | ||
| return { timeout: kTimeoutMax }; | ||
|
Comment on lines
+2542
to
+2543
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
| if (typeof timeout === "number" && Number.isFinite(timeout)) { | ||
| // Keep bun:test's watchdog at or above both the node-style timeout and | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -110,6 +110,8 @@ pub(crate) struct ConcurrentGroup { | |
| pub(crate) failure_skip_to: usize, | ||
| /// Like `ExecutionSequence::pending_matchers`, for matchers whose calling sequence isn't known. | ||
| pub(crate) pending_matchers: u32, | ||
| /// A test with no timeout was running when one of `pending_matchers` was called, so they have no deadline. | ||
| pub(crate) pending_matchers_have_no_deadline: bool, | ||
| } | ||
|
|
||
| impl ConcurrentGroup { | ||
|
|
@@ -122,6 +124,7 @@ impl ConcurrentGroup { | |
| failure_skip_to: next_index, | ||
| next_sequence_index: 0, | ||
| pending_matchers: 0, | ||
| pending_matchers_have_no_deadline: false, | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -217,6 +220,14 @@ impl ExecutionSequence { | |
| self.callback_done | ||
| } | ||
|
|
||
| /// The callback of its test has started, and the test has not ended. | ||
| pub(crate) fn runs_test_with_no_timeout(&self) -> bool { | ||
| self.executing | ||
| && self.active_entry == self.test_entry | ||
| // SAFETY: arena-owned entry, alive for the lifetime of BunTest which owns Execution | ||
| && self.test_entry.is_some_and(|entry| unsafe { entry.as_ref() }.timeout == 0) | ||
|
Comment on lines
+224
to
+228
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
Comment on lines
+224
to
+229
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 pre-existing, not blocking: nit: Users whose concurrent-group hook has timeout 0 or Infinity still get its un-awaited matcher ended at a sibling test's deadline, unlike the test case this PR fixes. runs_test_with_no_timeout at src/runtime/test_runner/Execution.rs:226 requires active_entry == test_entry, so a running beforeEach/afterEach/around hook with timeout 0 never sets pending_matchers_have_no_deadline. Fix: treat any executing entry whose timeout is 0 as a possible caller, e.g. check the active entry's timeout instead of the test entry's, while keeping EPOCH of a not-started entry meaning "not set" (gate on executing). Why this was flaggedA file has describe.concurrent with test.concurrent("a", fn, 100) and test.concurrent("b", fn, 100), and afterEach(async () => { await later(); expect(later(1, 500)).resolves.toBe(1); }, 0). After the await, execution.on_stack is None, so RunningEntry::of (src/runtime/test_runner/expect_deferred.rs:122-146) attributes the matcher to the group and hold() at expect_deferred.rs:227-233 asks sequences.iter().any(ExecutionSequence::runs_test_with_no_timeout). For the hook's sequence active_entry is the hook, not test_entry, so Execution.rs:226 returns false and pending_matchers_have_no_deadline stays false. step_group at Execution.rs:1045-1059 then folds the tests' 100 ms deadlines and calls group_timed_out at Execution.rs:1059, reporting "An un-awaited matcher in a concurrent test timed out because its promise never settled" although the caller has no timeout. This equals the base branch's behaviour for a hook with 0; the PR's new rule at Execution.rs:1045 only covers test entries. Verification: runs_test_with_no_timeout at Execution.rs:224-229 requires self.active_entry == self.test_entry. While a hook runs, active_entry is the hook entry, so a hook with timeout == 0 never sets the flag. step_group folds only test_entry timespecs, so group_timed_out reports even though the hook has no timeout. On the base branch the same fold ran unconditionally, so the hook outcome is unchanged. |
||
|
|
||
| fn entry_mode(&self) -> ScopeMode { | ||
| if let Some(entry) = self.test_entry { | ||
| // SAFETY: arena-owned entry, alive for the lifetime of BunTest which owns Execution | ||
|
|
@@ -453,7 +464,10 @@ impl Execution { | |
| // SAFETY: arena-owned entry, alive for lifetime of BunTest | ||
| let entry = unsafe { entry.as_ref() }; | ||
| let now = Timespec::now_force_real_time(); | ||
| if entry.timespec.order(&now) == core::cmp::Ordering::Less { | ||
| // A stale file timer can fire while an unlimited entry is active. | ||
| if !entry.timespec.eql(&Timespec::EPOCH) | ||
| && entry.timespec.order(&now) == core::cmp::Ordering::Less | ||
| { | ||
| // SAFETY: bun_vm() returns the live per-thread VM. | ||
| let kill_count = global_this.bun_vm().as_mut().auto_killer.kill(); | ||
| if kill_count.processes > 0 { | ||
|
|
@@ -1028,12 +1042,16 @@ fn step_group( | |
| let group = unsafe { &mut *group_ptr.as_ptr() }; | ||
| if group.pending_matchers > 0 { | ||
| // Any test in the group may have called them, so wait until the latest test deadline. | ||
| let timeout = this.sequences[group.sequence_start..group.sequence_end] | ||
| .iter() | ||
| .filter_map(|sequence| sequence.test_entry) | ||
| // SAFETY: arena-owned entry | ||
| .map(|entry| unsafe { entry.as_ref() }.timespec) | ||
| .fold(Timespec::EPOCH, |latest, timespec| if timespec.order(&latest).is_gt() { timespec } else { latest }); | ||
| let timeout = if group.pending_matchers_have_no_deadline { | ||
| Timespec::EPOCH | ||
| } else { | ||
| this.sequences[group.sequence_start..group.sequence_end] | ||
| .iter() | ||
| .filter_map(|sequence| sequence.test_entry) | ||
| // SAFETY: arena-owned entry | ||
| .map(|entry| unsafe { entry.as_ref() }.timespec) | ||
| .fold(Timespec::EPOCH, |latest, timespec| if timespec.order(&latest).is_gt() { timespec } else { latest }) | ||
| }; | ||
| if timeout.eql(&Timespec::EPOCH) || timeout.order(now).is_gt() { | ||
| return Ok(StepResult::Waiting { timeout }); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ use core::ptr::NonNull; | |
| use bun_core::{Timespec, TimespecMockMode}; | ||
| use bun_jsc::bindgen::{JsLocal, JsScope, JsWrapper}; | ||
| use bun_jsc::call_frame::CallerSrcLoc; | ||
| use bun_jsc::event_loop::TopLevelWaitError; | ||
| use bun_jsc::js_promise::{Status, UnwrapMode, Unwrapped}; | ||
| use bun_jsc::{ | ||
| AnyPromise, JSFunction, JSGlobalObject, JSHostFn, JSPromise, JSValue, JsCell, | ||
|
|
@@ -190,6 +191,14 @@ impl RunningEntry { | |
| .is_some_and(|buntest| self.pending_matchers(&buntest).is_some()) | ||
| } | ||
|
|
||
| /// The concurrent group that holds the call, when no sequence of it does. | ||
| fn group_index(&self) -> Option<usize> { | ||
| match self.entry { | ||
| RefDataValue::Execution { group_index, entry_data: None } => Some(group_index), | ||
| _ => None, | ||
| } | ||
| } | ||
|
|
||
| /// Checks the clock directly, because the runner only notices a timeout after the microtask queue drains. | ||
| fn has_timed_out(&self) -> bool { | ||
| let Some(buntest) = self.buntest.upgrade() else { | ||
|
|
@@ -215,6 +224,13 @@ impl RunningEntry { | |
| // SAFETY: as in `pending_matchers`. | ||
| unsafe { *pending_matchers.as_ptr() += 1 }; | ||
| self.unclaimed = Some(buntest.get().unclaimed.add(global, deferred)); | ||
| if let Some(group_index) = self.group_index() { | ||
| let execution = &mut buntest.get().execution; | ||
| let sequences = execution.groups[group_index].sequences(execution); | ||
| // Such a test may be the caller, and the deadline of another test must not end its wait. | ||
| let may_have_no_timeout = sequences.iter().any(ExecutionSequence::runs_test_with_no_timeout); | ||
| execution.groups[group_index].pending_matchers_have_no_deadline |= may_have_no_timeout; | ||
| } | ||
| } | ||
| self | ||
| } | ||
|
|
@@ -243,6 +259,9 @@ impl RunningEntry { | |
| *pending_matchers.as_ptr() -= 1; | ||
| *pending_matchers.as_ptr() == 0 | ||
| }; | ||
| if let (true, Some(group_index)) = (none_left, self.group_index()) { | ||
| buntest.get().execution.groups[group_index].pending_matchers_have_no_deadline = false; | ||
| } | ||
|
Comment on lines
+262
to
+264
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| let next = match self.sequence(&buntest) { | ||
| Some(sequence) => { | ||
| // SAFETY: as in `pending_matchers`. | ||
|
|
@@ -835,6 +854,33 @@ impl ExpectDeferred { | |
| ); | ||
| } | ||
|
|
||
| /// With nothing left on the event loop, gives up the calls that are all the active group still waits for, if any. | ||
| pub(crate) fn group_never_settles(buntest: &BunTestPtr, global: &JSGlobalObject) -> bool { | ||
| let this = buntest.get(); | ||
| if this.phase != Phase::Execution { | ||
| return false; | ||
| } | ||
| let group_index = this.execution.group_index; | ||
| let Some(group) = this.execution.groups.get_mut(group_index) else { | ||
| return false; | ||
| }; | ||
| if group.remaining_incomplete_entries > 0 || group.pending_matchers == 0 { | ||
| return false; | ||
| } | ||
|
Comment on lines
+867
to
+869
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 pre-existing, not blocking: pre-existing: A concurrent test with no timeout that calls an un-awaited matcher before its first await still abandons the whole file when the loop empties, instead of the call-site report the after-await case now gets. Such a matcher is sequence-held, so group_never_settles returns false at src/runtime/test_runner/expect_deferred.rs:867 (remaining_incomplete_entries is 1), and test_command.rs:4134 breaks out of the file with "A test or hook with no timeout never settled"; later tests and afterAll never run and the stuck test is not counted as failed. Fix: when nothing is left, also give up the matchers held by sequences of the active concurrent group whose callback_done is set, ending those entries, so both registration points behave the same. Why this was flaggedTrigger: a file with test.concurrent("a", () => { expect(new Promise(() => {})).resolves.toBe(1); }, 0) (or Infinity), another concurrent sibling, then more tests or an afterAll. The matcher is called while the callback is on the stack, so RunningEntry::of at src/runtime/test_runner/expect_deferred.rs:128-135 takes execution.on_stack and holds the sequence, not the group; step_sequence_one at src/runtime/test_runner/Execution.rs:1377 then returns Execute { timeout: EPOCH } with the sequence still incomplete. When the event loop is empty, ExpectDeferred::group_never_settles at expect_deferred.rs:867 sees remaining_incomplete_entries > 0 and returns false. TestCommand::run at src/runtime/cli/test_command.rs:4121 asks what_runner_waits_for, which returns ("A test or hook with no timeout", None), reports it as an unhandled error between tests, and breaks at test_command.rs:4134-4137: the rest of the file, including afterAll, is skipped and the test is not reported as failed. The same matcher placed after an await is group-held and is reported at its call site with the file continuing. The base branch behaves the same for timeout 0 and hung for Infinity. Verification: With the loop empty, group_never_settles (expect_deferred.rs:867) returns false because group.remaining_incomplete_entries > 0. Back in test_command.rs:4134 waits_for.is_none() is true and the loop breaks: the file is abandoned, later tests and afterAll never run, and the error is "A test or hook with no timeout never settled" rather than the matcher's call site. No new test covers it. |
||
| group.pending_matchers = 0; | ||
| Self::give_up( | ||
| buntest, | ||
| global, | ||
| Some(group_index), | ||
| format_args!( | ||
| "An un-awaited matcher in a concurrent test never settled\nnote: {}", | ||
| TopLevelWaitError::NothingLeft | ||
| ), | ||
| ); | ||
| buntest.get().add_result(RefDataValue::Start); | ||
| true | ||
| } | ||
|
|
||
| /// After a file's tests have run, keeps the file open until the calls it holds finish or time out. | ||
| pub(crate) fn step_file_end( | ||
| buntest: &BunTestPtr, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -172,10 +172,15 @@ impl<'a> TestRunner<'a> { | |
| if let Some(group) = active_file.execution.active_group_ref() { | ||
| let mut latest: Option<bun_core::Timespec> = None; | ||
| for seq in group.sequences_const(&active_file.execution) { | ||
| // Not started yet: its `EPOCH` is "not set", not "no timeout". | ||
| if !seq.executing { | ||
| continue; | ||
| } | ||
| let Some(entry) = seq.active_entry else { continue }; | ||
| // SAFETY: arena-owned entry, alive for the lifetime of BunTest. | ||
| let ts = unsafe { entry.as_ref() }.timespec; | ||
| if latest.is_none_or(|l| ts.order(&l) == core::cmp::Ordering::Greater) { | ||
| // A running entry with no timeout (`EPOCH`) is the latest of all. | ||
| if latest.is_none_or(|l| bun_core::Timespec::order_ignore_epoch(ts, l).is_gt()) { | ||
| latest = Some(ts); | ||
| } | ||
|
Comment on lines
+182
to
185
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 A timed concurrent test that blocks in Why this was flaggedA concurrent group has test.concurrent("stuck child", async () => { await immediate(); Bun.spawnSync(["sleep", "inf"]); }, 300) and test.concurrent("no timeout", () => new Promise(r => setTimeout(r, 10_000)), 0). After the await, on_stack is None, so get_active_timeout at src/runtime/test_runner/jest.rs:171-191 scans the running sequences. The 0-timeout entry has timespec EPOCH (Execution.rs:793) and seq.executing is true, so the new comparison at jest.rs:183 makes latest = EPOCH and the function returns EPOCH. The spawnSync wait loop treats EPOCH as no deadline and blocks the JS thread until the child exits; the child never does. On the base branch, Verification: src/bun_core/util.rs:4871-4881:
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No change here. This is question 1 in the PR notes, and it needs a maintainer. After an
|
||
| } | ||
|
|
@@ -613,10 +618,9 @@ pub(crate) fn asked_by_compiled_test_file(own_globals: bool) -> u32 { | |
|
|
||
| /// `setDefaultTimeout(milliseconds)` and `jest.setTimeout(milliseconds)` | ||
| pub(crate) fn set_default_timeout(milliseconds: f64) { | ||
| let timeout_ms: u32 = u32::try_from((milliseconds as i32).max(0)).unwrap(); | ||
|
|
||
| if let Some(test_runner) = Jest::runner() { | ||
| test_runner.default_timeout_override = timeout_ms; | ||
| // Never `u32::MAX`, which means that no override is set. | ||
| test_runner.default_timeout_override = bun_test::timeout_ms(milliseconds); | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 nit (optional): Users who pass the new 'no timeout' threshold on the command line get a 49.7-day deadline, not the no-timeout behaviour this line documents. docs/test/runtime-behavior.mdx:127 says 4294967295 ms or more disables the timeout, but
--timeoutis parsed with parse_int:: at src/runtime/cli/Arguments.rs:2122 and never goes through bun_test::timeout_ms, so--timeout 4294967295arms a far file timer and a stuck test waits instead of failing, while--timeout 4294967296is rejected as "Invalid timeout". Fix: apply the same rule to the CLI flag (route the parsed value through bun_test::timeout_ms, or document that the rule covers only test(), hooks, setDefaultTimeout() and jest.setTimeout()).Why this was flagged
A user runs
bun test --timeout 4294967295(the value docs/test/runtime-behavior.mdx:127 now says disables the timeout) on a file whose test can never settle. src/runtime/cli/Arguments.rs:2122 stores the value as u32::MAX in default_timeout_ms with no call to bun_test::timeout_ms; ScopeFunctions.rs:1045 hands it to the test as a finite timeout, so on_entry_started arms a deadline 49.7 days away and the never-settled check at src/runtime/cli/test_command.rs:4114 never fires because the file timer stays ACTIVE. The user waits forever, wheresetDefaultTimeout(4294967295)in the same file (jest.rs:623 via bun_test.rs:2337) stores 0 and fails the test at once withA test or hook with no timeout never settled. On the base branch neither path treated 4294967295 as no timeout, so the inconsistency and the inaccurate doc line are new with this change;--timeout 4294967296additionally exits 1 withInvalid timeoutrather than meaning no timeout.Verification: nit. Trigger:
bun test --timeout 4294967295on a test that can never settle. src/runtime/cli/Arguments.rs:2122 stores the parsed u32 raw, never going throughbun_test::timeout_ms, so a 49.7-day timer is armed and the never-settled check at src/runtime/cli/test_command.rs:4114-4115 never fires. Behavior is unchanged from base; docs/test/runtime-behavior.mdx:127 adds a claim the CLI does not honor.