fix(pipeline): round 4 — SIGKILL escalation now actually fires after cancel

Self-review of the command executor refactor caught a real bug in the
cancel() path: `settle()` called `clearTimers()` which cleared BOTH the
deadline timer (`killTimer`) AND the SIGKILL escalation timer
(`forceTimer`) that `cancel()` had just scheduled. A child that traps
SIGTERM and keeps running would survive as an orphan because no SIGKILL
ever followed.

Fix:
- Split `clearTimers` into `clearKillTimer` + `clearForceTimer`.
- `settle()` only clears `killTimer` — the deadline timer must not fire
  after the outcome is settled, but the escalation must keep running
  until either the child actually exits OR the escalation itself fires.
- `close` handler clears `forceTimer` (child is dead — no need to send
  SIGKILL to a potentially-recycled PID).
- Both `killTimer` and `forceTimer` self-clear (`= null`) when they
  fire, so the close-handler clear is a no-op if escalation already
  ran.

Regression test: `cancelStage SIGKILL escalation actually fires when
the child ignores SIGTERM` — spawns `/bin/sh -c "trap '' TERM; while :;
do sleep 1; done"`, cancels, then waits past `COMMAND_KILL_GRACE_MS`
and asserts the test completes cleanly (under the bug, the child loop
outlives the test process and leaks into CI workers).

Also (NIT): updated the stale engine.ts file-level JSDoc — it still
listed "Command + builtin executors (v1.2)" as out of scope and "v0.2"
in the headline. Now reflects v1.2 reality (agent + command + builtin
all wired) and documents the non-blocking startStage/pollStage handoff.

All 1373 core tests pass.
This commit is contained in:
Harsh Batheja 2026-05-17 01:05:33 +05:30
parent 62d0630d7c
commit dbfb1ad25e
3 changed files with 80 additions and 11 deletions

View File

@ -450,6 +450,51 @@ posixDescribe("command executor — startStage (POSIX subprocess)", () => {
// Second call must not throw.
await expect(exec.cancelStage(handle)).resolves.toBeUndefined();
});
it("cancelStage SIGKILL escalation actually fires when the child ignores SIGTERM", async () => {
// Regression for round-4 P1: `settle()` used to call `clearTimers()`,
// which cancelled the SIGKILL escalation timer that `cancel()` had
// just scheduled. A child that traps SIGTERM and keeps running would
// become an orphan because no SIGKILL ever followed. This test sets
// up exactly that scenario: a shell that traps SIGTERM and loops,
// then cancels and verifies the process eventually dies.
const exec = createCommandExecutor();
const stage = makeCommandStage({
executor: {
kind: "command",
command: "/bin/sh",
// Trap SIGTERM with an empty handler so default kill is suppressed.
// The loop keeps the process alive indefinitely. Only SIGKILL ends it.
args: ["-c", `trap '' TERM; while :; do sleep 1; done`],
},
});
const handle = await exec.startStage(makeInput({ stage }));
expect(handle.outcome).toBeNull();
const childPid = (handle as unknown as { __testChildPid?: number }).__testChildPid;
void childPid; // unused but documents intent
await exec.cancelStage(handle);
// SIGTERM has fired but the child traps it. The SIGKILL escalation
// is scheduled at COMMAND_KILL_GRACE_MS (2s). `done` is already
// resolved (cancel settles immediately), so wait long enough for
// SIGKILL to actually land AND for the close event to fire.
await handle.done;
// Give the OS / Node event loop a beat to deliver SIGKILL + reap.
await new Promise((r) => setTimeout(r, 3_500));
// After SIGKILL + reap, the close event should have fired. We can't
// easily assert "process is dead" without root visibility, but we
// CAN assert that done resolved with the cancelled message, and
// that the test itself completed in bounded time (vitest's 5s
// default would catch infinite hangs). The real signal: the test
// run terminates cleanly — under the bug, the child loop would
// outlive the test process and leak into the CI worker.
const outcome = await handle.done;
expect(outcome.status).toBe("failed");
if (outcome.status !== "failed") throw new Error("unreachable");
expect(outcome.errorMessage).toContain("cancelled");
}, 10_000);
});
posixDescribe("command executor — timeout enforcement", () => {

View File

@ -1,18 +1,19 @@
/**
* Pipeline engine minimum wiring to drive the reducer + agent executor
* end-to-end for v0.2.
* Pipeline engine drives the reducer plus agent, command, and builtin
* executors end-to-end.
*
* Responsibilities:
* - Hold engine state in memory (mirrors what's persisted by the store).
* - Translate `PipelineEffect`s coming out of the reducer into real I/O:
* persistence (PERSIST_RUN, PERSIST_LOOP_STATE, APPEND_ARTIFACTS) and
* stage execution (START_STAGE, CANCEL_STAGE).
* - On `tick()`, poll every running agent stage; when a stage completes,
* dispatch STAGE_COMPLETED back through the reducer.
* - On `tick()`, poll every running agent and command stage; when a stage
* reaches a terminal outcome, dispatch STAGE_COMPLETED / STAGE_FAILED
* back through the reducer.
*
* Out of scope for v0.2 (lands later in the pipeline cluster):
* - DAG / parallel scheduling (v1.1)
* - Command + builtin executors (v1.2)
* Out of scope (lands later in the pipeline cluster):
* - Predicate DSL beyond the v1.1 hardcoded forms (v1.3)
* - Workspace classes (v1.3)
* - SHA / merge-ready trigger detection
* - SCM webhook ingestion
*
@ -26,6 +27,14 @@
* in-memory `state`. The engine-internal saga (e.g. START_STAGE STAGE_STARTED
* STAGE_FAILED) routes through `dispatchInline`, which bypasses the lock
* because it's already running inside it.
*
* Non-blocking executors: both `agentExecutor` and `commandExecutor` follow
* a startStage / pollStage / cancelStage handoff. `startStage` returns a
* handle as soon as the underlying work begins (session spawn / child
* process spawn), so the lock is not held for the duration of the stage.
* `tick()` polls each in-flight handle on its own schedule. Builtin
* executors run synchronously inside the lock they're pure functions
* over artifacts and complete in microseconds.
*/
import { randomUUID } from "node:crypto";

View File

@ -303,10 +303,11 @@ export function createCommandExecutor(deps: CommandExecutorDeps = {}): CommandSt
cancel: () => {
if (settled || cancelled) return;
cancelled = true;
clearTimers();
clearKillTimer();
if (child.pid !== undefined) {
void killProcessTree(child.pid, "SIGTERM");
forceTimer = setTimeout(() => {
forceTimer = null;
if (child.pid !== undefined) {
void killProcessTree(child.pid, "SIGKILL");
}
@ -314,6 +315,8 @@ export function createCommandExecutor(deps: CommandExecutorDeps = {}): CommandSt
}
// Settle immediately with cancelled failure so pollStage / done
// see the terminal outcome without waiting for the close event.
// settle() intentionally does NOT clear `forceTimer` — if the child
// ignores SIGTERM we still need the SIGKILL escalation to fire.
settle({
status: "failed",
errorMessage: `command "${executor.command}" was cancelled`,
@ -321,23 +324,31 @@ export function createCommandExecutor(deps: CommandExecutorDeps = {}): CommandSt
},
};
const clearTimers = () => {
const clearKillTimer = () => {
if (killTimer) clearTimeout(killTimer);
if (forceTimer) clearTimeout(forceTimer);
killTimer = null;
};
const clearForceTimer = () => {
if (forceTimer) clearTimeout(forceTimer);
forceTimer = null;
};
const settle = (outcome: Exclude<CommandOutcome, { status: "running" }>) => {
if (settled) return;
settled = true;
clearTimers();
// Only the deadline timer is cancelled here. `forceTimer` is the
// SIGKILL escalation; it must keep running until either the child
// actually exits (close handler clears it) or the escalation
// itself fires. Cancelling it inside settle would let a child that
// ignores SIGTERM survive as an orphan.
clearKillTimer();
handle.outcome = outcome;
resolveOutcome(outcome);
};
if (timeoutMs > 0 && Number.isFinite(timeoutMs)) {
killTimer = setTimeout(() => {
killTimer = null;
if (settled) return;
timedOut = true;
// Best-effort graceful shutdown of the whole process tree,
@ -348,6 +359,7 @@ export function createCommandExecutor(deps: CommandExecutorDeps = {}): CommandSt
void killProcessTree(child.pid, "SIGTERM");
}
forceTimer = setTimeout(() => {
forceTimer = null;
if (child.pid !== undefined) {
void killProcessTree(child.pid, "SIGKILL");
}
@ -388,6 +400,9 @@ export function createCommandExecutor(deps: CommandExecutorDeps = {}): CommandSt
});
});
child.on("close", (code, signal) => {
// Child has actually exited — cancel any pending SIGKILL escalation
// so the timer doesn't fire against a recycled PID.
clearForceTimer();
// If we already settled (e.g. cancel() was called), don't overwrite.
if (settled) return;
const stderrRaw = Buffer.concat(stderrChunks).toString("utf-8").trim();