diff --git a/tests/bounded-spawn-sync.test.ts b/tests/bounded-spawn-sync.test.ts new file mode 100644 index 000000000..c5111a5fc --- /dev/null +++ b/tests/bounded-spawn-sync.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test"; +import { + describeCommand, + SyncSpawnTimeoutError, + spawnSyncBounded, +} from "./helpers/bounded-spawn-sync.ts"; + +test("a child that never finishes is killed at the bound and named in the error", () => { + const started = performance.now(); + expect(() => spawnSyncBounded(["sleep", "30"], { timeout: 200 })).toThrow( + new SyncSpawnTimeoutError({ command: "sleep 30", timeoutMs: 200 }) + ); + expect(performance.now() - started).toBeLessThan(10_000); +}); + +test("a child stopped before it does any work is still killed at the bound", () => { + // Stand-in for a child that hangs before it starts (HACK-2247): it stops + // itself at once, and SIGKILL still ends a stopped process. + expect(() => + spawnSyncBounded(["/bin/sh", "-c", "kill -STOP $$"], { timeout: 200 }) + ).toThrow(/^\/bin\/sh -c kill -STOP \$\$ did not finish within 200 ms/); +}); + +test("a finished child's result is returned unchanged, including a failure", () => { + const ok = spawnSyncBounded(["/bin/sh", "-c", "echo out; exit 3"]); + expect(ok.exitCode).toBe(3); + expect(ok.stdout.toString()).toBe("out\n"); +}); + +test("long commands are shortened in the name", () => { + expect(describeCommand(["git", "commit", "-m", "message"])).toBe( + "git commit -m …" + ); + expect(describeCommand(["ps"])).toBe("ps"); +}); diff --git a/tests/helpers/bounded-spawn-sync.ts b/tests/helpers/bounded-spawn-sync.ts new file mode 100644 index 000000000..1bb80ad5b --- /dev/null +++ b/tests/helpers/bounded-spawn-sync.ts @@ -0,0 +1,70 @@ +/** + * Synchronous spawns for tests, always with a time bound (HACK-2247). + * + * A synchronous spawn blocks the event loop, so Bun's per-test timeout cannot + * fire while it waits. A child that never finishes, including one that hangs + * before it starts (seen on macOS when the system policy daemon stalled), then + * holds the whole test file forever. Every synchronous spawn in `tests/` and + * `scripts/check-*.ts` therefore passes a `timeout`; `tests/sync-spawn-bound.test.ts` + * enforces that for new call sites. + * + * Limit: the child is killed with SIGKILL at the bound and the spawn then + * waits for the kernel to reap it. A process the kernel cannot kill (stuck in + * an uninterruptible wait) can still hold the call. + */ + +/** Default bound for one fixture command. Callers pass a smaller one when they can. */ +export const SYNC_SPAWN_DEFAULT_TIMEOUT_MS = 30_000; + +/** Error thrown when a bounded synchronous spawn hits its bound. */ +export class SyncSpawnTimeoutError extends Error { + readonly command: string; + readonly timeoutMs: number; + + constructor(opts: { readonly command: string; readonly timeoutMs: number }) { + super( + `${opts.command} did not finish within ${opts.timeoutMs} ms and was killed (a child that never starts or never exits; see HACK-2247).` + ); + this.name = "SyncSpawnTimeoutError"; + this.command = opts.command; + this.timeoutMs = opts.timeoutMs; + } +} + +/** The command's name for messages: the executable and at most two arguments. */ +export function describeCommand(cmd: readonly string[]): string { + const shown = cmd.slice(0, 3).join(" "); + return cmd.length > 3 ? `${shown} …` : shown; +} + +/** + * `Bun.spawnSync` with a bound: kills the child with SIGKILL after `timeout` + * ms (default {@link SYNC_SPAWN_DEFAULT_TIMEOUT_MS}) and throws + * {@link SyncSpawnTimeoutError} naming the command. Any other result, + * including a non-zero exit, is returned unchanged for the caller to assert. + */ +export function spawnSyncBounded< + const In extends Bun.SpawnOptions.Writable = "ignore", + const Out extends Bun.SpawnOptions.Readable = "pipe", + const Err extends Bun.SpawnOptions.Readable = "pipe", +>( + cmd: readonly string[], + options: Omit< + Bun.SpawnOptions.SpawnSyncOptions, + "killSignal" + > = {} +): Bun.SyncSubprocess { + const timeoutMs = options.timeout ?? SYNC_SPAWN_DEFAULT_TIMEOUT_MS; + const result = Bun.spawnSync([...cmd], { + ...options, + timeout: timeoutMs, + killSignal: "SIGKILL", + }); + if (result.exitedDueToTimeout === true) { + throw new SyncSpawnTimeoutError({ + command: describeCommand(cmd), + timeoutMs, + }); + } + return result; +} diff --git a/tests/helpers/fixture-git.ts b/tests/helpers/fixture-git.ts index 2d688736f..51fad19b6 100644 --- a/tests/helpers/fixture-git.ts +++ b/tests/helpers/fixture-git.ts @@ -1,3 +1,5 @@ +import { spawnSyncBounded } from "./bounded-spawn-sync.ts"; + /** * The one environment every test-fixture `git` command runs with (HACK-1459). * @@ -208,9 +210,13 @@ function spawnOptions(opts: FixtureGitOptions) { } as const; } -/** Runs one fixture `git` command synchronously. It does not throw on failure. */ +/** + * Runs one fixture `git` command synchronously. It does not throw on failure, + * except when git does not finish within the bound (HACK-2247): then it throws + * naming the command. + */ export function runFixtureGitSync(opts: FixtureGitOptions): FixtureGitResult { - const result = Bun.spawnSync(["git", ...opts.args], spawnOptions(opts)); + const result = spawnSyncBounded(["git", ...opts.args], spawnOptions(opts)); return { exitCode: result.exitCode, stdout: result.stdout.toString(), diff --git a/tests/helpers/lifecycle-mux-fixture.ts b/tests/helpers/lifecycle-mux-fixture.ts index 8584f215e..3946f8550 100644 --- a/tests/helpers/lifecycle-mux-fixture.ts +++ b/tests/helpers/lifecycle-mux-fixture.ts @@ -1,5 +1,6 @@ import { chmod, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { spawnSyncBounded } from "./bounded-spawn-sync.ts"; /** A closed tmux transport stand-in. It starts the shipping wrapper/client, not * a second controller. Only test-owned session metadata and captured children exist. @@ -41,7 +42,7 @@ case "list-windows": case "new-window": { if(!state)process.exit(91); const child=Bun.spawn(["/bin/sh","-c",args.at(-1)],{cwd:selected("-c"),detached:true,stdin:"ignore",stdout:"ignore",stderr:"ignore"}); - child.unref();const started=Bun.spawnSync(["/bin/ps","-o","lstart=","-p",String(child.pid)],{env:{LC_ALL:"C",TZ:"UTC"}}).stdout.toString().trim();state.windows[selected("-n")]={pid:child.pid,started};await writeFile(file,JSON.stringify(state));break; + child.unref();const started=Bun.spawnSync(["/bin/ps","-o","lstart=","-p",String(child.pid)],{env:{LC_ALL:"C",TZ:"UTC"},timeout:10000,killSignal:"SIGKILL"}).stdout.toString().trim();state.windows[selected("-n")]={pid:child.pid,started};await writeFile(file,JSON.stringify(state));break; } case "list-panes": { if(!state)process.exit(91);const name=selected("-t").split(":").at(-1);if(state.windows[name])console.log(state.windows[name].pid);break; @@ -61,10 +62,11 @@ default:process.exit(93); /** A process's start time as the stand-in records it; null when it has exited. */ function leaderStartedAt(pid: number): string | null { - const started = Bun.spawnSync( + const started = spawnSyncBounded( ["/bin/ps", "-o", "lstart=", "-p", String(pid)], { env: { LC_ALL: "C", TZ: "UTC" }, + timeout: 10_000, } ) .stdout.toString() diff --git a/tests/helpers/secret-fixture.ts b/tests/helpers/secret-fixture.ts index 93c4ed632..6b3ff37c7 100644 --- a/tests/helpers/secret-fixture.ts +++ b/tests/helpers/secret-fixture.ts @@ -1,5 +1,6 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; +import { spawnSyncBounded } from "./bounded-spawn-sync.ts"; import { fixtureGitEnv } from "./fixture-git.ts"; /** @@ -21,7 +22,7 @@ export function runFixtureGit(opts: { readonly home: string; readonly args: readonly string[]; }): string { - const result = Bun.spawnSync(["git", ...opts.args], { + const result = spawnSyncBounded(["git", ...opts.args], { cwd: opts.cwd, env: fixtureGitEnv({ env: { diff --git a/tests/nss-read-safety.test.ts b/tests/nss-read-safety.test.ts index 85d01c4d6..236a7d028 100644 --- a/tests/nss-read-safety.test.ts +++ b/tests/nss-read-safety.test.ts @@ -32,6 +32,7 @@ import { } from "../src/lib/nss-read-safety.ts"; import { vettedHelperEnv } from "../src/lib/vetted-env.ts"; import internalModuleFixture from "./fixtures/nss-read/upstream-internal.pkcs11.json"; +import { spawnSyncBounded } from "./helpers/bounded-spawn-sync.ts"; import { CURRENT_CA_PEM } from "./helpers/ca-certificates.ts"; const roots: string[] = []; @@ -615,7 +616,7 @@ test("nonblocking strict reads reject a FIFO without a writer, and no-follow ref } const f = await fixture(); const pipe = join(f.root, "fifo-placeholder"); - const created = Bun.spawnSync(["mkfifo", pipe]); + const created = spawnSyncBounded(["mkfifo", pipe], { timeout: 10_000 }); expect(created.exitCode).toBe(0); const started = Date.now(); expect(await readTextOutcome(pipe, { maxBytes: 32 })).toBe("unreadable"); diff --git a/tests/python-free-bootstrap.test.ts b/tests/python-free-bootstrap.test.ts index b6709c58a..60a824ba5 100644 --- a/tests/python-free-bootstrap.test.ts +++ b/tests/python-free-bootstrap.test.ts @@ -13,8 +13,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { renderBootstrap } from "../scripts/installer/bootstrap.ts"; import { hash } from "../scripts/installer/integrity.ts"; +import { spawnSyncBounded } from "./helpers/bounded-spawn-sync.ts"; // Filesystem fixtures may span several bounded subprocesses; the suite default is only 5 s. +// Every spawn here is synchronous, so the per-test timeout cannot interrupt one: +// each carries its own bound and fails naming the command (HACK-2247). const fixtureTest = (name: string, run: () => void | Promise) => test(name, run, 30_000); const roots: string[] = []; @@ -102,7 +105,7 @@ for (const shell of [ return; } const f = fixture(); - const result = Bun.spawnSync([...shell, f.bootstrap], { + const result = spawnSyncBounded([...shell, f.bootstrap], { env: f.env, timeout: 10_000, stdout: "pipe", @@ -134,7 +137,10 @@ for (const shell of [ expect(Bun.which("python3", { PATH: f.bin })).toBeNull(); expect(Bun.which("bun", { PATH: f.bin })).toBeNull(); expect(existsSync(`${f.home}/.hack-next`)).toBe(false); // Stand-in records protocol; compiled manager has a separate test. - expect(Bun.spawnSync(["/bin/sh", "-n", f.bootstrap]).exitCode).toBe(0); + expect( + spawnSyncBounded(["/bin/sh", "-n", f.bootstrap], { timeout: 10_000 }) + .exitCode + ).toBe(0); } ); } @@ -165,7 +171,7 @@ for (const [name, options, error] of [ const f = fixture(options); const sentinel = join(f.home, "keep"); writeFileSync(sentinel, "original"); - const result = Bun.spawnSync(["/bin/sh", f.bootstrap], { + const result = spawnSyncBounded(["/bin/sh", f.bootstrap], { env: f.env, timeout: 10_000, stdout: "pipe", @@ -183,14 +189,14 @@ fixtureTest( "bootstrap refuses relative roots and missing tools before network requests", () => { const f = fixture(); - const relative = Bun.spawnSync( + const relative = spawnSyncBounded( ["/bin/sh", f.bootstrap, "--root", "relative"], { env: f.env, timeout: 10_000 } ); expect(relative.exitCode).not.toBe(0); expect(relative.stderr.toString()).toContain("absolute"); rmSync(join(f.bin, "shasum")); - const missing = Bun.spawnSync(["/bin/sh", f.bootstrap], { + const missing = spawnSyncBounded(["/bin/sh", f.bootstrap], { env: f.env, timeout: 10_000, }); @@ -220,7 +226,7 @@ fixtureTest( ["--archive", ""], ]) { const f = fixture(); - const result = Bun.spawnSync(["/bin/sh", f.bootstrap, ...args], { + const result = spawnSyncBounded(["/bin/sh", f.bootstrap, ...args], { env: f.env, timeout: 10_000, }); @@ -235,7 +241,7 @@ fixtureTest( () => { const f = fixture(); const checksum = "a".repeat(64); - const result = Bun.spawnSync( + const result = spawnSyncBounded( [ "/bin/sh", f.bootstrap, diff --git a/tests/sync-spawn-bound.test.ts b/tests/sync-spawn-bound.test.ts new file mode 100644 index 000000000..92a337c92 --- /dev/null +++ b/tests/sync-spawn-bound.test.ts @@ -0,0 +1,222 @@ +import { expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { Glob } from "bun"; +import ts from "typescript"; + +/** + * Every synchronous spawn in `tests/` and `scripts/check-*.ts` carries a time + * bound (HACK-2247). + * + * A synchronous spawn blocks the event loop, so Bun's per-test timeout cannot + * fire while it waits. On macOS a child once hung before it started (the + * system policy daemon stalled) and an unbounded spawn held a test file for + * hours. New call sites use `spawnSyncBounded` from + * `tests/helpers/bounded-spawn-sync.ts`, which throws naming the command, or + * pass `timeout` to `Bun.spawnSync` / `spawnSync` / `execFileSync` / + * `execSync` directly. + * + * The check parses each file: a call counts as bounded when one of its + * arguments is an object literal with a `timeout` property. Options built + * elsewhere and passed as a variable are not followed, so such a site counts + * as unbounded; give it an explicit `timeout` or use the helper. Spawns inside + * generated fixture scripts (code in a string) are not seen. Sites that already existed are listed + * in KNOWN_UNBOUNDED, a ratchet: a file may not gain a site, and when a file + * loses one its count here must be lowered so it cannot be spent again. + */ + +const ROOT = resolve(import.meta.dir, ".."); +const SCANNED = ["tests/**/*.ts", "scripts/check-*.ts"] as const; +const SELF = "tests/sync-spawn-bound.test.ts"; + +/** Unbounded sites that predate this check, per file. Lower, never raise. */ +const KNOWN_UNBOUNDED: Readonly> = { + "tests/background-command-journal.test.ts": 1, + "tests/background-command-process.test.ts": 1, + "tests/ci-change-scope.test.ts": 5, + "tests/compose-startup-phases.test.ts": 1, + "tests/daemon-detach.test.ts": 1, + "tests/docker-context.test.ts": 1, + "tests/doctor-adopted-generated-files.test.ts": 1, + "tests/doctor-fix-adopted-project.test.ts": 1, + "tests/e2e-shard.test.ts": 2, + "tests/e2e/run-dependency-cache.test.ts": 1, + "tests/env-protection.test.ts": 1, + "tests/env-value-stdin-cli.test.ts": 1, + "tests/fixture-hermeticity.test.ts": 1, + "tests/gateway-tokens.test.ts": 1, + "tests/git-info-exclude.test.ts": 1, + "tests/lifecycle-command-child.test.ts": 2, + "tests/lifecycle-json.test.ts": 1, + "tests/lifecycle-private-output.test.ts": 3, + "tests/mcp-native-adapter.test.ts": 3, + "tests/mcp-startup-supervision.test.ts": 2, + "tests/native-compose-adoption-worktrees-fixture.test.ts": 1, + "tests/native-interactive-launcher.test.ts": 1, + "tests/native-preflight-cli.test.ts": 1, + "tests/native-project-branch-cli.test.ts": 1, + "tests/native-up-already-running-cli.test.ts": 1, + "tests/portable-bootstrap-env-refusal.test.ts": 1, + "tests/pre-commit-hook.test.ts": 2, + "tests/prerelease-plan.test.ts": 1, + "tests/project-down-worktree-safety.test.ts": 1, + "tests/project-env-layer-lock.test.ts": 1, + "tests/project-env-worktree-local.test.ts": 1, + "tests/project-target-inspect.test.ts": 1, + "tests/projects-registry-worktrees.test.ts": 1, + "tests/projects-registry.test.ts": 1, + "tests/qualify-provider-pin.test.ts": 1, + "tests/registry-touch-worktrees.test.ts": 1, + "tests/run-exec-branch-default.test.ts": 1, + "tests/session-list-stop-tmux.test.ts": 2, + "tests/test-shard.test.ts": 4, + "tests/tmux-backend-locale.test.ts": 2, + "tests/upgrade-apply.test.ts": 1, + "tests/upgrade-check.test.ts": 7, +}; + +const SYNC_SPAWN_NAMES = new Set(["spawnSync", "execFileSync", "execSync"]); + +/** `spawnSync(...)`, `Bun.spawnSync(...)`, `childProcess.execSync(...)` and the like. */ +function isSyncSpawnCall(node: ts.CallExpression): boolean { + const callee = node.expression; + if (ts.isIdentifier(callee)) { + return SYNC_SPAWN_NAMES.has(callee.text); + } + return ( + ts.isPropertyAccessExpression(callee) && + SYNC_SPAWN_NAMES.has(callee.name.text) + ); +} + +/** Whether an argument is an object literal with its own `timeout` property. */ +function hasTimeoutOption(node: ts.CallExpression): boolean { + return node.arguments.some( + (argument) => + ts.isObjectLiteralExpression(argument) && + argument.properties.some( + (property) => + (ts.isPropertyAssignment(property) || + ts.isShorthandPropertyAssignment(property)) && + property.name.getText() === "timeout" + ) + ); +} + +/** + * Number of synchronous spawn calls in `source` with no literal `timeout` + * option. Parsed, not matched as text, so strings and comments neither hide a + * call nor lend it a bound. Code inside a string (a generated fixture script) + * is not a call and is not counted. + */ +function countUnboundedSyncSpawns(source: string): number { + const file = ts.createSourceFile( + "scan.ts", + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + let count = 0; + const visit = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) && + isSyncSpawnCall(node) && + !hasTimeoutOption(node) + ) { + count += 1; + } + ts.forEachChild(node, visit); + }; + visit(file); + return count; +} + +async function scan(): Promise> { + const found = new Map(); + for (const pattern of SCANNED) { + for (const file of new Glob(pattern).scanSync({ cwd: ROOT })) { + if (file === SELF) { + continue; + } + const count = countUnboundedSyncSpawns( + await Bun.file(resolve(ROOT, file)).text() + ); + if (count > 0) { + found.set(file, count); + } + } + } + return found; +} + +test("the scanner tells bounded from unbounded synchronous spawns", () => { + expect(countUnboundedSyncSpawns('Bun.spawnSync(["true"])')).toBe(1); + expect( + countUnboundedSyncSpawns('Bun.spawnSync(["true"], { timeout: 1000 })') + ).toBe(0); + expect( + countUnboundedSyncSpawns( + 'spawnSync("git", ["status"], { env: f(g(1)) }); execFileSync("ls")' + ) + ).toBe(2); + expect( + countUnboundedSyncSpawns('execSync("ls", { timeout: 5, cwd: dir(a) })') + ).toBe(0); + expect( + countUnboundedSyncSpawns('Bun.spawnSync({ cmd: ["true"], timeout: 1 })') + ).toBe(0); + // Parentheses in strings do not move call boundaries (review of #857). + expect( + countUnboundedSyncSpawns( + `Bun.spawnSync(["/bin/sh", "-c", "printf ')'"], { timeout: 1000 });` + ) + ).toBe(0); + expect( + countUnboundedSyncSpawns( + `Bun.spawnSync(["/bin/sh", "-c", "printf '('"]);\nBun.spawnSync(["true"], { timeout: 1000 });` + ) + ).toBe(1); + // A comment or string that says "timeout" is not a bound. + expect( + countUnboundedSyncSpawns( + 'spawnSync("sleep", ["timeout"]); // timeout\nexecSync("ls", { cwd: "timeout" })' + ) + ).toBe(2); + // The option must be the call's own, not a nested object's. + expect( + countUnboundedSyncSpawns('spawnSync("ls", [], { env: { timeout: "1" } })') + ).toBe(1); + expect( + countUnboundedSyncSpawns( + 'const timeout = 5; childProcess.execFileSync("ls", [], { timeout })' + ) + ).toBe(0); + // The bounded helper itself is not a match. + expect(countUnboundedSyncSpawns('spawnSyncBounded(["true"])')).toBe(0); +}); + +test("no new synchronous spawn without a timeout in tests/ or scripts/check-*.ts", async () => { + const found = await scan(); + const added: string[] = []; + const lowered: string[] = []; + for (const [file, count] of found) { + const known = KNOWN_UNBOUNDED[file] ?? 0; + if (count > known) { + added.push(`${file}: ${count} unbounded (${known} known)`); + } + } + for (const [file, known] of Object.entries(KNOWN_UNBOUNDED)) { + const count = found.get(file) ?? 0; + if (count < known) { + lowered.push(`${file}: now ${count}, listed as ${known}`); + } + } + expect( + added, + "Synchronous spawn without a timeout. Use spawnSyncBounded from tests/helpers/bounded-spawn-sync.ts or pass timeout (HACK-2247)." + ).toEqual([]); + expect( + lowered, + "Fewer unbounded spawns than listed: lower KNOWN_UNBOUNDED in tests/sync-spawn-bound.test.ts." + ).toEqual([]); +}); diff --git a/tests/tmux-socket-guard.test.ts b/tests/tmux-socket-guard.test.ts index 2a28248b2..7ea65e9c6 100644 --- a/tests/tmux-socket-guard.test.ts +++ b/tests/tmux-socket-guard.test.ts @@ -18,6 +18,7 @@ import { writeFileSync, } from "node:fs"; import { join, resolve } from "node:path"; +import { spawnSyncBounded } from "./helpers/bounded-spawn-sync.ts"; import { privateTmux } from "./helpers/private-tmux.ts"; import { assertPrivateTmuxSocket, @@ -38,10 +39,11 @@ test("exact compiler environment scope preserves only its pure fixture command", { mode: 0o700 } ); const run = () => - Bun.spawnSync([binary], { + spawnSyncBounded([binary], { env: { PATH: "/usr/bin:/bin" }, stdout: "pipe", stderr: "pipe", + timeout: 10_000, }); withCompilerFixtureEnvironment({ binary, @@ -49,9 +51,14 @@ test("exact compiler environment scope preserves only its pure fixture command", const exact = run(); expect(exact.exitCode).toBe(0); expect(JSON.parse(exact.stdout.toString())).toEqual(["PATH"]); - const peer = Bun.spawnSync( + const peer = spawnSyncBounded( [process.execPath, "-e", childEnvironmentScript], - { env: { PATH: "/usr/bin:/bin" }, stdout: "pipe", stderr: "pipe" } + { + env: { PATH: "/usr/bin:/bin" }, + stdout: "pipe", + stderr: "pipe", + timeout: 10_000, + } ); expect(peer.exitCode).toBe(0); expect(JSON.parse(peer.stdout.toString())).toEqual({ @@ -118,9 +125,9 @@ test("Bun and Node children receive private sockets with omitted or minimal envs process.env.TMUX = "/not-a-real-socket/default,1,0"; const minimal = { PATH: process.env.PATH }; for (const env of [undefined, minimal]) { - const synchronous = Bun.spawnSync( + const synchronous = spawnSyncBounded( [process.execPath, "-e", childEnvironmentScript], - { env, stdout: "pipe", stderr: "pipe" } + { env, stdout: "pipe", stderr: "pipe", timeout: 10_000 } ); expect(synchronous.exitCode, synchronous.stderr.toString()).toBe(0); expect(JSON.parse(synchronous.stdout.toString())).toEqual({ @@ -142,18 +149,23 @@ test("Bun and Node children receive private sockets with omitted or minimal envs execFileSync(process.execPath, ["-e", childEnvironmentScript], { env: minimal, encoding: "utf8", + timeout: 10_000, }) ) ).toEqual({ root, tmux: null }); const nodeChild = spawnSync( process.execPath, ["-e", childEnvironmentScript], - { env: minimal, encoding: "utf8" } + { env: minimal, encoding: "utf8", timeout: 10_000 } ); expect(nodeChild.status).toBe(0); expect(JSON.parse(nodeChild.stdout)).toEqual({ root, tmux: null }); expect( - execSync('printf "%s" "$TMUX_TMPDIR"', { env: minimal, encoding: "utf8" }) + execSync('printf "%s" "$TMUX_TMPDIR"', { + env: minimal, + encoding: "utf8", + timeout: 10_000, + }) ).toBe(root); } finally { mux.close(); @@ -239,26 +251,27 @@ test.skipIf(!Bun.which("tmux"))( const pid=Bun.spawnSync(["tmux","display-message","-p","#{pid}"],{stdout:"pipe",stderr:"pipe"}); process.stdout.write(pid.stdout); `; - const child = Bun.spawnSync([process.execPath, "-e", script], { + const child = spawnSyncBounded([process.execPath, "-e", script], { env, stdout: "pipe", stderr: "pipe", + timeout: 10_000, }); expect(child.exitCode, child.stderr.toString()).toBe(0); pids.push(Number(child.stdout.toString().trim())); } - const pid = Bun.spawnSync(["tmux", "display-message", "-p", "#{pid}"], { - stdout: "pipe", - stderr: "pipe", - }); + const pid = spawnSyncBounded( + ["tmux", "display-message", "-p", "#{pid}"], + { stdout: "pipe", stderr: "pipe", timeout: 10_000 } + ); expect(pid.exitCode).toBe(0); expect(pids).toEqual([ Number(pid.stdout.toString().trim()), Number(pid.stdout.toString().trim()), ]); - const sessions = Bun.spawnSync( + const sessions = spawnSyncBounded( ["tmux", "list-sessions", "-F", "#{session_name}"], - { stdout: "pipe", stderr: "pipe" } + { stdout: "pipe", stderr: "pipe", timeout: 10_000 } ); expect(sessions.exitCode).toBe(0); expect(sessions.stdout.toString().trim().split("\n").sort()).toEqual([ @@ -284,6 +297,7 @@ test("both spawn forms refuse an unsafe explicit tmux socket before executing", Bun.spawnSync({ cmd: [tmux, "list-sessions"], env: { TMUX_TMPDIR: "/tmp" }, + timeout: 10_000, }) ).toThrow("tmux-socket-guard"); } finally { @@ -436,14 +450,14 @@ test.skipIf(!Bun.which("tmux"))( ["-L", "named-fixture"], ["-S", join(root, "explicit.sock")], ]) { - const made = Bun.spawnSync( + const made = spawnSyncBounded( ["tmux", ...client, "new-session", "-d", "-s", "extra", "sleep 60"], - { stdout: "pipe", stderr: "pipe" } + { stdout: "pipe", stderr: "pipe", timeout: 10_000 } ); expect(made.exitCode, made.stderr.toString()).toBe(0); - const pid = Bun.spawnSync( + const pid = spawnSyncBounded( ["tmux", ...client, "display-message", "-p", "#{pid}"], - { stdout: "pipe", stderr: "pipe" } + { stdout: "pipe", stderr: "pipe", timeout: 10_000 } ); expect(pid.exitCode).toBe(0); pids.push(Number(pid.stdout.toString().trim()));