From 7c3578335f0dec850f0a10f0a2db811704d04a0c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:11:27 +0000 Subject: [PATCH 1/2] bun test: rank "no timeout" as the latest deadline for spawnSync in a concurrent group `TestRunner::get_active_timeout()` gives the spawnSync wait loop the deadline at which the child is sent its kill signal and its pipes are closed. After an `await` the callback of the caller is no longer on the stack, and the function takes the latest deadline of the group. It compared with `Timespec::order`, which ranks `EPOCH` ("no timeout") below every deadline. The timeout of one test then ended the spawnSync child of a concurrent test that has no timeout. Compare with `Timespec::order_ignore_epoch`, so a running entry with no timeout gives no deadline. Skip a sequence that has not started: its `EPOCH` is "not set", and it would otherwise keep the child of a test that times out under `--max-concurrency`. --- src/runtime/test_runner/jest.rs | 7 +- test/js/bun/spawn/spawnSync.test.ts | 222 +++++++++++++++++++++++++++- 2 files changed, 227 insertions(+), 2 deletions(-) diff --git a/src/runtime/test_runner/jest.rs b/src/runtime/test_runner/jest.rs index 703b9186eb7d..284210e9b8d6 100644 --- a/src/runtime/test_runner/jest.rs +++ b/src/runtime/test_runner/jest.rs @@ -168,10 +168,15 @@ impl<'a> TestRunner<'a> { if let Some(group) = active_file.execution.active_group_ref() { let mut latest: Option = 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); } } diff --git a/test/js/bun/spawn/spawnSync.test.ts b/test/js/bun/spawn/spawnSync.test.ts index 9ccf716fae0c..3691ffd39639 100644 --- a/test/js/bun/spawn/spawnSync.test.ts +++ b/test/js/bun/spawn/spawnSync.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, bunRun, isLinux, isMusl, isPosix, isRoot, isWindows, tempDir } from "harness"; +import { bunEnv, bunExe, bunRun, isASAN, isDebug, isLinux, isMusl, isPosix, isRoot, isWindows, tempDir } from "harness"; import { totalmem } from "os"; import { join } from "path"; describe("spawnSync", () => { @@ -331,3 +331,223 @@ it.skipIf(!isLinux)("a writer finalized during spawnSync leaves its fd number us exitCode: 0, }); }); + +// In `bun test`, the deadline of a test ends a spawnSync wait: the child is sent its kill signal and its pipes are +// closed. When no test callback is on the stack, as after an `await`, the runner does not know which test of a group +// of concurrent tests called spawnSync, so the wait ends at the latest deadline of the tests that are running. "No +// timeout" ranked below every deadline there, and the timeout of one test ended the child of a test that has none. +// +// One case at a time on debug and ASAN builds: their processes start slowly. Five cases at once took 3.7 s of the 5 s +// each has on a debug build. +(isDebug || isASAN ? describe : describe.concurrent)("spawnSync after an await in a group of concurrent tests", () => { + // Each defines `childRanToItsEnd(cmd)`. + const entryPoints = { + "Bun.spawnSync": /* ts */ ` + const childRanToItsEnd = (cmd: string[]) => + Bun.spawnSync({ cmd, stdin: "ignore", stdout: "ignore", stderr: "ignore" }).exitCode === 0; + `, + "child_process.spawnSync": /* ts */ ` + import { spawnSync } from "node:child_process"; + const childRanToItsEnd = (cmd: string[]) => spawnSync(cmd[0], cmd.slice(1), { stdio: "ignore" }).status === 0; + `, + "child_process.execFileSync": /* ts */ ` + import { execFileSync } from "node:child_process"; + const childRanToItsEnd = (cmd: string[]) => returns(() => execFileSync(cmd[0], cmd.slice(1), { stdio: "ignore" })); + `, + "child_process.execSync": /* ts */ ` + import { execSync } from "node:child_process"; + const childRanToItsEnd = (cmd: string[]) => + returns(() => execSync('"' + cmd[0] + '" ' + cmd.slice(1).join(" "), { stdio: "ignore" })); + `, + }; + + async function runGroup( + tests: string, + { entryPoint = "Bun.spawnSync", args = [] }: { entryPoint?: keyof typeof entryPoints; args?: string[] } = {}, + ) { + using dir = tempDir("spawnsync-concurrent-group", { + // Its parent waits in spawnSync, so nothing can tell it that a deadline has passed: it runs for a fixed time. + "child.js": `Bun.sleepSync(Number(process.argv[2]));`, + "group.test.ts": /* ts */ ` + import { expect, test } from "bun:test"; + + // The sync functions of node:child_process throw when the child does not exit with 0. + function returns(fn: () => unknown) { + try { + fn(); + return true; + } catch { + return false; + } + } + ${entryPoints[entryPoint]} + + // 300 ms is past every 100 ms timeout below, and 4000 ms is past every timeout. + function waitForChild(ms: 300 | 4000) { + const finished = childRanToItsEnd([process.execPath, "child.js", String(ms)]); + console.log(finished ? "the child finished" : "the child was ended early"); + return finished; + } + const immediate = () => new Promise(resolve => setImmediate(resolve)); + const never = new Promise(() => {}); + ${tests} + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", ...args, "group.test.ts"], + env: bunEnv, + cwd: String(dir), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { + child: stdout.split(/\r?\n/).filter(line => line.startsWith("the child")), + // A test that takes no time has no duration after its name. + tests: stderr + .split(/\r?\n/) + .map(line => line.match(/^\((pass|fail)\) (.+?)(?: \[[\d.]+ms\])?$/)) + .filter(match => match !== null) + .map(([, result, name]) => `${name}: ${result}`) + .sort(), + exitCode, + }; + } + + it.each(Object.keys(entryPoints) as (keyof typeof entryPoints)[])( + "%s: the timeout of a sibling does not end the child of a test with no timeout", + async entryPoint => { + const group = await runGroup( + ` + test.concurrent("times out", async () => { + await never; + }, 100); + + test.concurrent("has no timeout", async () => { + await immediate(); + expect(waitForChild(300)).toBe(true); + }, 0); + `, + { entryPoint }, + ); + + expect(group).toEqual({ + child: ["the child finished"], + tests: ["has no timeout: pass", "times out: fail"], + exitCode: 1, + }); + }, + ); + + // The sibling passes when it is tried again, so the signal alone made this run fail. + it("a sibling that times out once does not fail a test with no timeout", async () => { + const group = await runGroup(` + let attempts = 0; + + test.concurrent("times out once", async () => { + if (attempts++ === 0) await never; + }, { retry: 1, timeout: 100 }); + + test.concurrent("has no timeout", async () => { + await immediate(); + expect(waitForChild(300)).toBe(true); + }, 0); + `); + + expect(group).toEqual({ + child: ["the child finished"], + tests: ["has no timeout: pass", "times out once (attempt 2): pass"], + exitCode: 0, + }); + }); + + // The wait loop closes the pipes when it signals the child. This shell has exited by then, so the deadline of the + // sibling showed only as cut output, with exit code 0. + it.skipIf(isWindows)("the timeout of a sibling does not cut the output of a test with no timeout", async () => { + const group = await runGroup(` + test.concurrent("times out", async () => { + await never; + }, 100); + + test.concurrent("has no timeout", async () => { + await immediate(); + const { stdout, exitCode } = Bun.spawnSync({ + cmd: ["sh", "-c", "(sleep 0.3; echo late) & echo early"], + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + }); + console.log("the child wrote " + JSON.stringify(stdout.toString()) + " and exited with " + exitCode); + }, 0); + `); + + expect(group).toEqual({ + child: [`the child wrote "early\\nlate\\n" and exited with 0`], + tests: ["has no timeout: pass", "times out: fail"], + exitCode: 1, + }); + }); + + it("the latest timeout ends the child when every test that runs has one", async () => { + const group = await runGroup(` + test.concurrent("times out first", async () => { + await never; + }, 100); + + test.concurrent("times out last", async () => { + await immediate(); + waitForChild(4000); + }, 300); + `); + + expect(group).toEqual({ + child: ["the child was ended early"], + tests: ["times out first: fail", "times out last: fail"], + exitCode: 1, + }); + }); + + // Its deadline is not set before it starts. That is not "no timeout". + it("a test that has not started does not keep the child of a test that times out", async () => { + const group = await runGroup( + ` + test.concurrent("times out", async () => { + await immediate(); + waitForChild(4000); + }, 300); + + test.concurrent("has not started", () => {}, 0); + `, + { args: ["--max-concurrency", "1"] }, + ); + + expect(group).toEqual({ + child: ["the child was ended early"], + tests: ["has not started: pass", "times out: fail"], + exitCode: 1, + }); + }); + + // Before its first await the runner knows the caller: its callback is on the stack. + it("a test that waits in spawnSync before its first await is ended at its own timeout", async () => { + const group = await runGroup(` + const { promise: childEnded, resolve } = Promise.withResolvers(); + + test.concurrent("has no timeout", async () => { + await childEnded; + }, 0); + + test.concurrent("times out", () => { + waitForChild(4000); + resolve(); + }, 100); + `); + + expect(group).toEqual({ + child: ["the child was ended early"], + tests: ["has no timeout: pass", "times out: fail"], + exitCode: 1, + }); + }); +}); From ed52a5004523c7b1aad29a7bac062255eb4633a4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:24:45 +0000 Subject: [PATCH 2/2] ci: retrigger