Files
PaperClipAI/tests/runner-e2e/server-stop.ts
T
DottaandClaude Opus 5.5 0d80b74c21 test(chaos): treat a crash-restart EPERM as a warning once the server pid is dead (#20232)
## 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>
2026-11-09 05:39:40 -06:00

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;
},
});
}