Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions bin/prctl-pdeathsig.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* prctl-pdeathsig — Linux helper that sets PR_SET_PDEATHSIG to SIGKILL,
* then exec's the remaining arguments.
*
* Usage: prctl-pdeathsig <command> [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 <sys/prctl.h>
#include <signal.h>
#include <unistd.h>

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;
}
9 changes: 4 additions & 5 deletions src/agent/tools/exec/__tests__/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
62 changes: 49 additions & 13 deletions src/agent/tools/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof spawn>>();

/**
* 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",
Expand Down Expand Up @@ -73,19 +102,30 @@ 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",
cwd: spawnCwd,
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({
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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();
}
}

Expand All @@ -1533,6 +1537,12 @@ export async function main(configPath?: string): Promise<void> {
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 () => {
Expand Down
Loading