Skip to content
Merged
Changes from all commits
Commits
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
117 changes: 101 additions & 16 deletions scripts/lib/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ describe('unit · run()', () => {
});

test('stdout then stderr, one newline apart, and the exit code', async () => {
const result = await run(['sh', '-c', 'echo out; echo err >&2; exit 3'], { cwd: repoRoot() });
// `bun -e`, not `sh -c`: the windows CI job runs this file on native Windows, which has no
// POSIX shell. Every child here is Bun itself — the one program the suite already depends on.
const result = await run(
['bun', '-e', 'console.log("out"); console.error("err"); process.exit(3);'],
{ cwd: repoRoot() },
);
expect(result).toMatchObject({ code: 3, ok: false, output: 'out\nerr', timedOut: false });
});
});
Expand All @@ -67,49 +72,129 @@ describe('unit · run()', () => {
describe('unit · run() deadline', () => {
const cwd = import.meta.dir;

test('a grandchild holding stdout cannot hold the caller past the deadline', async () => {
// The child both deadline tests below run: it prints `started`, then holds its stdout past the
// answer `run()` waits on. One property, one spelling per platform — the platforms differ in
// WHO can hold a pipe, never in whether the deadline answers:
//
// - POSIX: the child spawns a long-lived grandchild inheriting its stdout and exits, so the
// pipe's write end lives on in the orphan and EOF never comes (the original `sh -c 'sleep 30
// & echo started'`).
// - Windows: nothing observable can hold it there. Bun passes a child pipe handles it does not
// re-pass to a grandchild — the identical spawn answered `code: 0, timedOut: false` in 18 ms
// on windows-latest — and `start /b`, the scriptable background, points a child's stdio at
// the console instead of the pipe it was given. So the child holds the pipe itself by
// outliving the deadline; the kill seam records the same wall
// (`packages/cli/src/verify-deadline.ts`: Windows keeps no tag to find a re-parented
// grandchild).
//
// That leaves Windows with two same-deadline killers — `run()`'s own timer, and Bun's spawn
// timeout, which kills the holder and so EOFs its pipes — and whichever wins the race decides
// the verdict's SHAPE (`timedOut` + no code, or a killed code), measured 1 in 15 flips on a
// Linux box, 2026-10. Both answers carry the same facts, so those are what these tests assert
// on every platform: answered AT the deadline (never the #780 hang), `ok: false`, what was
// printed kept. The coded verdict is asserted where it is deterministic — POSIX, whose orphan
// leaves no second killer. Dropping Bun's redundant internal spawn timeout from `run()` (one
// line, `scripts/lib/run.ts`) removes the race everywhere and is what lets the Windows branch
// grow the POSIX assertions.
//
// One constant, because both tests assert on the same program and its exact text — `run()`
// echoes `command.join(' ')` into the timeout line.
const HOLDS_STDOUT =
process.platform === 'win32'
? 'console.log("started"); setTimeout(() => {}, 60_000);'
: 'Bun.spawn(["bun","-e","setTimeout(()=>{},60000)"],{stdout:"inherit",stderr:"inherit",stdin:"ignore"});console.log("started");process.exit(0);';

test('stdout that never reaches EOF cannot hold the caller past the deadline', async () => {
const started = performance.now();
const result = await run(['sh', '-c', 'sleep 30 & echo started'], { cwd, timeoutMs: 400 });
const result = await run(['bun', '-e', HOLDS_STDOUT], { cwd, timeoutMs: 300 });
expect(performance.now() - started).toBeLessThan(5_000);
expect(result).toMatchObject({ ok: false, code: null, timedOut: true });
// Answered AT the deadline on both platforms, whichever killer won: the holder hangs for a
// minute, so an answer this early is the deadline speaking — never the 18 ms `ok: true` the
// old grandchild fixture got on Windows.
expect(result.ok).toBe(false);
expect(result.durationMs).toBeGreaterThanOrEqual(190);
// What the child DID print is kept: it is the clue to where it stopped.
expect(result.output).toContain('started');
expect(result.output).toContain(`${RUN_TIMEOUT_CODE}: sh -c sleep 30 & echo started`);
expect(result.output).toContain('did not finish within 400 ms');
if (process.platform !== 'win32') {
// POSIX reaches the deadline one way only — the orphan holds the pipes past the child's
// death, so EOF never comes and `run()`'s timer always wins — which makes the coded
// verdict deterministic here and not on Windows (see the fixture comment).
expect(result).toMatchObject({ ok: false, code: null, timedOut: true });
// The coded line names the program that was killed, verbatim: `run()` echoes
// `command.join(' ')`, so the reported command is the one that actually hung.
expect(result.output).toContain(`${RUN_TIMEOUT_CODE}: bun -e ${HOLDS_STDOUT}`);
expect(result.output).toContain('did not finish within 300 ms');
}
});

test('a child that never exits is killed at the deadline', async () => {
const started = performance.now();
const result = await run(['sleep', '30'], { cwd, timeoutMs: 300 });
const result = await run(['bun', '-e', 'setTimeout(() => {}, 60_000);'], {
cwd,
timeoutMs: 300,
});
expect(performance.now() - started).toBeLessThan(5_000);
expect(result).toMatchObject({ ok: false, code: null, timedOut: true });
});

// The pipes are released, not only abandoned: a script whose `run()` timed out still exits,
// rather than living until the orphan holding its output does.
test('the calling process exits after a timeout, not when the orphan does', async () => {
// rather than living until whatever holds its output does. The inner run's verdict travels
// out as one JSON line, because the probe only proves something if it REACHES the deadline —
// the Windows grandchild fixture used to answer `ok: true` in 18 ms and green this without
// racing anything.
test('the calling process exits after its timeout, not when the holder dies', async () => {
const VERDICT = 'inner verdict ';
const probe = `import { run } from ${JSON.stringify(join(import.meta.dir, 'run.ts'))};
await run(['sh', '-c', 'sleep 30 & echo started'], { cwd: ${JSON.stringify(cwd)}, timeoutMs: 200 });`;
const inner = await run(['bun', '-e', ${JSON.stringify(HOLDS_STDOUT)}], { cwd: ${JSON.stringify(cwd)}, timeoutMs: 200 });
console.log(${JSON.stringify(VERDICT)} + JSON.stringify({ code: inner.code, durationMs: inner.durationMs, ok: inner.ok, timedOut: inner.timedOut }));`;
const outer = await run(['bun', '-e', probe], { cwd, timeoutMs: 10_000 });
expect({ timedOut: outer.timedOut, ok: outer.ok }).toEqual({ timedOut: false, ok: true });
const line = outer.output.split('\n').find((one) => one.startsWith(VERDICT));
if (line === undefined) return expect.unreachable('the probe never reported the inner verdict');
const inner = JSON.parse(line.slice(VERDICT.length)) as {
code: number | null;
durationMs: number;
ok: boolean;
timedOut: boolean;
};
// The inner run reached its deadline on both platforms — the holder hangs for a minute, so
// only the deadline ends it — and answered refused, never the old Windows `ok: true`.
expect(inner.ok).toBe(false);
expect(inner.durationMs).toBeGreaterThanOrEqual(190);
expect(inner.durationMs).toBeLessThan(10_000);
if (process.platform !== 'win32') {
// Deterministic on POSIX only — the orphan holds the pipes past the child's death, so
// `run()`'s timer always wins (see the fixture comment for the Windows race).
expect(inner.timedOut).toBe(true);
expect(inner.code).toBeNull();
}
});

test("stdin is never the caller's: a reader of it sees EOF at once", async () => {
const result = await run(['cat'], { cwd, timeoutMs: 5_000 });
// `cat`, the POSIX original, does not exist on windows-latest; a reader that must see EOF is
// the property, so any stdin reader proves it on both platforms. This one prints nothing.
const result = await run(['bun', '-e', 'await Bun.stdin.text();'], { cwd, timeoutMs: 5_000 });
expect(result).toMatchObject({ ok: true, code: 0, output: '', timedOut: false });
});

test('env is passed through when given', async () => {
const result = await run(['sh', '-c', 'printf %s "$RUN_PROBE"'], {
cwd,
env: { ...Bun.env, RUN_PROBE: 'seen' },
});
// Exact bytes, no trailing newline: `process.stdout.write` is async on a pipe, but this
// payload is 4 bytes — far below the 64KB where `process.exit` would drop the queue.
const result = await run(
['bun', '-e', 'process.stdout.write(String(process.env.RUN_PROBE));'],
{
cwd,
env: { ...Bun.env, RUN_PROBE: 'seen' },
},
);
expect(result.output).toBe('seen');
});

test('a deadline that is not a positive finite number is a coded refusal', async () => {
for (const timeoutMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
const caught = await run(['true'], { cwd, timeoutMs }).then(
// `true`, the POSIX original, does not exist on windows-latest. The deadline is validated
// before anything is spawned, so the program here only has to be a spawnable one.
const caught = await run(['bun', '-e', ''], { cwd, timeoutMs }).then(
() => undefined,
(error: unknown) => error,
);
Expand Down
Loading