Repository navigation
Conversation
|
Updated 6:05 PM PT - Oct 21st, 2026
❌ @robobun, your commit 497bcf1 has some failures in 🧪 To try this PR locally: bunx bun-pr 45081That installs a local version of the PR into your bun-45081 --bun |
|
Status: reproduced, fixed, waiting for CI and a maintainer. How I reproduced it:
The same file shape is in After review, a timed-out |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
WalkthroughProcessAutoKiller now tracks spawned processes by scope. The test runner starts a scope for each callback and uses scoped cleanup for eligible timeouts. Documentation and tests describe and check which processes are cleaned up. ChangesTest timeout process cleanup
Suggested reviewers: Priority: ➖ Normal Severity of issue fixed: Medium Merge Risk: ⚪ Minimal · up to
|
|
The review found no actionable comments. One correction to the linked-issues check: #39657 is an alternative pull request for the same bug, not an issue whose list this PR must complete. The three behaviors it names are left out on purpose.
The notes in the description have the probe for each case. |
There was a problem hiding this comment.
Beyond the inline findings (all about which children a timeout should reap), I also checked the memory-safety side of the refactor: kill_scope walks the map backwards so swap_remove_at cannot skip an entry, every removed key is deref'd exactly once in kill_and_release, and clear/Drop are unchanged, so the ref taken on insert stays balanced. The raw-pointer read of result_queue in active_entry_reported never materializes a &BunTest and the scan does not mutate the queue, so it does not alias &mut self.
Extended reasoning...
The change scopes bun test's timeout kill to the processes spawned by the timed-out callback (ProcessAutoKiller gains a per-entry scope counter and kill_scope; Execution.rs begins a scope per callback and skips the kill once the entry has reported) and adds ~350 lines of subprocess tests plus a docs paragraph. It touches no security-sensitive surface. The posted findings are a behavioral tradeoff (hook timeouts and --bail now leave earlier scopes' children running) that the PR itself flags as needing a maintainer decision, so a human must weigh it; the refcount and aliasing mechanics of the new unsafe code were examined and hold.
Findings marked 🟡 are optional suggestions and need no follow-up push.
…k spawned A timeout called `auto_killer.kill()`, which sends SIGTERM to every process the file has spawned so far: the children of `beforeAll`, of `beforeEach`, of earlier tests, and under `--isolate` of module scope. A helper started once per file was gone after the first timeout. `ProcessAutoKiller` now stamps each tracked process with a scope. The runner begins a scope before it runs a test or hook callback, and a timeout kills only the current scope. The whole-set `kill()` stays for the `--isolate` swap between files and is now `pub(crate)`. The kill is skipped when the active entry has already returned and its result is queued: that callback was not interrupted, and the entry passes. The `ProcessAutoKiller` change comes from #38774.
…thout a cleanup An afterEach hook that times out was the cleanup of its test. The runner now also kills what the test and its other hooks started. The processes of beforeAll and of earlier tests stay alive. An afterAll hook that times out was the cleanup of everything that is still tracked. The runner kills the whole set, as it did before. --bail exits before the remaining hooks run. The runner kills the tracked processes before that exit, also when the failure was not a timeout.
a189309 to
0f000e4
Compare
There was a problem hiding this comment.
The new commits address the three points from my earlier review (afterEach/afterAll timeouts now reap via kill_since/kill, both --bail exit sites drain the tracked set, and the late-return test holds the thread with a loop instead of a wall-clock sleepSync). I found no bugs on this pass, but because this changes the runner's process-reaping policy and the description itself asks for a maintainer decision on the hook rules, a human look is still worthwhile.
What was reviewed:
kill_sincearithmetic and the backwardsswap_remove_atwalk: entries stamped== markare kept,kill_scopereduces to span 1, andProcess::killis a plainsend_signalso nothing re-enters the map mid-walk; refs are released once per removed entry on every path.active_entry_reported:result_queueis reached through theimpl_field_parent!raw pointer only,peek_itemcopies, and the spawnSync wait loop cannot see a queued result for the callback still on the stack.mark_after_allis applied per describe, so an inner describe'safterAlltimeout also kills children of the outerbeforeAll(and module-scope children under--isolate) that the outerafterAllwas still going to stop; this matches the new docs sentence but is a policy choice worth confirming.
Extended reasoning...
The diff scopes bun test's timeout process-killing to the timed-out callback's own spawns via a u32 scope counter in ProcessAutoKiller.rs, with wider kills for afterEach (kill_since sequence mark), afterAll (kill all), and --bail; it touches unsafe Rust in the test runner (raw parent-pointer read of result_queue, arena entry pointer walks) and the --bail exit paths. No security-sensitive surface is touched. The bug hunt ran dry and my own reading of the ref-count paths, the wrapping arithmetic, and the swap-remove walk found them balanced and correct, and the new tests cover each kill rule including the swap-remove ordering case. Deferring rather than approving because the change redefines user-visible reaping behavior for hook timeouts, including a nested-describe afterAll case that kills outer-scope children, which the author explicitly flags as needing a maintainer decision.
The inner `bun test --bail` exits through `Global::exit`, which skips teardown, so LeakSanitizer reports what the runner leaves behind. `bun-test.test.ts` holds the other `--bail` cases and is not leak-checked for that reason. The timeout cases stay leak-checked. Fold three comments to one line each.
There was a problem hiding this comment.
The three points from the earlier rounds (after-hook timeouts sparing earlier scopes, --bail leaving beforeAll children, and the wall-clock-dependent sleepSync in the late-return case) are addressed by the new commits. Beyond the inline findings, I also checked kill_since in src/jsc/ProcessAutoKiller.rs for the swap-remove-while-iterating hazard and refcount balance: it walks backwards so the swapped-in entry was already visited, the wrapping scope - value < span test is correct for kill_scope (span 1) and for marks older than the current scope, and kill_and_release derefs on both the exited and killed paths.
Extended reasoning...
The change rescopes bun test's timeout process-kill from the whole per-VM set to per-callback scopes across ProcessAutoKiller.rs, Execution.rs, Order.rs, bun_test.rs and test_command.rs, with no security-sensitive surface. Two confirmed inline findings remain (retry restart re-reading auto_kill_mark, and nested-describe afterAll timeouts draining the whole set), so a human decision on the intended hook-timeout semantics is still needed.
…rAll reaps its own scope The mark that an afterEach timeout kills back to is now taken when the group of the test starts. A retry or a repeat restarts the sequence in the same group, so the children of an earlier attempt stay above the mark. An afterAll timeout kills what the hooks and tests of its describe block started. For an afterAll at the top level of a file, those are all hooks and tests of the file. Only a preload afterAll and --bail still kill every tracked process, because nothing runs after them. Tests: no callback that has to finish runs under a 100 ms timeout. On a loaded Windows machine the afterEach case timed out the afterEach of its second test, which returns at once.
…r callback The timer callback and the step each read the clock. A timer that fires just before the deadline of the active entry (the timer of an entry that finished earlier) passed the first read and failed the second: the entry timed out and its processes stayed. On main the next timeout killed them together with everything else. With the scoped kill nothing did. handle_timeout now only wakes the runner. step_sequence_one kills when evaluate_timeout reports the timeout, so a timeout always has its kill. A queued result is stepped before the wake-up, so an entry that already returned passes without a kill, and the scan of the result queue for that case is gone. A timer that fires while an entry with no timeout is active kills nothing.
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline finding, I re-checked the two points from my last review against the latest push: the retry case is covered now that the mark lives on ConcurrentGroup (set once in step_group when executing flips, and reset_sequence does not touch it, so attempt-1 scopes stay above the mark), and a nested describe's afterAll timeout now goes through scope_start_mark instead of kill(). I also walked kill_since's wrapping arithmetic: it removes exactly the scopes in (mark, current], and kill_scope is the mark = current - 1 case, with the backward walk keeping swap_remove_at from skipping entries.
Extended reasoning...
The latest commits move the kill call from handle_timeout into step_sequence_one after evaluate_timeout, record the auto-kill mark per concurrent group, and add scope_start_mark/after_all_scope for afterAll timeouts; both earlier concerns I raised (retry attempts and nested-describe afterAll) are addressed in code. No security-sensitive surface is touched. One nit finding remains from this run, and the change is a non-trivial rewrite of process-reaping semantics in the test runner, so a human look is still appropriate.
|
A question for the maintainer about the signal that a timeout sends, before more is built on this PR. This PR changes which processes a timeout signals. It keeps the one Which answer do you want for
For 1 and 2 there is a second choice: the spawned pid, or its process tree. The data and a worked version of answer 2 are in the Notes of #45792. That PR does only the |
expect(promise).resolves waits in place: it turns the event loop on the stack of the test callback, and the runner cannot step until the callback returns. With the kill only at the step, a test that waits there for its own child got no kill at its deadline and waited for the whole life of the child. handle_timeout now decides that case itself. When the deadline of the callback on the stack has passed, it kills as the step does. When the timer fired for an earlier entry, it arms the timer again for the deadline of the callback, because run does that only after the callback returns. On main that timer is lost and the wait is never ended. An entry with no timeout gets no kill.
…-timeout-kill-entry-scope
…test of its block
There was a problem hiding this comment.
The earlier points about the retry restart re-reading the mark and a nested describe's afterAll draining the whole set are addressed by the per-group auto_kill_mark and scope_start_mark. Beyond the inline findings, I also checked the refcount balance in kill/kill_since/kill_and_release (each removed entry derefs exactly once; clear and Drop unchanged) and the wrapping scope arithmetic (kill_scope hits only the current scope, kill_since(mark) hits exactly the scopes after mark), and found no issue there.
Extended reasoning...
The change scopes the test runner's dangling-process auto-killer in src/jsc/ProcessAutoKiller.rs and src/runtime/test_runner/Execution.rs, moves the timeout kill from the timer callback into the runner's step, and adds a whole-set kill at both --bail exit sites in src/runtime/cli/test_command.rs. It touches no security-sensitive surface. Two findings from this run are posted inline, including a possible run-level hang when a test waits in place on a child owned by an earlier scope, so a human should weigh those before merging.
Findings marked 🟡 are optional suggestions and need no follow-up push.
expect(promise).resolves waits in place: it turns the event loop under the script frame that called it, and that frame returns only when what it waits for ends. With the scoped kill, a wait for a child of beforeAll or of an earlier test had no end. When the callback that waits is on the stack of the runner, the runner cannot step. Past the deadline of that callback the timer callback now kills every tracked process, as main does: nothing narrower is sure to end the wait. When the wait is in a continuation, the runner goes on and the timeout stays scoped. The file cannot end while that frame is on the stack, so the end of the file kills what is still tracked. spawnSync reports its deadline through its own entry point, so that report is not taken for a timer that fired under a callback. The --bail case with a timeout now also starts a child in the test that times out, so it tells the kill at the timeout from the kill at the exit.
…eout spared The end of a file killed every tracked process whenever a script frame was on the stack. That is also true under done(err), which steps the runner from a host function, and it killed a preload server that no timeout would have killed on main. The event loop now counts its wait_for_promise calls, so the runner knows when a frame really waits in place. ProcessAutoKiller records the scope of its last scoped kill. At the end of a file under such a wait, only the processes that a scoped kill left alive are killed: the ones that main killed at that timeout. spawnSync reports its deadline with the callback still on the stack. The timer is now armed again for that deadline, so a wait in place that the callback starts afterwards is ended like one that began before it.
…-timeout-kill-entry-scope # Conflicts: # src/runtime/cli/test_command.rs # test/cli/test/bun-test.test.ts
| test("test timeout kills dangling processes", async () => { | ||
| Bun.spawn({ | ||
| cmd: [bunExe(), "--eval", "Bun.sleepSync(50); console.log('This should not be printed!');"], | ||
| cmd: [process.execPath, "--eval", "Bun.sleepSync(50); console.log('This should not be printed!');"], |
There was a problem hiding this comment.
🟡 nit (optional): maintainers get two fixtures that spawn Bun without the harness bunExe/bunEnv the test guide requires; the base branch used them. test/cli/test/process-kill-fixture.ts:5 and process-kill-fixture-sync.ts:5 now spawn process.execPath with the inherited env. Fix: keep the harness convention for every Bun spawn in both fixtures (or state in a one-line comment why the harness import is avoided here, e.g. the hooks harness.ts registers would change the kill-scope accounting), while keeping the test's cwd: import.meta.dir so no bunfig preload runs. Same pattern at 2 sites (test/cli/test/process-kill-fixture.ts:5, test/cli/test/process-kill-fixture-sync.ts:5).
Why this was flagged
test/CLAUDE.md:28 says that when spawning Bun processes in tests, use bunExe and bunEnv from harness so the same build is used and debug logging is silenced. The diff removes import { bunEnv, bunExe } from "harness" and the env: bunEnv option from test/cli/test/process-kill-fixture.ts:5-10 and test/cli/test/process-kill-fixture-sync.ts:5-9, replacing bunExe() with process.execPath. The behaviour is equivalent today only because the outer spawn at test/cli/test/test-timeout-behavior.test.ts:20 passes env: bunEnv, which the fixture inherits, and because the fixture runs under the same binary; nothing in the fixture states that dependency. The base branch followed the convention. No user-visible failure; this is a convention slip flagged because CLAUDE.md violations are in scope for this review.
Verification: nit. Trigger: any maintainer reading or copying these fixtures; nothing breaks at runtime. The diff removes import { bunEnv, bunExe } from "harness" from both test/cli/test/process-kill-fixture.ts and test/cli/test/process-kill-fixture-sync.ts, replacing bunExe() with process.execPath. test/CLAUDE.md:28 requires bunExe and bunEnv from harness, and the base branch followed it.
Problem
bun testkills every child process the file spawned:killed 7 dangling processeswhere the test spawned 1.Execution::kill_dangling_processes_on_timeout(src/runtime/test_runner/Execution.rs:324) callsauto_killer.kill(), which drains the per-VM set. Rewrite test/describe, add test.concurrent #22534 (1.2.23) dropped the per-test clear.Fix
ProcessAutoKillerstamps each process with a scope number. The runner begins one per callback, and a timeout kills only that scope.afterEachkills what its test started, a timed-outafterAllwhat itsdescribeblock or file started.--bailkills every tracked process.expect().resolves) keeps the wide kill, at its deadline or at file end.test/cli/test/test-timeout-behavior.test.ts(23 new cases, 18 fail on 1.4.3), two--bailcases inbun-test.test.ts. Self-review: see notes.Background
ProcessAutoKiller(src/jsc/) is the per-VM set of child processes that a test file spawned.expect(promise).resolveswaits in place: it turns the event loop until the promise settles.beforeEachchild died beforeafterEachran. Builds on bun test: only kill the timed-out test's own subprocesses on timeout #38774.Downsides
beforeEachchildren toafterEach, and a timed-outafterAllleaves outer children alone. main kills both.--bailalso kills after a non-timeout failure. main does not..text+1,792 B.Notes
Repro. One file:
beforeAllstarts 2 children (Bun.spawnandnode:child_process),beforeEachstarts 1 per test, the first test starts 2, the second test starts 1 and never settles (timeout 200 ms). A third test does a stdin/stdout round trip with every helper.beforeAll,beforeEach, earlier testkilled 7 dangling processeskilled 1 dangling processThe result is the same with
--isolate,--paralleland--parallel --no-isolate.Who is killed. "Tracked" means every process the hooks and tests started that is still running.
beforeAllorbeforeEachafterEach,onTestFinishedafterAllof adescribeblockafterAllat the top level of a fileafterAllof a preload--bailafter a timeout--bailafter another failuretimeout: 0expect().resolvesbefore its firstawait(the runner cannot step)expect().resolvesafter anawait(the runner goes on)spawnSyncand then waits in placespawnSyncreturnsHistory. A probe with one child each from
beforeAll,beforeEachand the test body printskilled 1on 1.1.30 and 1.2.22, andkilled 3on 1.2.23 and 1.4.3. The commit message of #22534 lists a per-testauto_killerlayer as not done ("at the end of the test, we kill the test processes"). Commit 534ba47 records one timeout inbun-publish.test.tsthat killed the Verdaccio registry started inbeforeAll. I found no user issue for this.Where the kill runs. On main the timer callback kills when
nowis past the deadline of the active entry, and then the step reads the clock again to declare the timeout. Two things follow from the two reads.beforeEachthat holds the thread for 100 µs under the same 50 ms timeout. main: 5 and 6 of the 150 timeouts print no kill in 2 of 3 runs (a later timeout then printskilled 2). With only the scoped kill those children stay: 5 of 150 in one run of an earlier state of this PR. This PR: 150 of 150 in 3 of 3 runs.timeout: 0is active kills on main (timespec0 is beforenow). That is the bug of fix(test): preserve unlimited test children after stale deadlines #44613.In this PR
step_sequence_onecalls the kill whenevaluate_timeoutreports the timeout, andhandle_timeoutwakes the runner. A timeout always has its kill, and nothing else has one. A result that is already queued is stepped before the wake-up, so a callback that returned late passes and keeps its children. An earlier state of this PR scanned the result queue for that case. That scan is gone.spawnSynckeeps its own call, because its isolated loop does not run the file timer.A wait in place.
expect(promise).resolves,.rejectsand an asynctoThrowwait in place: they turn the event loop under the script frame that called them, and that frame returns only when the promise settles. Nothing can interrupt that wait. On main the kill of every tracked process at a timeout is what ends it when the promise depends on a child, for examplechild.exitedor a request to a helper. A promise that depends on no child is never ended, on main and here (a test that waits inexpect(new Promise(() => {})).resolveshangsbun teston 1.4.3 too). Two cases keep a kill for it.run(it has not reached its firstawait). The runner cannot step until it returns, sohandle_timeoutdecides itself: past the deadline of that callback it kills every tracked process. With only the scoped kill, a wait for a child ofbeforeAlllasted for the whole life of that child (8.1 s in a probe, 0.2 s on main and now). When the timer fired for an earlier entry,handle_timeoutarms the timer again for the deadline of the callback, becauserundoes that only after the callback returns. main loses the timer there: a test that waits for its own child after an earlier test waits for the whole life of the child (11.8 s on main, 0.3 s here). An entry withtimeout: 0gets nothing.await). The runner steps, so the timeout stays scoped and the helpers serve the next tests. The frame that waits is still under every later step, and the file cannot end before it returns. The event loop now counts itswait_for_promisecalls, andProcessAutoKillerrecords the scope of its last scoped kill. When a file ends while that count is not zero,advancekills the processes that a scoped kill of this file left alive (kill_spared): the ones that main killed at that timeout (9.0 s with only the scoped kill, 0.2 s on main and now). Without a wait in place, or without a scoped kill before it, the end of a file kills nothing. A test that fails throughdone(err)steps the runner under a script frame too, and a preload server must survive a file in which no timeout spared it.spawnSyncreports a deadline that passed in its own loop throughbun_test_timeout_in_spawn_sync, with the callback still on the stack. The report arms the timer again for that deadline, which is past. When the callback then starts a wait in place, the timer fires under it and the first case applies. main killed every tracked process at the report.Three narrow cases still wait where main does not. Each needs an earlier timeout that spared a helper, and a later wait in place that ends only when that helper dies. The wait is in a callback with
timeout: 0before its firstawait, or in a group oftest.concurrenttests before its firstawait, or it starts after its file ended.Open for a maintainer.
beforeEachchildren toafterEach. That is the 1.2.22 behavior. The other choice is to kill them when the sequence of the test ends. That also takes a helper thatbeforeEachstarts once for later tests, and it fails the retry case.timeout: 0. This PR moves that kill to the step, and the one case that stays in the timer callback skips an entry with no timeout. The regression test of fix(test): preserve unlimited test children after stale deadlines #44613 passes here without its guard (it fails on main). The line that fix(test): preserve unlimited test children after stale deadlines #44613 changes is still there, andgit merge-treemerges the two PRs in either order.wait_for_promisereturns, and the matcher throws a timeout error. That ends every such wait, also one that depends on no child, and both kills of "A wait in place" go away. It changes whatexpect().resolvesdoes at a timeout, so it is not in this PR.Also fixed here.
killed 1 dangling processwith onebeforeAllserver, 3 of 3 runs). 1.2.22 skipped the kill once a test had reported.spawnSyncwait loop calls the same function, so a test that times out insideBun.spawnSyncorchild_process.spawnSyncloses only that child (main:killed 2).beforeEachstarted for attempt 1 survives the timeout of attempt 1. If theafterEachof a later attempt times out, that helper is killed with the rest of the test.Not changed.
test.concurrenttests: the runner kills nothing at a timeout, as on main.afterAllof its block, of its file or of a preload, and--bail, still reap it.describeboundary run in one group. TheafterAllof that block then also kills what the other tests of that group started. The docs say so. Inside a group the callbacks of the sequences interleave, and a process gets the scope of the callback that began last, so a mark per sequence does not separate them either.beforeEachruns inside the test's bun:test callback, so its child still dies with the test.expect().resolvesbefore its firstawaitkills every tracked process, as on main. See "A wait in place".Other designs. A per-group clear of the set fails under
--isolate, where the set must hold the whole file. A mark perdescribeblock inDescribeScope, written when the order is built, needs a field and a special case for concurrent tests that join the group before the block. The scan inscope_start_markruns only when anafterAlltimes out.Other open PRs. #44613, #41878 and #45792 merge cleanly with this branch. #43803, #30598 and #40235 already conflict with main. This PR changes regions of
Execution.rsthat #40235 rewrites.Measurements. Release builds (ThinLTO) of main 9634520 and this PR, same build directory.
execution::step_sequence, wherestep_sequence_oneis inlined)beforeAllorafterAllhook, one per run of concurrent tests): +1 instruction when the order is built, +2 when the group starts (objdump ofOrder::generate_order_describeandBunTest::run)BunTest::run, whereadvanceis inlined)kill_dangling_processes_on_timeoutand its clock read. It testsin_run_loopinstead (objdump ofBunTest::on_timeout)wait_for_promise: +2 instructions when the promise is already settled, about +6 when it waits (objdump, 123 -> 132 instructions in the function)cargo check)ArrayHashMap::put48 -> 55), first tracked spawn of a VM: +98 (put230 -> 327), tracked exit: +22 (swap_remove62 -> 84), spawn/exit with tracking off: +0 (objdump of the two sites inspawn_maybe_sync, gdb single-step, 4 of 4 calls identical)catch syscall kill. strace is not installed in my container.)size -Aandnm -S)kill_dangling_processes_on_timeoutgrows from 403 B to 574 B, the timer callback from 232 B to 455 B.Tests.
test-timeout-behavior.test.ts: 25 cases, 23 of them new.USE_SYSTEM_BUN=1(1.4.3) and a release build of main: 7 pass, 18 fail. Debug and release builds of this PR: 25 pass, 3 of 3 release runs. Release build with every core busy 3 times over: 25 of 25 runs, 8 times over: 12 of 12. It also passes with the leak-check environment of the ASAN lane.bun-test.test.ts: two new--bailcases. Both fail on 1.4.3 and main. The timeout case starts a child in the test that times out, so it expects one kill at the timeout and one at the exit. They are in that file with the other--bailtests. With the leak-check environment, a--bailrun with both kills exits with code 1 and no leak report (checked on the debug build).afterEach, a wait inexpect().resolvesfor its own child and for a child ofbeforeAll, a file that ends under a wait in place with no timeout before it, a last test that fails throughdone(err)) pin behavior that the narrower kill must keep. The file-end case fails when the end of the file kills every tracked process (checked with that change in a debug build). The retry case fails when the mark is read again per attempt (checked with that change in a debug build). The wait for its own child fails with the kill only at the step: no kill, and 12.3 s against 0.5 s (checked on a build of d20ac16). The wait for a child ofbeforeAllfails with the scoped kill there: 8.1 s against 0.2 s (checked on a build of 669f229).describeblock, the retry case times out on every attempt, and the late-return case (500 ms) runs alone after the concurrent cases.test.concurrentIf(!isDebug)). A debug bun needs about 0.5 s of CPU to start, and all cases at once ran past the default 5 s timeout on a 12-core machine. A release build runs them together: 2.0 s for the file.harness(2.6 s per process in a debug build), and their runner getscwd: import.meta.dirso the repobunfig.tomlpreload is not loaded. Theirslow test after test timeoutloses its 200 ms timeout: with all cases at once its 100 ms sleep overran it once. The assertions are unchanged.isolation(47 pass. Before the last merge of main, 2 of 6 debug runs each failed one case that passes alone: one uses a FinalizationRegistry, one a RedisClient),expect(424 pass),node-test(45 pass),spawnSync,spawnsync-isolated-event-loop,spawnsync-no-microtask-drain,bun_test,concurrent,concurrent_immediate,failure-skip,test-retry-repeats-basic,test-failing,jest-hooks,concurrent-test-glob,done-async,test-timers,test-test,test-on-test-finished,describe,nested-describes,bunfig/preload,regression/issue/23865.cargo clippyonbun_runtime.test/cli/test/bun-test.test.ts(it has the--bailcases): 109 pass on a release build of this PR. On the debug build 108 pass:instances of a class nested too deeply to print are a RangeErrorandthe data of events nested too deeply to print are a RangeErrorfail there. Those cases print a value nested 100,000 deep and do not spawn or time out. They pass on release builds of main and this PR.Review history.
test.concurrentsentence in the docs, list the after-hook and--bailcases, state what needs a maintainer's decision, and cite the repro and the two commits instead of a user report. The seventh is about bun test: kill only the timed-out test's processes, and a concurrent group's once it is done #39657, and I leave that PR to a maintainer. That review also counted 15 smaller concerns about the implementation. That list was not available to me, so I do not claim them.afterEachorafterAllkills what it was going to stop,--bailkills the tracked processes before it exits, and the late-return test holds the thread with a loop.afterEachtimeout is taken once per test and not per attempt, and a timed-outafterAllkills what its owndescribeblock started and not the children of outer scopes.describeboundary. It is now in the docs, and "Not changed" says why the code stays.expect().resolvesgot no kill at its deadline. I confirmed it and fixed it inhandle_timeout. The same place now also arms the timer again, which main does not.beforeAllwithout an end. Confirmed and changed: see "A wait in place". The same check found the wait in a continuation, which the end of the file now handles. Its optional point about the--bailtimeout case is done too.done(err)killed every tracked child. It killed every tracked process and not only what a timeout spared, so a preload server died where main keeps it. A wait in place after aspawnSynctimeout in the same callback had no timer. The count in the event loop,kill_sparedand the timer that thespawnSyncreport arms are the answers. Each has a case in the test file.no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/cli/test/test-timeout-behavior.test.ts, test/cli/test/bun-test.test.ts