mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 16:49:00 +01:00
## 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>
378 lines
17 KiB
TypeScript
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(); }
|
|
});
|