mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* 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>
254 lines
10 KiB
TypeScript
254 lines
10 KiB
TypeScript
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));
|
||
}
|