Files
open-design/apps/daemon/tests/retry-orphan-process-group.test.ts
88b411efd7 fix(daemon): reap the failed attempt's process group on same-run retry (#5463)
* fix(daemon): reap the failed attempt's process group on same-run retry

A same-run retry tore down the failing attempt with run.child.kill('SIGTERM') —
a signal to the direct child PID only. Killing a parent does not kill its
children on POSIX, so any descendants the CLI spawned (MCP servers, tool
subprocesses, internal runners) were orphaned (re-parented to PID 1) and kept
running. One process group leaked per retry, accumulating over the daemon's
lifetime. The cancel path already disbands the whole group via
process.kill(-pgid); the daemon even spawns detached and captures
run.processGroupId, but the retry teardown never used it.

Add signalProcessGroup(pgid, signal) — a group signal that works even after the
direct child object has exited (grandchildren outlive it), kept SEPARATE from
signalChildProcess so the shared cancel/escalation path keeps its childHasExited
guard against the cross-generation kill fixed in #5202. reapProcessGroup(pgid)
does SIGTERM then a SIGKILL escalation, both bound to the CAPTURED pgid.

tearDownAttemptForRetry now snapshots the attempt's child + pgid, reaps its
group by the captured pgid (falling back to a direct-child signal on win32 / no
pgid), and clears run.processGroupId so the next attempt captures cleanly. The
escalation targets the captured pgid, never the shared field the next attempt
overwrites — so it cannot hit the healthy new attempt's group (#5202).

Fixes #5462.

Regression: tests/retry-orphan-process-group.test.ts drives a full run over the
HTTP API with a fake CLI that spawns a long-lived grandchild then stalls into a
retryable timeout; it asserts the grandchild is dead after the run finishes. Red
on origin/main (grandchild survives, PPID=1), green with this fix. Verified live
on a real daemon: a 20x retry stress leaked 20/20 grandchildren before the fix
and 0/20 after, with every run still completing 'succeeded'.

* fix(daemon): reap the process group on cancel/shutdown when the child already exited

Secondary case from #5462: when the parent CLI has already exited but a
grandchild still holds the inherited stdio pipe (run still running), cancel
returned 200 and finished the run canceled, but the grandchild survived — the
same childHasExited short-circuit in signalChildProcess that the retry teardown
hit. killChild (used only by the terminating cancel / shutdownActive paths, never
by a re-spawning retry) now falls back to signalProcessGroup by pgid when the
direct child has already exited, so cancel/shutdown reap surviving descendants
too. No cross-generation risk: both callers terminate and never re-spawn into
the pgid (cf. #5202).

Covered by the existing cancel/shutdown/lifecycle suites staying green (no
regression); the retry-orphan regression exercises the shared signalProcessGroup
helper end-to-end. A dedicated cancel-after-parent-exit HTTP regression can be
added if preferred — the forensics harness confirms this case manually.

---------

Co-authored-by: tomsen-ai <230283659+tomsen-ai@users.noreply.github.com>
2026-07-13 04:25:49 +00:00

254 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Server } from 'node:http';
import { randomUUID } from 'node:crypto';
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';
// Red spec: a same-run retry must not orphan the failed attempt's descendant
// processes.
//
// The daemon spawns agent CLIs detached (process-group leader) and captures
// run.processGroupId precisely so teardown can signal the WHOLE group — the
// cancel path does this (runs.ts signalChildProcess: process.kill(-pgid)).
// But tearDownAttemptForRetry (server.ts) only calls run.child.kill('SIGTERM')
// on the direct child. Any grandchild the CLI spawned (MCP servers, tool
// subprocesses, internal runners) survives, reparented to PID 1, and keeps
// running forever. Every watchdog-triggered same-run retry leaks exactly one
// such orphan; a 20-run live loop against a real daemon leaked 20/20.
// The forced-shutdown escalation timers cannot save this: the old child's
// close handler clears them (clearForcedChildShutdown), and even when they
// fire, signalChildProcess bails on the childHasExited(child) guard because
// the direct child is already dead — so the group signal is never sent.
type StartedServer = {
url: string;
server: Server;
shutdown?: () => Promise<void> | void;
};
type RunStatus = {
id: string;
status: string;
exitCode: number | null;
signal: string | null;
};
describe('same-run retry orphaned process group', () => {
const originalEnv = snapshotEnv();
let started: StartedServer | null = null;
let binDir: string | null = null;
let leakedPids: number[] = [];
afterEach(async () => {
// Never leave test orphans behind, pass or fail.
for (const pid of leakedPids) {
try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ }
}
leakedPids = [];
await Promise.resolve(started?.shutdown?.());
if (started?.server) {
await new Promise<void>((resolve) => started?.server.close(() => resolve()));
}
started = null;
if (binDir) await rm(binDir, { recursive: true, force: true });
binDir = null;
restoreEnv(originalEnv);
});
it('kills the failed attempt’s descendants when the same-run retry tears it down', async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-retry-orphan-bin-'));
const { bin: fakeClaude, grandchildPidPath } = await writeOrphaningClaude(binDir, 'claude-orphan');
delete process.env.POSTHOG_KEY;
delete process.env.POSTHOG_HOST;
delete process.env.LANGFUSE_PUBLIC_KEY;
delete process.env.LANGFUSE_SECRET_KEY;
delete process.env.LANGFUSE_BASE_URL;
delete process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL;
// Trip the no-output inactivity watchdog quickly so the first (silent)
// attempt fails at first_token_wait and the same-run retry fires. Kept
// comfortably above node cold-start so the retry attempt's own watchdog
// does not also trip before it emits its first token.
process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = '1200';
started = await startServer({ port: 0, returnServer: true }) as StartedServer;
await putConfig(started.url, {
agentId: 'claude',
agentCliEnv: { claude: { CLAUDE_BIN: fakeClaude } },
telemetry: { metrics: true, content: false, artifactManifest: false },
privacyDecisionAt: Date.now(),
});
const run = await createAndWaitForRun(started.url);
// The retry itself recovers the run — that part works.
expect(run.status).toBe('succeeded');
// The failed attempt spawned one descendant inside its process group.
const grandchildPid = Number((await readFile(grandchildPidPath, 'utf8')).trim());
expect(Number.isInteger(grandchildPid)).toBe(true);
leakedPids.push(grandchildPid);
// Teardown of the failed attempt must take its whole process group with
// it (the cancel path already does: process.kill(-pgid)). Poll so a
// correct implementation passes as soon as the group signal lands; on
// buggy main the grandchild is still alive (reparented to PID 1) long
// after the run finished.
const dead = await waitForProcessExit(grandchildPid, 3_000);
expect(
dead,
`grandchild ${grandchildPid} of the torn-down retry attempt is still alive ` +
`${3_000}ms after the run finished — same-run retry teardown leaked its process group`,
).toBe(true);
});
});
async function writeOrphaningClaude(
dir: string,
name: string,
): Promise<{ bin: string; grandchildPidPath: string }> {
const bin = path.join(dir, name);
const counterPath = path.join(dir, `${name}-attempts`);
const grandchildPidPath = path.join(dir, `${name}-grandchild-pid`);
await writeFile(bin, `#!/usr/bin/env node
const fs = require('node:fs');
const { spawn } = require('node:child_process');
const counterPath = ${JSON.stringify(counterPath)};
const grandchildPidPath = ${JSON.stringify(grandchildPidPath)};
if (process.argv.includes('--version')) { fs.writeSync(1, 'claude-code 1.0.0-orphan\\n'); process.exit(0); }
if (process.argv.includes('--help')) { fs.writeSync(1, 'Usage: claude -p\\n'); process.exit(0); }
// Auxiliary daemon invocations (memory extraction / title generation) must
// not consume the chat-attempt counter.
if (!process.argv.includes('--session-id') && !process.argv.includes('--resume')) {
fs.writeSync(1, '{"entries":[]}');
process.exit(0);
}
let attempts = 0;
try { attempts = Number(fs.readFileSync(counterPath, 'utf8')) || 0; } catch {}
fs.writeFileSync(counterPath, String(attempts + 1));
if (attempts === 0) {
// First attempt: spawn a long-lived descendant in OUR process group (we are
// the group leader because the daemon spawns agents detached), record its
// PID, then silent-stall so the inactivity watchdog fails this attempt as a
// retryable first-token stall. The watchdog's SIGTERM kills us (default
// node behavior); a correct teardown also signals our process group and
// takes the descendant with us.
const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
fs.writeFileSync(grandchildPidPath, String(grandchild.pid));
setTimeout(() => process.exit(0), 120000);
} else {
fs.writeSync(1, JSON.stringify({ type: 'system', subtype: 'init', model: 'claude-orphan-test' }) + '\\n');
fs.writeSync(1, JSON.stringify({
type: 'assistant',
message: { id: 'msg-orphan-recovered', content: [{ type: 'text', text: 'Recovered after retry.' }], stop_reason: 'end_turn' },
}) + '\\n');
setTimeout(() => process.exit(0), 20);
}
`, 'utf8');
await chmod(bin, 0o755);
return { bin, grandchildPidPath };
}
function processAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitForProcessExit(pid: number, timeoutMs: number): Promise<boolean> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (!processAlive(pid)) return true;
await delay(100);
}
return !processAlive(pid);
}
function snapshotEnv(): Record<string, string | undefined> {
return {
LANGFUSE_PUBLIC_KEY: process.env.LANGFUSE_PUBLIC_KEY,
LANGFUSE_SECRET_KEY: process.env.LANGFUSE_SECRET_KEY,
LANGFUSE_BASE_URL: process.env.LANGFUSE_BASE_URL,
OPEN_DESIGN_TELEMETRY_RELAY_URL: process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL,
POSTHOG_KEY: process.env.POSTHOG_KEY,
POSTHOG_HOST: process.env.POSTHOG_HOST,
OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS: process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS,
};
}
function restoreEnv(env: Record<string, string | undefined>): void {
for (const [key, value] of Object.entries(env)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
async function putConfig(url: string, patch: Record<string, unknown>): Promise<void> {
const response = await fetch(`${url}/api/app-config`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch),
});
expect(response.status).toBe(200);
}
async function createAndWaitForRun(url: string): Promise<RunStatus> {
const projectId = `retry_orphan_${randomUUID()}`;
const projectResponse = await fetch(`${url}/api/projects`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Retry orphan process group probe',
metadata: { kind: 'prototype' },
skipDiscoveryBrief: true,
}),
});
expect(projectResponse.status).toBe(200);
const projectBody = await projectResponse.json() as { conversationId: string };
const runResponse = await fetch(`${url}/api/runs`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-od-analytics-device-id': 'retry-orphan-test',
'x-od-analytics-session-id': 'retry-orphan-session',
'x-od-analytics-client-type': 'web',
},
body: JSON.stringify({
projectId,
conversationId: projectBody.conversationId,
assistantMessageId: `assistant_orphan_${randomUUID()}`,
clientRequestId: `client_orphan_${randomUUID()}`,
agentId: 'claude',
message: 'please do not orphan my descendants',
currentPrompt: 'please do not orphan my descendants',
}),
});
expect(runResponse.status).toBe(202);
const body = await runResponse.json() as { runId: string };
return await waitForRun(url, body.runId);
}
async function waitForRun(url: string, runId: string): Promise<RunStatus> {
const startedAt = Date.now();
while (Date.now() - startedAt < 15_000) {
const response = await fetch(`${url}/api/runs/${encodeURIComponent(runId)}`);
expect(response.status).toBe(200);
const run = await response.json() as RunStatus;
if (run.status === 'failed' || run.status === 'succeeded' || run.status === 'canceled') {
return run;
}
await delay(100);
}
throw new Error(`run ${runId} did not finish`);
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}