diff --git a/src/run.test.ts b/src/run.test.ts new file mode 100644 index 0000000..37d0227 --- /dev/null +++ b/src/run.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test" +import { readFile } from "node:fs/promises" + +import { runFile, cleanupTmpDir } from "./run.ts" +import type { ShoutFile } from "./parse.ts" + +function makeFile(commands: { command: string; expected?: string[] }[]): ShoutFile { + return { + path: "test.shout", + commands: commands.map((c, i) => ({ + line: i + 1, + raw: `$ ${c.command}`, + command: c.command, + expected: c.expected ?? [], + exitCode: null, + })), + } +} + +const defaultOpts = { + cleanEnv: false, + timeout: 5000, + verbose: false, +} + +async function isProcessRunning(pid: number): Promise { + try { + const stat = await readFile(`/proc/${pid}/stat`, "utf-8") + // State is the 3rd field: R=running, S=sleeping, Z=zombie, etc. + const state = stat.split(" ")[2] + return state !== "Z" && state !== "X" + } catch { + // /proc entry doesn't exist — process is fully gone + return false + } +} + +describe("runFile", () => { + test("basic command", async () => { + const file = makeFile([{ command: "echo hello" }]) + const result = await runFile(file, defaultOpts) + expect(result.results[0]?.actual).toEqual(["hello"]) + expect(result.results[0]?.exitCode).toBe(0) + await cleanupTmpDir(result.tmpDir) + }) + + test("cleans up backgrounded processes after exit", async () => { + const file = makeFile([ + { command: "sleep 300 >/dev/null 2>&1 & echo $!" }, + ]) + + const result = await runFile(file, defaultOpts) + const pid = parseInt(result.results[0]?.actual[0] ?? "", 10) + expect(pid).toBeGreaterThan(0) + + // Give the SIGTERM a moment to be delivered + await new Promise(r => setTimeout(r, 100)) + + expect(await isProcessRunning(pid)).toBe(false) + 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" }, + ]) + + const result = await runFile(file, defaultOpts) + const pids = (result.results[0]?.actual[0] ?? "").split(" ").map(Number) + expect(pids).toHaveLength(2) + expect(pids[0]).toBeGreaterThan(0) + expect(pids[1]).toBeGreaterThan(0) + + await new Promise(r => setTimeout(r, 100)) + + for (const pid of pids) { + expect(await isProcessRunning(pid)).toBe(false) + } + + await cleanupTmpDir(result.tmpDir) + }) +}) diff --git a/src/run.ts b/src/run.ts index 001ac4d..e5754ac 100644 --- a/src/run.ts +++ b/src/run.ts @@ -132,15 +132,15 @@ export async function runFile( env["PATH"] = options.pathDirs.join(":") + ":" + (env["PATH"] ?? "") } - try { - const proc = Bun.spawn(["/bin/sh"], { - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - cwd: tmpDir, - env, - }) + const proc = Bun.spawn(["setsid", "/bin/sh"], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + cwd: tmpDir, + env, + }) + try { proc.stdin.write(script) proc.stdin.end() @@ -174,6 +174,16 @@ export async function runFile( tmpDir, error: err instanceof Error ? err.message : String(err), } + } finally { + // Kill the shell's process group to clean up backgrounded children + if (proc.pid) { + try { + process.kill(-proc.pid, "SIGTERM") + } catch {} + setTimeout(() => { + try { process.kill(-proc.pid, "SIGKILL") } catch {} + }, 500) + } } }