utils/subprocess.test.ts
Rama main · réplica de lectura del repositorio original.
import { performance } from "node:perf_hooks";
import { describe, expect, it } from "vitest";
import { spawn, TailBuffer } from "./subprocess.ts";
describe("spawn error path", () => {
it("surfaces ENOENT-style spawn failures in stderr so callers can diagnose", async () => {
// before this regression-test's fix, spawn resolved with exitCode=1 and
// an empty stderr buffer when the command itself couldn't start —
// lifecycle hook warnings then said "output: (empty)" and users had no
// way to tell a broken script from a flaky one.
const result = await spawn({
cmd: "/nonexistent-command-for-spawn-test-xyz",
args: [],
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
activityTimeout: 0,
});
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("/nonexistent-command-for-spawn-test-xyz");
expect(result.stderr).toMatch(/ENOENT|not found/i);
});
it("clears the SIGKILL escalator when a timed-out child exits cleanly from SIGTERM", async () => {
// regression: the overall-timeout path did
// setTimeout(() => { if (!child.killed) child.kill("SIGKILL") }, 5000)
// without capturing the timer id. if the child responded to SIGTERM and
// `close` fired promptly, the SIGKILL escalator stayed in the event loop
// for up to 5 seconds — delaying any clean shutdown by that long.
const beforeHandles = process.getActiveResourcesInfo().filter((r) => r === "Timeout").length;
// sleep does not install a TERM trap, so the default action (terminate)
// fires immediately — `close` lands within ms of the SIGTERM, giving us
// the orphaned-escalator window that the bug would have triggered.
const result = await spawn({
cmd: "sleep",
args: ["30"],
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
activityTimeout: 0,
timeout: 200,
}).catch((err) => err);
// timed out, so we get the SpawnTimeoutError
expect(result).toBeInstanceOf(Error);
// the SIGKILL escalator (and any other timer spawn() owned) must be
// cleared by the time the promise settles — active timer count should
// not have grown past the pre-spawn baseline.
const afterHandles = process.getActiveResourcesInfo().filter((r) => r === "Timeout").length;
expect(afterHandles).toBeLessThanOrEqual(beforeHandles);
});
it("killGroup: true propagates SIGKILL to grandchildren so close fires promptly", async () => {
// regression: node_modules/opencode-ai/bin/opencode is a Node shim that
// spawnSyncs the native binary with stdio:"inherit". without killGroup,
// child.kill("SIGKILL") hit only the shim — the native binary was
// reparented to PID 1, kept holding our stdout pipe via the inherited
// fds, and `child.on("close")` never fired (because pipes stayed open).
// a 5-min outer safety-net timer eventually rejected the agent promise,
// but the grandchild kept running until the GitHub Actions job-level
// timeout. this test replicates the shape with bash + a backgrounded
// sleep grandchild: with killGroup, close fires promptly after SIGKILL;
// without it, the parent would wait for sleep to exit (30s).
//
// the activity-check interval is fixed at 5s so the earliest the kill
// can fire is ~5s after start. budget 15s end-to-end.
const before = performance.now();
const result = await spawn({
cmd: "bash",
args: ["-c", "sleep 30 & wait"],
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
activityTimeout: 1000,
killGroup: true,
}).catch((err) => err);
const elapsed = performance.now() - before;
expect(result).toBeInstanceOf(Error);
// 10s ceiling: 5s activity-check tick + signal delivery. a regression
// here (no killGroup) would hang for the full 30s sleep.
expect(elapsed).toBeLessThan(10_000);
}, 20_000);
it('retain:"tail" caps stderr at maxRetainedBytes and prepends a truncation sentinel', async () => {
// regression for issue #680: unbounded `stderrBuffer += chunk` previously
// crashed the wrapper with `RangeError: Invalid string length` once V8's
// ~1 GiB kMaxLength was breached on long-lived agent runs. the fix caps
// retention with a TailBuffer; this test exercises the cap end-to-end by
// emitting ~2 MiB of stderr against a 256 KiB ceiling and asserts the
// wrapper does not crash, the result is bounded, and the sentinel is
// present so downstream consumers can detect the truncation.
const result = await spawn({
cmd: "bash",
// print ~2 MiB to stderr in 64 KiB chunks. /dev/zero + head gives us a
// reliable byte budget that's well above the 256 KiB cap below. not
// `yes`: the `yes` npm package's node bin shadows coreutils on PATH and
// flips the shared stderr pipe to O_NONBLOCK, so `head` hits EAGAIN.
args: ["-c", "head -c 2097152 /dev/zero | tr '\\0' A 1>&2"],
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
activityTimeout: 0,
maxRetainedBytes: 256 * 1024,
});
expect(result.exitCode).toBe(0);
expect(result.stderr).toMatch(/truncated by retain:tail cap/);
expect(result.stderr.length).toBeLessThan(256 * 1024 + 200);
}, 15_000);
it('retain:"none" returns empty stdout/stderr regardless of child output', async () => {
// long-lived agent callers (opencode, claude) drain via onStdout/onStderr
// and never read result.stdout/result.stderr — they pass retain:"none"
// to skip the per-chunk concatenation entirely. assert that contract:
// empty strings out, but onStdout still fires.
const chunks: string[] = [];
const result = await spawn({
cmd: "bash",
args: ["-c", "echo hello; echo world 1>&2"],
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
activityTimeout: 0,
retain: "none",
onStdout: (chunk) => chunks.push(chunk),
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("");
expect(result.stderr).toBe("");
expect(chunks.join("")).toContain("hello");
});
it('retain defaults to "tail" so short-lived callers keep failure-surfacing snapshots', async () => {
// lock the default explicitly. gitAuth, package installs, and lifecycle
// hooks all rely on `result.stderr` being non-empty on failure — flipping
// the default to "none" would silently break their error messages while
// all other tests in this file kept passing.
const result = await spawn({
cmd: "bash",
args: ["-c", "echo -n diagnostic-output 1>&2; exit 7"],
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
activityTimeout: 0,
});
expect(result.exitCode).toBe(7);
expect(result.stderr).toBe("diagnostic-output");
});
it("TailBuffer drops oldest bytes once the cap is exceeded", () => {
const buf = new TailBuffer(10);
buf.append("0123456789");
expect(buf.toString()).toBe("0123456789");
buf.append("abcde");
// 0-9 plus abcde = 15 chars; cap is 10, so we keep the last 10 = "56789abcde"
expect(buf.toString()).toMatch(/truncated by retain:tail cap/);
expect(buf.toString()).toContain("56789abcde");
});
it("reports signal-killed subprocesses as failures, not success", async () => {
// regression: before the fix, `child.on("close", (exitCode) => ...)`
// discarded the signal parameter and `exitCode || 0` coerced the
// node-delivered null to 0. lifecycle hooks killed by OOM, segfault,
// or external SIGTERM were silently reported as exit code 0, and
// lifecycle.ts's `if (result.exitCode !== 0)` skipped the warning —
// so callers proceeded as if setup/post-checkout/prepush had succeeded.
const result = await spawn({
cmd: "bash",
args: ["-c", "kill -KILL $$"],
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
activityTimeout: 0,
});
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toMatch(/killed by signal/i);
expect(result.stderr).toMatch(/SIGKILL/);
});
});