mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:39:44 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The restart chaos harness crashes the local Paperclip server with SIGKILL (K1, K13 storms) and grades how tasks survive > - `crashForRestart` sends SIGKILL to every process group the server owns > - On macOS, `killpg` can report EPERM for a group whose members are still exiting > - When that happens the fault is recorded as failed, and the wave exits 2, even though the server process itself is dead > - This pull request tolerates EPERM in the crash path once the server pid is confirmed dead, and logs it as a warning > - The benefit is that a wave grades the product, not a cleanup race in the harness ## Linked Issues or Issue Description **What happened?** A local Codex K13 storm at concurrency 6 (wave w-20261109T084023-s9403, 112 tasks) exited 2 because faults f04 and f09 failed with `kill EPERM`. The server pids of both faults had died, and the tasks recovered normally. **Steps to reproduce** 1. Run a long K13 storm on macOS with high concurrency. 2. Occasionally the SIGKILL of the server's process groups races with members that are exiting. **Expected behavior** If the server process is dead, the crash completes and the remaining cleanup continues; the EPERM is recorded as a warning. **Actual behavior** The fault fails with `kill EPERM` and the wave reports an error. ## What Changed - `crashForRestart` catches EPERM from the group SIGKILL. It then sends SIGKILL to the server pid directly if needed and waits for it to exit within the forced timeout. - If the server exited, it re-observes the tree and sends SIGKILL to each remaining group on its own, so one group's EPERM does not leave the others to drain gracefully. It logs a warning naming the server pid and any group that still refused SIGKILL, then continues with the normal post-kill cleanup. - If the server is still alive, or the error is not EPERM, the error is rethrown as before. - Four tests: EPERM with a dead server finishes with a warning; a detached descendant is still killed without a graceful drain when the first group kill reports EPERM; EPERM with a surviving server fails; a non-EPERM error fails. The failure tests stop their owners in `finally`. ## Verification - `npx vitest run --config tests/runner-e2e/vitest.config.ts tests/runner-e2e/server-stop.test.ts`: the dead-server test fails before the change (`kill EPERM`), the descendant test fails against the first revision (the descendant drains on SIGTERM), and all 22 pass after it. - `node scripts/typecheck-test-projects.mjs tests/runner-e2e`: clean. ## Risks - A group that is still running and returns EPERM is no longer reported in the crash step itself. The cleanup that follows still checks every observed group, and it fails if any group survives. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
185 lines
9.3 KiB
TypeScript
185 lines
9.3 KiB
TypeScript
import type { ChildProcess } from "node:child_process";
|
|
import { createProcessTreeOwner, incompleteTreeFallback, settleUncertainIdentity, type RestartRunnerIdentity } from "./process-tree-owner.js";
|
|
|
|
// The wrapper receives Playwright's group signal; only it signals Paperclip.
|
|
export const runnerE2EServerDetached = process.platform !== "win32";
|
|
const wait = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
|
|
|
|
export function createRunnerE2EServerStopper(options: {
|
|
gracefulTimeoutMs: number;
|
|
forcedTimeoutMs: number;
|
|
hasSpawnError: (child: ChildProcess) => boolean;
|
|
markExpectedStop: (child: ChildProcess) => void;
|
|
log: (message: string) => void;
|
|
createOwner?: typeof createProcessTreeOwner;
|
|
}) {
|
|
type State = {
|
|
owner: ReturnType<typeof createProcessTreeOwner>;
|
|
promise?: Promise<void>;
|
|
restartPromise?: Promise<void>;
|
|
finalRequested?: boolean;
|
|
finalPhaseStarted?: boolean;
|
|
finalComplete?: boolean;
|
|
gracefulDeadline?: number;
|
|
forcedDeadline?: number;
|
|
gracefulSent: boolean;
|
|
groupSignals: Set<number>;
|
|
escalationLogged: boolean;
|
|
};
|
|
const states = new WeakMap<ChildProcess, State>();
|
|
const watched = new Set<ChildProcess>();
|
|
let finalGraceDeadline: number | undefined;
|
|
let finalForcedDeadline: number | undefined;
|
|
let finalAllPromise: Promise<void> | undefined;
|
|
const exited = (child: ChildProcess) => child.exitCode !== null || child.signalCode !== null || options.hasSpawnError(child);
|
|
function watch(child: ChildProcess) {
|
|
let state = states.get(child);
|
|
if (!state) {
|
|
state = { owner: (options.createOwner ?? createProcessTreeOwner)(child), gracefulSent: false, groupSignals: new Set(), escalationLogged: false };
|
|
states.set(child, state);
|
|
watched.add(child);
|
|
}
|
|
return state;
|
|
}
|
|
async function stopOnce(child: ChildProcess, state: State, signal: NodeJS.Signals, restarting = false) {
|
|
state.gracefulDeadline ??= Date.now() + options.gracefulTimeoutMs;
|
|
// A provider group that tears itself down during the drain can be briefly
|
|
// unproven; wait within the phase deadline, never signaling it meanwhile.
|
|
const grace = () => state.gracefulDeadline!;
|
|
const observe = (until: () => number) => settleUncertainIdentity(() => state.owner.observe(), until);
|
|
await observe(grace);
|
|
if (!exited(child) && !state.gracefulSent) {
|
|
state.gracefulSent = child.kill(signal);
|
|
}
|
|
while (Date.now() < state.gracefulDeadline) {
|
|
await observe(grace);
|
|
if (restarting && state.finalRequested) throw new Error("Final shutdown interrupted controller restart");
|
|
if (exited(child)) {
|
|
if (restarting) {
|
|
state.owner.assertRestartRunnerAlive();
|
|
if (!state.owner.restartCleanupGroups().size) return;
|
|
}
|
|
if (!state.owner.liveGroups().length) { state.owner.stopObserving(); return; }
|
|
// The leader is gone. Retire only descendants whose start identities
|
|
// we observed while they belonged to this server, including private groups.
|
|
const unsignaled = new Set((restarting ? [...state.owner.restartCleanupGroups()] : state.owner.liveGroups().map(group => group.processGroupId)).filter(pid => !state.groupSignals.has(pid)));
|
|
for (const pid of await settleUncertainIdentity(() => state.owner.signal("SIGTERM", unsignaled), grace)) state.groupSignals.add(pid);
|
|
}
|
|
await wait(25);
|
|
}
|
|
if (restarting) throw new Error("Controller restart did not retire non-durable children within its graceful deadline");
|
|
if (!state.escalationLogged) {
|
|
options.log(`\nPaperclip did not stop within ${options.gracefulTimeoutMs}ms; sending SIGKILL\n`);
|
|
state.escalationLogged = true;
|
|
}
|
|
state.forcedDeadline ??= finalForcedDeadline ?? Date.now() + options.forcedTimeoutMs;
|
|
const forced = () => state.forcedDeadline!;
|
|
if (runnerE2EServerDetached) await settleUncertainIdentity(() => state.owner.signal("SIGKILL"), forced);
|
|
else if (!exited(child)) child.kill("SIGKILL");
|
|
do {
|
|
await observe(forced);
|
|
if (exited(child) && !state.owner.liveGroups().length) { state.owner.stopObserving(); return; }
|
|
await wait(25);
|
|
} while (Date.now() < state.forcedDeadline);
|
|
throw new Error("Paperclip server or observed descendants did not exit after SIGKILL");
|
|
}
|
|
const stop = (child: ChildProcess, signal: NodeJS.Signals = "SIGTERM"): Promise<void> => {
|
|
options.markExpectedStop(child);
|
|
const state = watch(child);
|
|
state.finalRequested = true;
|
|
state.promise ??= Promise.resolve().then(async () => {
|
|
if (state.restartPromise && !state.finalPhaseStarted) {
|
|
await state.restartPromise.catch(() => {});
|
|
state.finalPhaseStarted = true;
|
|
// A completed restart is a separate phase, not elapsed cleanup time.
|
|
state.gracefulDeadline = finalGraceDeadline ?? Date.now() + options.gracefulTimeoutMs;
|
|
state.forcedDeadline = undefined;
|
|
state.groupSignals.clear();
|
|
}
|
|
await stopOnce(child, state, signal);
|
|
state.finalComplete = true;
|
|
}).catch(async error => {
|
|
try {
|
|
await incompleteTreeFallback(error, child, state, state.gracefulDeadline!, options.forcedTimeoutMs);
|
|
} finally {
|
|
state.promise = undefined;
|
|
}
|
|
});
|
|
/* Retain the signal/deadline phase so a later audit can recover without
|
|
repeating the graceful signal. */
|
|
return state.promise;
|
|
};
|
|
return Object.assign(stop, {
|
|
assertGracefulExit: (child: ChildProcess): void => {
|
|
const state = states.get(child);
|
|
if (!state?.finalComplete || state.escalationLogged || child.exitCode !== 0 || child.signalCode !== null) {
|
|
throw new Error("Controller did not exit gracefully; forced cleanup cannot qualify a rolling deployment");
|
|
}
|
|
},
|
|
watch: (child: ChildProcess) => watch(child).owner.observe(),
|
|
crashForRestart: (child: ChildProcess): Promise<void> => {
|
|
if (process.platform === "win32") return Promise.reject(new Error("Owned crash restart requires process-group inspection"));
|
|
options.markExpectedStop(child);
|
|
const state = watch(child);
|
|
if (state.finalRequested || state.restartPromise) return Promise.reject(new Error("Restart already requested or final cleanup active"));
|
|
state.restartPromise = (async () => {
|
|
state.gracefulDeadline = Date.now() + options.forcedTimeoutMs;
|
|
try {
|
|
await state.owner.signal("SIGKILL");
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "EPERM") throw error;
|
|
if (!exited(child)) child.kill("SIGKILL");
|
|
while (!exited(child) && Date.now() < state.gracefulDeadline) await wait(25);
|
|
if (!exited(child)) throw error;
|
|
const refused: number[] = [];
|
|
await settleUncertainIdentity(() => state.owner.observe(), () => state.gracefulDeadline!);
|
|
for (const group of state.owner.liveGroups()) {
|
|
try {
|
|
await state.owner.signal("SIGKILL", new Set([group.processGroupId]));
|
|
} catch (groupError) {
|
|
if ((groupError as NodeJS.ErrnoException).code !== "EPERM") throw groupError;
|
|
refused.push(group.processGroupId);
|
|
}
|
|
}
|
|
options.log(`Warning: SIGKILL of the Paperclip process groups reported EPERM after server pid=${child.pid} exited; `
|
|
+ `groups still refusing SIGKILL: ${refused.join(",") || "none"}; continuing cleanup\n`);
|
|
}
|
|
await stopOnce(child, state, "SIGKILL");
|
|
})();
|
|
return state.restartPromise;
|
|
},
|
|
forRestart: (child: ChildProcess, identity: RestartRunnerIdentity): Promise<void> => {
|
|
options.markExpectedStop(child);
|
|
const state = watch(child);
|
|
if (state.finalRequested || state.restartPromise) return Promise.reject(new Error("Restart already requested or final cleanup active"));
|
|
state.restartPromise = (async () => {
|
|
await state.owner.preserveRunnerForRestart(identity);
|
|
options.log(`Controller restart preserves admitted runner pid=${identity.processPid} pgid=${identity.processGroupId} started=${identity.processStartedAt}\n`);
|
|
await stopOnce(child, state, "SIGTERM", true);
|
|
})();
|
|
return state.restartPromise;
|
|
},
|
|
stopAll: (signal: NodeJS.Signals = "SIGTERM"): Promise<void> => {
|
|
// Replacement controller drain precedes old daemon retirement. All
|
|
// generations share the original final-cleanup window, including retries.
|
|
finalGraceDeadline ??= Math.min(Date.now() + options.gracefulTimeoutMs,
|
|
...[...watched].map(child => states.get(child)!).filter(state => state.finalRequested && !state.finalComplete
|
|
&& (!state.restartPromise || state.finalPhaseStarted)).map(state => state.gracefulDeadline ?? Infinity));
|
|
finalForcedDeadline ??= finalGraceDeadline + options.forcedTimeoutMs;
|
|
for (const child of watched) {
|
|
const state = states.get(child)!;
|
|
state.finalRequested = true;
|
|
state.gracefulDeadline ??= finalGraceDeadline;
|
|
}
|
|
finalAllPromise ??= Promise.resolve().then(async () => {
|
|
const failures: unknown[] = [];
|
|
for (const child of [...watched].reverse()) {
|
|
try { await stop(child, signal); } catch (error) { failures.push(error); }
|
|
}
|
|
if (failures.length) throw new AggregateError(failures, "Owned server cleanup incomplete");
|
|
}).catch(error => { finalAllPromise = undefined; throw error; });
|
|
return finalAllPromise;
|
|
},
|
|
});
|
|
}
|