Files
PaperClipAI/tests/runner-e2e/server-stop.test.ts
T
DottaandPaperclip 16787e4167 Qualify rolling web deployments and preserve Stop through handoff (#18526)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The web server owns request handling and remote execution
coordination.
> - Deployment must start a ready replacement while the old server still
serves.
> - An outage test does not prove that two live servers can overlap
safely.
> - This pull request adds a separate Product E2E cell for that
boundary.
> - The existing run, file, recovery, and retained-session checks still
apply.

## Linked Issues or Issue Description

Refs #18518. The runtime prerequisite is merged; this PR qualifies its
ephemeral server and deployment contracts.

## What Changed

- Add the explicit `rolling-web-deployment` Codex Daytona workflow.
- Start independent A/B/C homes and compiled builds behind a test
ingress.
- Wait for readiness before routing, then drain and delete the old home.
- Observe active authority before handoff and use the existing read-only
recovery oracle.
- Require ordinary reads and saves to succeed throughout replacement.
Read back every accepted save exactly once.
- Preserve the five-minute outage suites and their existing validators.
- Add a model-free supervisor smoke and an unready replacement control.
- Require graceful exit before deleting an old home; forced cleanup
cannot satisfy the oracle.
- Preserve a durable Stop through lease release/expiry until the
recovered execution owner acknowledges it. Permit only fenced
reattachment of the original execution for Stop cleanup; submit no new
turn.
- Exclude events saved by A during shutdown from evidence of B making
progress.

## Verification

- E2E TypeScript check passed.
- Affected harness checks: 143 passed. Catalog, fixture and report
checks: 120 passed.
- Actual supervisor smoke passed two replacements: 436 reads, 45 saves,
zero request failures, and confirmed owned resource cleanup.
- B became ready in 18.7 seconds; C became ready in 109.2 seconds. A
prior C timeout remains failed evidence.
- Fresh browser proof: 11 checks passed, 246 reads, 50 acknowledged
saves, zero failures, 1,058ms reconnect, both old homes deleted and
cleanup confirmed.
- Stop lease-gap regression: three PostgreSQL delivery tests passed with
a simulated provider edge. Current server TypeScript build/vendoring
passed.
- Full harness: 5,814 passed, 17 failed, four skipped. Two shutdown
races passed the focused file; two report timeouts passed an isolated
42-test rerun. Onboarding/workflow-security/Pi/date-expired fixtures are
unchanged on inspected master; the host embedded PostgreSQL fixture
remains a setup limitation. This is not a green full local suite.
- CI at `172509b8be` passed all gates. Greptile is 5/5 with every review
thread resolved. The earlier access-service race failure passed its
isolated regression and the subsequent full CI.
- Final review corrections: six focused executor cases, three PostgreSQL
command-delivery cases, 19 harness cases and server/E2E typechecks
passed.
- Three live attempts failed before agent creation: two harness setup
gaps were fixed, then provider authentication failed. The saved OpenAI
key returns 401; a refresh is pending. The Daytona probe reported
cleanup uncertain, and no sandbox with that attempt label was found
through the authenticated staging manager. No live model execution is
claimed.
- Live Runner overlap, released-container qualification and hosted
deployment proof remain pending. Merging this opt-in qualification lane
does not claim those results.

## Risks

- The source lane shares workspace dependencies and does not establish
container packaging correctness.
- The live workflow needs an explicitly owned S3 bucket, PostgreSQL,
pinned remote image and provider credentials.
- Qualification cleanup stops all owned processes after failure. A
failed replacement must not be reported as a pass.

## Model Used

OpenAI Codex, GPT-6. Tool use, code execution and repository review. The
runtime does not expose a more specific model ID or context-window size.

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-01 02:53:27 -06:00

378 lines
17 KiB
TypeScript

import { spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, expect, it } from "vitest";
import { createRunnerE2EServerStopper, runnerE2EServerDetached } from "./server-stop.js";
import { readProcessTable } from "./process-tree.js";
import { createProcessTreeOwner, stopOwnedProcessTree } from "./process-tree-owner.js";
import { runnerE2ETypeScriptProcessArgs } from "./web-server-command.js";
const children: ChildProcess[] = [];
const directories: string[] = [];
const worker = `
process.once('SIGTERM', () => {
process.send('stopping');
process.once('message', () => {
process.send('close-complete', () => process.exit(0));
});
});
process.send('ready');
setInterval(() => {}, 1000);
`;
async function fixture(source = worker) {
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-server-stop-"));
directories.push(root);
const entry = path.join(root, "worker.cjs");
await writeFile(entry, source);
return { root, entry };
}
function start(entry: string) {
const child = spawn(process.execPath, [entry], {
detached: runnerE2EServerDetached,
stdio: ["ignore", "pipe", "pipe", "ipc"],
});
children.push(child);
return child;
}
function stopper(gracefulTimeoutMs = 2_000) {
const logs: string[] = [];
const errors = new WeakSet<ChildProcess>();
const stop = createRunnerE2EServerStopper({
gracefulTimeoutMs, forcedTimeoutMs: 2_000,
hasSpawnError: child => errors.has(child),
markExpectedStop: () => {}, log: line => logs.push(line),
});
return { stop, logs, errors };
}
function expectSuccessfulWrapperReceipt(wrapper: ChildProcess, messages: unknown[], diagnostics = "") {
expect([wrapper.exitCode, wrapper.signalCode], diagnostics).toEqual([0, null]);
expect(messages, diagnostics).toContainEqual({ exitCode: 0, signalCode: null });
}
afterEach(async () => {
for (const child of children.splice(0)) {
if (child.exitCode !== null || child.signalCode !== null) continue;
const exited = once(child, "exit").catch(() => {});
try {
if (runnerE2EServerDetached && child.pid) process.kill(-child.pid, "SIGKILL");
else child.kill("SIGKILL");
} catch { child.kill("SIGKILL"); }
await exited;
}
for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true });
});
it.skipIf(process.platform === "win32")("joins signal, final cleanup and repeated calls while a once-handler finishes asynchronous close", async () => {
const { entry } = await fixture();
const child = start(entry);
expect((await once(child, "message"))[0]).toBe("ready");
const { stop, logs } = stopper();
const stopping = once(child, "message");
const first = stop(child);
expect((await stopping)[0]).toBe("stopping");
// The server supervisor reaches final cleanup after its 200ms poll.
await new Promise(resolve => setTimeout(resolve, 250));
const messages: unknown[] = [];
child.on("message", message => messages.push(message));
const finalCleanup = stop(child);
const repeated = stop(child, "SIGINT");
// Give an erroneous repeated signal time to interrupt the held close before
// releasing it. A behavioral failure must not depend on promise identity.
await new Promise(resolve => setTimeout(resolve, 50));
if (child.connected) child.send("finish");
await Promise.all([first, finalCleanup, repeated]);
expect(messages).toContain("close-complete");
expect([child.exitCode, child.signalCode]).toEqual([0, null]);
expect(finalCleanup).toBe(first);
expect(repeated).toBe(first);
expect(stop(child)).toBe(first);
await stop(child);
expect(logs).toEqual([]);
expect(() => stop.assertGracefulExit(child)).not.toThrow();
});
it.skipIf(process.platform === "win32")("bounds an unresponsive child and reaps it with one escalation", async () => {
const { entry } = await fixture(`process.on('SIGTERM', () => {}); process.send('ready'); setInterval(() => {}, 1000);`);
const child = start(entry);
await once(child, "message");
const { stop, logs } = stopper(50);
await Promise.all([stop(child), stop(child), stop(child)]);
expect(child.signalCode).toBe("SIGKILL");
expect(() => stop.assertGracefulExit(child)).toThrow("forced cleanup cannot qualify");
expect(logs).toHaveLength(1);
expect(() => process.kill(child.pid!, 0)).toThrow();
});
it("settles an already-exited child without signals or escalation", async () => {
const { entry } = await fixture("process.exit(0)");
const child = start(entry);
await once(child, "exit");
const { stop, logs } = stopper();
await stop(child);
expect([child.exitCode, child.signalCode, logs]).toEqual([0, null, []]);
});
it("settles a failed spawn without attempting escalation", async () => {
const { root } = await fixture();
const child = spawn(path.join(root, "missing-server-executable"));
const { stop, logs, errors } = stopper();
child.once("error", () => errors.add(child));
await new Promise<void>(resolve => child.once("error", () => resolve()));
await stop(child);
expect(logs).toEqual([]);
});
it.skipIf(process.platform === "win32")("lets launcher cancellation join wrapper shutdown without signaling the server twice", async () => {
const { root, entry } = await fixture();
const supervisor = path.join(root, "supervisor.mts");
const helper = path.join(import.meta.dirname, "server-stop.ts");
await writeFile(supervisor, `
import { spawn } from 'node:child_process';
import { createRunnerE2EServerStopper, runnerE2EServerDetached } from ${JSON.stringify(helper)};
const child = spawn(process.execPath, [${JSON.stringify(entry)}], {
detached: runnerE2EServerDetached, stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
});
const stop = createRunnerE2EServerStopper({ gracefulTimeoutMs: 2000, forcedTimeoutMs: 2000,
hasSpawnError: () => false, markExpectedStop: () => {}, log: () => {} });
const finish = () => { void stop(child).then(() => {
process.send!({ exitCode: child.exitCode, signalCode: child.signalCode }, () => process.exit(0));
}); };
process.on('SIGTERM', finish);
process.on('message', message => { if (message === 'stop') finish(); else child.send('finish'); });
child.on('message', message => process.send!({ message, pid: child.pid }));
`);
const wrapper = spawn(process.execPath, runnerE2ETypeScriptProcessArgs(path.resolve(import.meta.dirname, "../.."), supervisor), {
detached: true, stdio: ["ignore", "pipe", "pipe", "ipc"],
});
children.push(wrapper);
let wrapperErrors = "";
wrapper.stderr?.on("data", chunk => { wrapperErrors += chunk; });
let workerPid: number | undefined;
const owner = createProcessTreeOwner(wrapper);
try {
workerPid = (await once(wrapper, "message"))[0].pid;
const stopping = once(wrapper, "message");
wrapper.send("stop");
expect((await stopping)[0].message).toBe("stopping");
// If Paperclip shared the wrapper group, this would kill its once-handler
// during the async close even though the helper sends only one signal.
const messages: unknown[] = [];
wrapper.on("message", message => messages.push(message));
// Process exit is not IPC completion. Register both barriers before
// cancellation so the final worker receipt is drained before asserting it.
const closed = once(wrapper, "close");
const disconnected = once(wrapper, "disconnect");
await owner.observe();
const cancellation = stopOwnedProcessTree(wrapper, owner, 2_000, 2_000);
await new Promise(resolve => setTimeout(resolve, 250));
if (wrapper.connected) wrapper.send("finish");
await Promise.all([closed, disconnected]);
await cancellation;
const diagnostics = `${wrapperErrors}\nwrapper exit=${wrapper.exitCode} signal=${wrapper.signalCode} connected=${wrapper.connected}`;
expect(messages, diagnostics).toContainEqual({ message: "close-complete", pid: workerPid });
expectSuccessfulWrapperReceipt(wrapper, messages, diagnostics);
expect(() => process.kill(workerPid!, 0)).toThrow();
} finally {
owner.stopObserving();
if (workerPid) { try { process.kill(-workerPid, "SIGKILL"); } catch { /* reaped */ } }
}
});
it.each(["failed", "missing"] as const)("rejects a %s worker receipt even when the wrapper closes successfully", async receipt => {
const { entry } = await fixture(receipt === "failed"
? "process.send({ exitCode: 23, signalCode: null }, () => process.exit(0));"
: "process.exit(0);");
const wrapper = start(entry);
const messages: unknown[] = [];
wrapper.on("message", message => messages.push(message));
const closed = once(wrapper, "close");
const disconnected = once(wrapper, "disconnect");
await Promise.all([closed, disconnected]);
expect([wrapper.exitCode, wrapper.signalCode]).toEqual([0, null]);
expect(messages).toEqual(receipt === "failed" ? [{ exitCode: 23, signalCode: null }] : []);
expect(() => expectSuccessfulWrapperReceipt(wrapper, messages)).toThrow();
});
it.skipIf(process.platform === "win32")("escalates the owned server group so a hung descendant cannot remain", async () => {
const { entry } = await fixture(`
const { spawn } = require('node:child_process');
const descendant = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
process.on('SIGTERM', () => {});
process.send({ pid: descendant.pid });
setInterval(() => {}, 1000);
`);
const child = start(entry);
const pid = (await once(child, "message"))[0].pid as number;
try {
await stopper(50).stop(child);
expect(child.signalCode).toBe("SIGKILL");
// A stopped orphan may remain a zombie until the host's init reaps it.
const table = await readProcessTable();
expect(table).not.toBeNull();
expect(table!.some(row => row.pid === pid && !row.state?.startsWith("Z"))).toBe(false);
} finally {
try { process.kill(pid, "SIGKILL"); } catch { /* reaped */ }
}
});
it.skipIf(process.platform === "win32")("retires observed descendants after the server leader exits early", async () => {
const { root, entry } = await fixture();
const marker = path.join(root, "descendant-closed");
const descendant = `
process.once('SIGTERM', () => setTimeout(() => {
require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'closed'); process.exit(0);
}, 50));
process.send('ready'); setInterval(() => {}, 1000);
`;
await writeFile(entry, `
const child = require('node:child_process').spawn(process.execPath, ['-e', ${JSON.stringify(descendant)}], {
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
});
child.once('message', () => process.send({ pid: child.pid }));
process.once('message', () => process.exit(1));
`);
const child = start(entry);
const pid = (await once(child, "message"))[0].pid as number;
const { stop } = stopper();
try {
await stop.watch(child);
const exited = once(child, "exit");
child.send("crash");
await exited;
await stop(child);
expect(await readFile(marker, "utf8")).toBe("closed");
const table = await readProcessTable();
expect(table).not.toBeNull();
expect(table!.some(row => row.pid === pid && !row.state?.startsWith("Z"))).toBe(false);
} finally { try { process.kill(pid, "SIGKILL"); } catch { /* stopped */ } }
});
it.skipIf(process.platform === "win32")("recovers a failed cleanup without repeating the graceful signal", async () => {
const { entry } = await fixture();
const child = start(entry);
await once(child, "message");
let failInspection = false;
let markInspectionFailed!: () => void;
const inspectionFailed = new Promise<void>(resolve => { markInspectionFailed = resolve; });
let owner: ReturnType<typeof createProcessTreeOwner> | undefined;
const messages: unknown[] = [];
child.on("message", message => {
messages.push(message);
if (message === "stopping") failInspection = true;
});
const stop = createRunnerE2EServerStopper({
gracefulTimeoutMs: 2_000, forcedTimeoutMs: 2_000,
hasSpawnError: () => false, markExpectedStop: () => {}, log: () => {},
createOwner: candidate => {
owner = createProcessTreeOwner(candidate);
return { ...owner, observe: async () => {
if (failInspection) {
failInspection = false;
markInspectionFailed();
throw new Error("transient inspection failure");
}
await owner!.observe();
} };
},
});
try {
const failed = expect(stop(child)).rejects.toThrow("transient inspection failure");
// Hold the child until the one-shot failure is actually injected. A fixed
// delay can let it exit during the preceding successful inspection.
await inspectionFailed;
if (child.connected) child.send("finish");
await failed;
const recovery = stop(child);
// A second graceful signal would terminate this child's once-handler.
await new Promise(resolve => setTimeout(resolve, 50));
if (child.connected) child.send("finish");
await recovery;
expect(messages).toEqual(["stopping", "close-complete"]);
expect([child.exitCode, child.signalCode]).toEqual([0, null]);
} finally { owner?.stopObserving(); }
});
it.skipIf(process.platform === 'win32').each([['launcher', 'null'], ['server', 'null'], ['launcher', 'throw'], ['server', 'throw']] as const)(
'bounds the known %s child when inspection fails with %s', async (kind, failure) => {
const { entry } = await fixture(`process.on('SIGTERM', () => {}); process.send('ready'); setInterval(() => {}, 1000);`);
const child = start(entry);
await once(child, 'message');
const groupSignals: number[] = [];
const owner = createProcessTreeOwner(child, {
readTable: async () => { if (failure === "throw") throw new Error("inspection failed"); return null; },
signalGroup: pid => { groupSignals.push(pid); },
});
try {
const stopping = kind === 'launcher'
? stopOwnedProcessTree(child, owner, 50, 500)
: createRunnerE2EServerStopper({ gracefulTimeoutMs: 50, forcedTimeoutMs: 500,
hasSpawnError: () => false, markExpectedStop: () => {}, log: () => {}, createOwner: () => owner })(child);
await expect(stopping).rejects.toThrow(/inspect|incomplete/i);
expect(child.signalCode).toBe('SIGKILL');
expect(groupSignals).toEqual([]);
} finally { owner.stopObserving(); }
},
);
it.skipIf(process.platform === 'win32')('does not re-signal a server after its graceful wrapper forwards and exits', async () => {
const { root, entry } = await fixture();
const marker = path.join(root, 'async-close-finished');
await writeFile(entry, `
const child = require('node:child_process').spawn(process.execPath, ['-e', ${JSON.stringify(`
process.once('SIGTERM', () => setTimeout(() => {
require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'closed'); process.exit(0);
}, 300));
process.send('ready'); setInterval(() => {}, 1000);
`)}], { detached: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
child.once('message', () => process.send({ pid: child.pid }));
process.once('SIGTERM', () => { child.kill('SIGTERM'); process.exit(0); });
`);
const wrapper = start(entry);
const pid = (await once(wrapper, 'message'))[0].pid as number;
const owner = createProcessTreeOwner(wrapper);
try {
await owner.observe();
await stopOwnedProcessTree(wrapper, owner, 2_000, 500);
expect(await readFile(marker, 'utf8')).toBe('closed');
expect(wrapper.exitCode).toBe(0);
} finally {
owner.stopObserving();
try { process.kill(-pid, 'SIGKILL'); } catch { /* already stopped */ }
}
});
it.skipIf(process.platform === "win32")("crashes the owned controller and detached descendant without graceful drain or touching a neighboring process", async () => {
const { root } = await fixture();
const marker = path.join(root, "graceful-drain.txt"), descendantEntry = path.join(root, "descendant.cjs");
await writeFile(descendantEntry, `
process.on('SIGTERM', () => require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'descendant drained'));
process.send('ready'); setInterval(() => {}, 1000);
`);
const { entry } = await fixture(`
const child = require('node:child_process').fork(${JSON.stringify(descendantEntry)}, [], { detached: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
process.on('SIGTERM', () => require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'controller drained'));
child.once('message', () => process.send({ ready: true, descendantPid: child.pid }));
setInterval(() => {}, 1000);
`);
const controller = start(entry), controllerReady = once(controller, "message");
const { stop } = stopper();
await stop.watch(controller);
try {
const neighborFixture = await fixture(), neighbor = start(neighborFixture.entry);
await once(neighbor, "message");
const [ready] = await controllerReady;
await stop.crashForRestart(controller);
expect(controller.signalCode).toBe("SIGKILL");
await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" });
const table = await readProcessTable();
expect(table).not.toBeNull();
expect(table!.some(row => row.pid === ready.descendantPid && !row.state?.startsWith("Z"))).toBe(false);
expect(() => process.kill(neighbor.pid!, 0)).not.toThrow();
await stop.stopAll();
expect(() => process.kill(neighbor.pid!, 0)).not.toThrow();
} finally { await stop.stopAll(); }
});