diff --git a/bin/prctl-pdeathsig.c b/bin/prctl-pdeathsig.c new file mode 100644 index 00000000..f5de1c82 --- /dev/null +++ b/bin/prctl-pdeathsig.c @@ -0,0 +1,27 @@ +/* + * prctl-pdeathsig — Linux helper that sets PR_SET_PDEATHSIG to SIGKILL, + * then exec's the remaining arguments. + * + * Usage: prctl-pdeathsig [args...] + * + * Build: gcc -o bin/prctl-pdeathsig bin/prctl-pdeathsig.c + * + * PR_SET_PDEATHSIG (value 1) tells the kernel to send the specified signal + * to the calling process when its parent dies. This is the most reliable + * way to ensure no zombie/detached children survive an agent crash. + */ +#include +#include +#include + +int main(int argc, char *argv[]) { + if (argc < 2) { + return 1; + } + /* Ask kernel to send SIGKILL when parent dies */ + prctl(PR_SET_PDEATHSIG, SIGKILL); + /* Exec the real command, replacing this process */ + execvp(argv[1], argv + 1); + /* If exec fails, just exit */ + return 127; +} diff --git a/src/agent/tools/exec/__tests__/runner.test.ts b/src/agent/tools/exec/__tests__/runner.test.ts index c0f4a758..3b2732d7 100644 --- a/src/agent/tools/exec/__tests__/runner.test.ts +++ b/src/agent/tools/exec/__tests__/runner.test.ts @@ -105,23 +105,22 @@ describe("runner", () => { expect(result.stderr).toContain("ENOENT"); }); - it("kills process tree on timeout", async () => { + it("kills child process on timeout", async () => { const proc = createMockProcess(); mockSpawn.mockReturnValue(proc); - const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); const promise = runCommand("sleep 999", { timeout: 100, maxOutput: 50000 }); vi.advanceTimersByTime(150); + // child.kill("SIGTERM") should have been called on timeout + expect(proc.kill).toHaveBeenCalledWith("SIGTERM"); + // Process gets killed, simulate close proc.emit("close", null, "SIGTERM"); const result = await promise; expect(result.timedOut).toBe(true); - expect(killSpy).toHaveBeenCalledWith(-12345, "SIGTERM"); - - killSpy.mockRestore(); }); it("returns duration in ms", async () => { diff --git a/src/agent/tools/exec/runner.ts b/src/agent/tools/exec/runner.ts index b3bb8ecb..9e1787cf 100644 --- a/src/agent/tools/exec/runner.ts +++ b/src/agent/tools/exec/runner.ts @@ -7,9 +7,38 @@ const log = createLogger("Exec"); const KILL_GRACE_MS = 5000; +/** + * Path to the optional prctl-pdeathsig helper binary (Linux only). + * If present, it is used as a wrapper so that prctl(PR_SET_PDEATHSIG, SIGKILL) + * is called in the child's own context before exec'ing the real command. + * + * Build: gcc -o bin/prctl-pdeathsig src/agent/tools/exec/prctl-pdeathsig.c + */ +const PDEATHSIG_HELPER = new URL("../../../../bin/prctl-pdeathsig", import.meta.url); + export const MAX_CONCURRENT = 10; let activeCount = 0; +/** Registry of all spawned child processes for cleanup on agent stop. */ +const spawnedProcesses = new Set>(); + +/** + * Kill all spawned child processes that are still running. + * Called during agent shutdown to prevent zombie process accumulation. + */ +export function killAllSpawnedProcesses(): void { + for (const child of spawnedProcesses) { + if (child.pid && !child.killed) { + try { + child.kill("SIGKILL"); + } catch { + // Process already dead + } + } + } + spawnedProcesses.clear(); +} + const SAFE_ENV = new Set([ "PATH", "HOME", @@ -73,6 +102,15 @@ export function runCommand( // Use security-provided env whitelist, or fall back to sanitized env const env = securityEnv ?? sanitizeEnv(process.env); + // On Linux, use prctl-pdeathsig helper if available so the kernel kills + // this child automatically when the parent dies (PR_SET_PDEATHSIG). + const usePdeathsig = process.platform === "linux" && fs.existsSync(PDEATHSIG_HELPER); + + const spawnCmd = usePdeathsig + ? (PDEATHSIG_HELPER as unknown as string).replace("file://", "") + : "bash"; + const spawnArgs = usePdeathsig ? ["bash", "-c", command] : ["-c", command]; + const spawnOpts: SpawnOptions & { encoding: string; cwd?: string; env?: NodeJS.ProcessEnv } = { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", @@ -80,12 +118,14 @@ export function runCommand( env, }; - const child = spawn("bash", ["-c", command], spawnOpts); + const child = spawn(spawnCmd, spawnArgs, spawnOpts); + spawnedProcesses.add(child); const finish = (exitCode: number | null, signal: string | null) => { if (resolved) return; resolved = true; activeCount--; + spawnedProcesses.delete(child); clearTimeout(timeoutTimer); clearTimeout(killTimer); resolve({ @@ -137,22 +177,18 @@ export function runCommand( const timeoutTimer = setTimeout(() => { timedOut = true; log.warn({ command, timeout }, "Command timed out, sending SIGTERM"); - if (child.pid != null) { - try { - process.kill(-child.pid, "SIGTERM"); - } catch { - // Process already dead - } + try { + child.kill("SIGTERM"); + } catch { + // Process already dead } killTimer = setTimeout(() => { log.warn({ command }, "Grace period expired, sending SIGKILL"); - if (child.pid != null) { - try { - process.kill(-child.pid, "SIGKILL"); - } catch { - // Process already dead - } + try { + child.kill("SIGKILL"); + } catch { + // Process already dead } }, KILL_GRACE_MS); }, timeout); diff --git a/src/index.ts b/src/index.ts index 066aadf8..4ea41525 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,6 +53,7 @@ import type { AutonomousTaskManager } from "./autonomous/manager.js"; import { initMetrics } from "./services/metrics.js"; import { initAnalytics } from "./services/analytics.js"; import { flushOffsets } from "./telegram/offset-store.js"; +import { killAllSpawnedProcesses } from "./agent/tools/exec/runner.js"; const log = createLogger("App"); @@ -1507,6 +1508,9 @@ ${blue} ┌────────────────────── } catch (e) { log.error({ err: e }, "⚠️ Bridge disconnect failed"); } + + // Kill any remaining child processes (detached or long-running exec tasks) + killAllSpawnedProcesses(); } } @@ -1533,6 +1537,12 @@ export async function main(configPath?: string): Promise { process.exit(1); }); + // Safety net: kill any remaining child processes on forced exit + // (covers paths where stopAgent() is never called, e.g. process.exit(1)) + process.on("exit", () => { + killAllSpawnedProcesses(); + }); + // Handle graceful shutdown with timeout safety net let shutdownInProgress = false; const gracefulShutdown = async () => {