From 481807255ad669cb25f421e59afdc68c124c39db Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Thu, 12 Mar 2026 16:06:33 -0700 Subject: [PATCH] Isolate per-command output from background procs --- src/run.test.ts | 18 ++++++++++++++++++ src/run.ts | 17 ++++++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/run.test.ts b/src/run.test.ts index a06f994..472c265 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -62,6 +62,24 @@ describe("runFile", () => { } }) + test("background process output does not leak into subsequent commands", async () => { + const file = makeFile([ + // Start a background process that writes to stdout after a delay + { command: "{ sleep 0.1; echo LEAKED; } &" }, + // Wait long enough for the background output to appear + { command: "sleep 0.3; echo clean" }, + ]) + const result = await runFile(file, defaultOpts) + try { + // The background command itself should have no output + expect(result.results[0]?.actual).toEqual([]) + // The sleep command should only see its own output, not the background "LEAKED" + expect(result.results[1]?.actual).toEqual(["clean"]) + } finally { + await cleanupTmpDir(result.tmpDir) + } + }) + test("cleans up multiple backgrounded processes", async () => { const file = makeFile([ { command: "sleep 300 >/dev/null 2>&1 & P1=$!; sleep 300 >/dev/null 2>&1 & P2=$!; echo $P1 $P2" }, diff --git a/src/run.ts b/src/run.ts index 378d08f..a1da05a 100644 --- a/src/run.ts +++ b/src/run.ts @@ -58,9 +58,10 @@ function buildScript(commands: Command[], sentinel: string, verbose: boolean): s if (verbose) { // Save original stderr to fd 3 before merging stderr into stdout - lines.push("exec 3>&2 2>&1") + // Save the pipe on fd 9 so we can restore after each command + lines.push("exec 3>&2 2>&1 9>&1") } else { - lines.push("exec 2>&1") + lines.push("exec 2>&1 9>&1") } for (let i = 0; i < commands.length; i++) { @@ -68,11 +69,21 @@ function buildScript(commands: Command[], sentinel: string, verbose: boolean): s if (verbose) { lines.push(`printf '${VERBOSE_MARKER}${i}\\n' >&3`) } + // Redirect stdout+stderr to a temp file so background processes + // from previous commands can't pollute this command's output. + // Background processes keep their fd pointing at the old temp file, + // which becomes orphaned after rm — their output goes nowhere. + lines.push(`__shout_out=$(mktemp)`) + lines.push(`exec 1>"$__shout_out" 2>&1`) lines.push(cmd.command) + lines.push(`__shout_ec=$?`) + lines.push(`exec 1>&9 2>&1`) + lines.push(`cat "$__shout_out"`) + lines.push(`rm -f "$__shout_out"`) // Sentinel: printf to avoid echo interpretation issues // Format: __SHOUT_SENTINEL____ lines.push( - `printf '\\n${sentinel}%s_${i}__\\n' "$?"`, + `printf '\\n${sentinel}%s_${i}__\\n' "$__shout_ec"`, ) }