Files
PaperClipAI/tests/restart-chaos/supervisor.ts
T
DottaandClaude Opus 5.5 068d814735 test(chaos): run waves in Cloud's volume-free server mode; make W2 phase 1 yield (#20930)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The restart chaos harness (`tests/restart-chaos/`) runs real agent
tasks against an isolated control plane and injects faults
> - Cloud servers run in the volume-free mode
(`PAPERCLIP_EPHEMERAL_SERVER=true`, durable agent files, run logs in
object storage)
> - The harness never set that mode, so every wave ran a different
server mode from staging. Paths that exist only in that mode were never
tested. One of them is the per-agent directory operation lock, so
`--fail-recovery-step directory-lock` could never reach its step with
any workload
> - The re-wake workloads W2 and W2C also lost coverage: an agent that
reported phase 1 with disposition `done` closed the task, and the
harness never sent the re-wake (four of eight Claude cells on
2026-11-11)
> - This pull request adds `--ephemeral-server` and makes the W2/W2C
brief ask for a `yielded` phase-1 report
> - The benefit is that chaos waves can run in staging's server mode and
the re-wake cells test the re-wake on every provider

## Linked Issues or Issue Description

**What is the problem?**
The restart chaos harness cannot run the control plane in the
volume-free mode that Cloud and staging use. The `directory-lock`
recovery step (`agent_directory.operation_lock.before_acquire`) is
reached only for disposable remote copies, which need
`PAPERCLIP_EPHEMERAL_SERVER=true`. W2 and W2C cells also stop before the
re-wake when the agent reports phase 1 as `done`.

**What is the expected behavior?**
A wave can opt in to the volume-free mode on Daytona, and every restart
of the wave keeps that mode. The W2 and W2C cells reach the re-wake.

**Steps to reproduce**
Run `--fail-recovery-step
agent_directory.operation_lock.before_acquire:1` on Daytona with any
workload: the check `recovery_step_failed_once` reports "never reached".
Run W2 on Claude: the first run ends with the task `done` and
`rewake_sent` is "never sent".

## What Changed

- `assertIsolatedServerEnvironment` (`tests/runner-e2e/harness-env.ts`)
takes `expected.generatedSecretsMasterKey`. It admits
`PAPERCLIP_SECRETS_MASTER_KEY` only when that parameter is set and
equals the value exactly. `PAPERCLIP_SECRETS_MASTER_KEY_FILE` stays
refused. The check runs over the final server environment.
- `--k15-fresh-home` implies `--ephemeral-server` and defaults
`--storage` to `s3`. A fresh instance home without the volume-free mode
loses the agents' instruction bundles, which staging keeps in Postgres
and object storage.
- `--ephemeral-server` (`config.ts`, `types.ts`, `main.ts`,
`supervisor.ts`, new `ephemeral-server.ts` with its unit test
`ephemeral-server.test.ts`): it needs `--storage s3` and `--env daytona`
and refuses K14. Every server of the wave gets
`PAPERCLIP_EPHEMERAL_SERVER`, `PAPERCLIP_DURABLE_AGENT_FILES`,
`PAPERCLIP_SHARED_UI_ASSETS`, the wave's Postgres as
`PAPERCLIP_DATABASE_LISTEN_URL`, one stable
`PAPERCLIP_SECRETS_MASTER_KEY` (also written to the config key file) and
`RUN_LOG_S3_*` in the wave's object store.
- `rewake.ts`: the W2/W2C phase-1 brief asks for `paperclip_finish` with
disposition `yielded` and a `response_wake` continuation.
- W2/W2C tasks are created in `backlog` with their assignee, and a board
comment starts phase 1 (`startComment` in `workloads.ts`, used by
`observer.ts`). A passive board wait needs a comment-started run;
without it the yield queues an immediate continuation and phase 1 wakes
itself. The start comment is posted once (saved-comment check plus a
stable `clientRequestId`), and the brief keeps phase 2 out of blocking
`remainingWork`.
- Volume-free mode drops the repository workloads (B1, B5, F1, R1) from
the default list and refuses them when listed: their fixture is a
`local_path` project source, which a volume-free server refuses.
- `README.md`: a section for the new flag and a note on the brief.
- `shape-cells.test.ts`: option parsing and refusals for the new flag.

## Verification

- `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts
tests/restart-chaos/shape-cells.test.ts` passes (16 tests).
- `node scripts/typecheck-test-projects.mjs tests/restart-chaos` passes.
- Live proof wave (Daytona, Codex, W2, K1 and K15 with a fresh home,
`--storage s3 --ephemeral-server`): wave `w-20261111T205259-s6301` (2
tasks). The first server and both replacements (K15 with a fresh home,
then K1) started and reached ready in the volume-free mode. The server
process had `PAPERCLIP_EPHEMERAL_SERVER=true`,
`PAPERCLIP_DURABLE_AGENT_FILES=true` and `RUN_LOG_S3_BUCKET` set. Both
W2 tasks left phase 1 `in_progress` (yielded), received the re-wake and
ended `done`. The wave also found product faults (reported separately,
not fixed here): a re-wake after K1 failed with
`runner_harness_state_mismatch: backup_without_reusable_lease` and
needed Retry.
- `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts
tests/runner-e2e/support.test.ts` passes, including the new master-key
admission test (a matching wave key passes; an inherited key, a
mismatched key and `PAPERCLIP_SECRETS_MASTER_KEY_FILE` are refused).

## Risks

- Low risk. The flag is opt-in and the harness is test-only. The brief
change alters the W2/W2C prompt, so results from older W2/W2C seeds are
not directly comparable.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use and
code execution.

## 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-11 17:12:14 -06:00

1001 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Owns one isolated Paperclip control plane and injects K faults.
*
* Reuses the runner-e2e process code instead of copying it: the config seed
* (server-config.ts), port reservation (ports.ts), environment isolation
* (harness-env.ts), wrapper-owned Postgres (controller-database.ts), the
* process-tree stopper (server-stop.ts + process-tree-owner.ts), the source
* server entry (server-entry.ts) and the stop → unhealthy → hold → start →
* ready receipt in controller-outage.ts `replaceController`.
*
* Postgres runs beside, not under, the server process tree, so K1 kills only
* the controller and its owned descendants (local runners included), never the
* database (that is K9). The controller runtime cache is kept: discarding it is
* the separate K11 fault.
*/
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
import { randomBytes } from "node:crypto";
import { createWriteStream, readFileSync, statSync, writeFileSync, type WriteStream } from "node:fs";
import { copyFile, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { prepareControllerDatabase } from "../runner-e2e/controller-database.js";
import { CONTROLLER_READY_BY_MS, replaceController, type ControllerOutageReceipt } from "../runner-e2e/controller-outage.js";
import {
assertIsolatedServerEnvironment, buildPaperclipServerEnvironment, buildRunnerE2EProcessEnvironment,
resolvePaperclipRunnerBinaryForHarness,
} from "../runner-e2e/harness-env.js";
import { reserveRunnerE2EServerPort } from "../runner-e2e/ports.js";
import { createRunnerE2ETemporaryRoot, prepareRunnerE2EServerConfig } from "../runner-e2e/server-config.js";
import { createRunnerE2EServerStopper, runnerE2EServerDetached } from "../runner-e2e/server-stop.js";
import { prepareSourceServerDependencies } from "../runner-e2e/source-startup.js";
import { runnerE2ETypeScriptProcessArgs } from "../runner-e2e/web-server-command.js";
import type { MatrixExecution } from "../runner-e2e/types.js";
import { DEFAULT_TIME_AGE_MS, ProcessFreezer, ageWaveDatabase, databaseUrlFromConfig, runFreezeFault, type AgingShift, type TimeFaultRecord } from "./time-faults.js";
import type { Timeline } from "./timeline.js";
import { isRepeatedRestartFault, newRepeatedRestartRecord, recordFirstRestart, runRepeatedRestarts, stopPhaseWatcher, type RepeatedRestartKind, type RepeatedRestartStopOn,
type RepeatedRestartRecord, type RestartStep } from "./repeated-restarts.js";
import type { FaultKind, FaultReceipt, TriggerId } from "./types.js";
import { isMultiServerFault, runMultiServerFault, type MultiServerKind, type MultiServerRecord, type ServerProcessWindow } from "./multi-server.js";
import { generationHome, nextRedeployIdentity, redeployEnvironment, redeployIdentity, redeployNodeArgs, redeployReceipt, type RedeployIdentity } from "./redeploy.js";
import { startEdgeProxy, type EdgeProxy } from "./edge-proxy.js";
import { ephemeralServerEnvironment } from "./ephemeral-server.js";
import { CLASS_R_PRE_FAULT_DEADLINE_MS, classRRedeploys, isClassRFault, runPluginStall, type ClassRHooks } from "./class-r.js";
/** First boot applies every migration; a loaded host can need longer (RESTART_CHAOS_HEALTH_TIMEOUT_MS). */
const HEALTH_TIMEOUT_MS = (() => {
const raw = Number(process.env.RESTART_CHAOS_HEALTH_TIMEOUT_MS);
return Number.isInteger(raw) && raw >= 30_000 && raw <= 1_800_000 ? raw : 180_000;
})();
const READY_TIMEOUT_MS = 300_000;
const STOP_TIMEOUT_MS = 30_000;
export const FAULT_SIGNAL: Record<FaultKind, FaultReceipt["signal"]> = { K1: "SIGKILL", K2: "SIGTERM", K4: "SIGKILL", K6: "SIGKILL", K14: "SIGTERM", K15: "SIGTERM",
TM1: "SIGSTOP", TM2: "SIGSTOP", TM3: "SIGKILL", RR1: "SIGKILL", RR2: "SIGKILL", RR3: "SIGKILL",
// MS1 leaves the primary alone; MS2 freezes it; MS3 stops it gracefully (multi-server.ts).
MS1: "none", MS2: "SIGSTOP", MS3: "SIGTERM",
// CR1 stops the server like K15; CR2 signals only the sandbox's provider (SIGTERM to the server with a hold);
// CR3 stops the Daytona plugin worker, not the server (class-r.ts).
CR1: "SIGTERM", CR2: "none", CR3: "SIGSTOP" };
/** The checkout's commit, for the K14 receipt. */
function checkoutSha(root: string): string | null {
try { return execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); } catch { return null; }
}
export const CRASH_LOOP_TARGETS = ["recovering", "ready"] as const;
const PHASE_POLL_MS = 50;
export function crashLoopShouldFire(target: (typeof CRASH_LOOP_TARGETS)[number], phase: string): boolean {
if (target === "recovering") return phase === "recovering" || phase === "ready";
return phase === "ready";
}
/**
* Agents reach the server at PAPERCLIP_API_URL. An explicit value wins over the URL the server
* derives from its public URL (a developer's .paperclip/.env or shell can name a tailnet origin
* that serves a different instance). Pin this wave's own loopback origin so an agent's key for
* the temporary instance is only ever sent to that instance.
*/
export function waveServerUrlEnvironment(port: number): { PAPERCLIP_API_URL: string } {
return { PAPERCLIP_API_URL: `http://127.0.0.1:${port}` };
}
export interface SupervisorPaths { temporaryRoot: string; home: string; configPath: string; serverLogPath: string }
/**
* Crash-point suite: every server spawn reads its armed point from the control
* file at each hit (PAPERCLIP_CRASH_POINT_CONTROL), so one booted server serves
* many points. The supervisor writes "<token> <name>[:<nth>]" to arm a point
* and empties the file to disarm, including before a crash's replacement starts.
*/
export interface CrashPointControl { controlPath: string; receiptPath: string }
/** The receipt line the crash-point helper appended for this pid, if any. */
export function readCrashPointReceipt(receiptPath: string, pid: number | undefined): { name: string; hit: number; pid: number; token?: string } | null {
let text: string;
try { text = readFileSync(receiptPath, "utf8"); } catch { return null; }
for (const line of text.split("\n").reverse()) {
try {
const parsed = JSON.parse(line) as { point?: unknown; hit?: unknown; pid?: unknown; mode?: unknown };
// A fail-mode hit (`--fail-recovery-step`) threw in the server; it did not kill it.
if (parsed.mode === "fail") continue;
if (pid !== undefined && parsed.pid === pid && typeof parsed.point === "string" && typeof parsed.hit === "number") {
const token = (parsed as { token?: unknown }).token;
return { name: parsed.point, hit: parsed.hit, pid, ...(typeof token === "string" ? { token } : {}) };
}
} catch { /* partial or blank line */ }
}
return null;
}
const secret = () => randomBytes(48).toString("base64url");
/** Same provider PATH shape as the runner-e2e launcher: pinned Codex first. */
async function prepareProviderPath(repositoryRoot: string, temporaryRoot: string, inherited: string | undefined) {
const toolBin = path.join(temporaryRoot, "provider-bin");
await mkdir(toolBin, { recursive: true });
const runnerRequire = createRequire(path.join(repositoryRoot, "packages/paperclip-runner/package.json"));
const codexAcp = runnerRequire.resolve("@agentclientprotocol/codex-acp/package.json");
const codexPackage = createRequire(codexAcp).resolve("@openai/codex/package.json");
const manifest = JSON.parse(await readFile(codexPackage, "utf8")) as { bin?: string | Record<string, string> };
const codexBin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.codex;
if (!codexBin) throw new Error("Pinned Codex executable not found");
// A K15 with a fresh home or an overlap prepares the environment again: replace the link.
await rm(path.join(toolBin, "codex"), { force: true });
await symlink(path.resolve(path.dirname(codexPackage), codexBin), path.join(toolBin, "codex"));
return [toolBin, path.join(repositoryRoot, "packages/paperclip-runner/node_modules/.bin"), inherited].filter(Boolean).join(path.delimiter);
}
export class ChaosSupervisor {
readonly baseUrl: string;
private child: ChildProcess | null = null;
private readonly expectedStops = new WeakSet<ChildProcess>();
private readonly spawnErrors = new WeakMap<ChildProcess, Error>();
private readonly log: WriteStream;
private readonly stopper: ReturnType<typeof createRunnerE2EServerStopper>;
private database: { cleanup(): Promise<void> } | undefined;
private environment: Record<string, string> = {};
private faultChain: Promise<unknown> = Promise.resolve();
private faultCount = 0;
private shuttingDown = false;
/** False from fault start until /api/ready reports `ready` again. */
serverReady = false;
faultInProgress = false;
unexpectedExits = 0;
private crashPointControl: CrashPointControl | null = null;
/** Called with the receipt of a crash-point self-kill once the replacement is ready (or failed). */
onCrashPoint: ((receipt: FaultReceipt) => void) | null = null;
/** Crash-point suite: keep the control plane down this long after a self-kill (a K4-style hold). */
crashPointHoldMs = 0;
/** TM2/TM3: how far each aging fault shifts the wave's database back. */
timeAgeMs = DEFAULT_TIME_AGE_MS;
/** RR1–RR3 (`--rr-stop-on`): stop each replacement on this `/api/ready` status instead of after its gap. */
rrStopOn: RepeatedRestartStopOn | null = null;
/** Every server process this wave started, with its lifetime and freezes (MS cross-server invariant). */
readonly serverWindows: ServerProcessWindow[] = [];
/** MS1–MS3: the second server, while one is running. */
private secondary: { child: ChildProcess; port: number; log: WriteStream; exit: string | null } | null = null;
/** Every aging shift so far, in order; the grader's API reads times through them (time-faults.ts). */
readonly agingShifts: AgingShift[] = [];
private readonly createdAt = Date.now();
/** The checkout the server runs from: the older build (`--upgrade-from`) until the first K14, then this one. */
private serverRoot: string;
/** The environment overrides the wave passed to `start`, kept for every restart and for the K14 switch. */
private extraEnvironment: Record<string, string> = {};
/** K15 waves: the wave id the emulated deploy identities derive from, and the generation serving now. */
private redeploy: { waveId: string; identity: RedeployIdentity } | null = null;
/**
* K15 evidence hook (main.ts sets it on Daytona waves): runs right after the server stopped and again just
* before the next deploy starts, while the server is down. It must not throw.
*/
redeployEvidence: ((faultId: string, phase: "stopped" | "before_start") => Promise<void>) | null = null;
/** K15 deploy shape (redeploy.ts `parseK15DeployShape`); main.ts sets it before `start`. */
deployShape: { overlap: boolean; freshHome: boolean } = { overlap: false, freshHome: false };
ephemeralServer = false;
/** `--k15-fresh-home`: the instance home and config of the serving generation (generation 0 uses `paths`). */
private serverHome: { home: string; configPath: string };
/** `--k15-overlap-seconds`: the edge on the wave's port, and the serving generation's own port behind it. */
private edge: EdgeProxy | null = null;
private backendPort: number;
/** `--fail-recovery-step`: "<point>:<nth>", armed in fail mode when the first restart fault starts its replacement. */
private pendingRecoveryStep: string | null = null;
private recoveryStepWatch: NodeJS.Timeout | null = null;
/** CR1–CR3 waves: sandbox captures and the provider kill (class-r.ts). */
classR: ClassRHooks | null = null;
/** Wave secrets, kept across a K14 switch as an in-place upgrade keeps its configuration. */
private readonly secrets = { PAPERCLIP_AGENT_JWT_SECRET: secret(), PAPERCLIP_DECISION_SIGNING_SECRET: secret(),
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: secret(), BETTER_AUTH_SECRET: secret() };
private constructor(readonly repositoryRoot: string, readonly port: number, readonly paths: SupervisorPaths,
private readonly timeline: Timeline, upgradeFrom: string | null, redeployWaveId: string | null) {
this.serverRoot = upgradeFrom ?? repositoryRoot;
this.serverHome = { home: paths.home, configPath: paths.configPath };
this.backendPort = port;
if (redeployWaveId) this.redeploy = { waveId: redeployWaveId, identity: redeployIdentity(redeployWaveId, 0) };
this.baseUrl = `http://127.0.0.1:${port}`;
this.log = createWriteStream(paths.serverLogPath, { flags: "a", mode: 0o600 });
this.stopper = createRunnerE2EServerStopper({
gracefulTimeoutMs: STOP_TIMEOUT_MS, forcedTimeoutMs: 5_000,
hasSpawnError: candidate => this.spawnErrors.has(candidate),
markExpectedStop: candidate => { this.expectedStops.add(candidate); },
log: message => { this.log.write(message); },
});
}
/** `redeployWaveId`: a K15 wave; every server gets the emulated deploy identity of its generation (redeploy.ts). */
static async create(input: { repositoryRoot: string; waveDir: string; timeline: Timeline; upgradeFrom?: string;
redeployWaveId?: string }): Promise<ChaosSupervisor> {
const temporaryRoot = await createRunnerE2ETemporaryRoot();
const home = path.join(temporaryRoot, "home");
const paths: SupervisorPaths = { temporaryRoot, home, configPath: path.join(home, "instances", "default", "config.json"),
serverLogPath: path.join(input.waveDir, "server.log") };
const port = await reserveRunnerE2EServerPort();
const supervisor = new ChaosSupervisor(input.repositoryRoot, port, paths, input.timeline, input.upgradeFrom ?? null,
input.redeployWaveId ?? null);
input.timeline.add("supervisor_created", { temporaryRoot, port, ...(input.upgradeFrom
? { upgradeFrom: input.upgradeFrom, upgradeFromSha: checkoutSha(input.upgradeFrom) } : {}) });
return supervisor;
}
/** K14 waves: whether the older build or this checkout is serving. */
get serverBuild(): "upgrade_from" | "current" { return this.serverRoot === this.repositoryRoot ? "current" : "upgrade_from"; }
serverLogOffset(): number {
try { return statSync(this.paths.serverLogPath).size + this.log.writableLength; } catch { return 0; }
}
/** `extraEnvironment` is kept for every restart (e.g. the Daytona image's runnerd and provider pack paths). */
async start(extraEnvironment: Record<string, string> = {}): Promise<void> {
const { temporaryRoot, configPath } = this.paths;
const reservation = await prepareRunnerE2EServerConfig({ temporaryRoot, configPath, serverPort: this.port });
await reservation?.close();
// Wrapper-owned Postgres: survives K1/K2/K4 like a managed database would.
this.database = await prepareControllerDatabase(configPath, temporaryRoot);
this.timeline.add("database_started", {});
this.extraEnvironment = extraEnvironment;
if (this.deployShape.overlap) {
// The wave's port becomes the edge; each generation listens behind it on its own port.
this.backendPort = await reserveRunnerE2EServerPort();
this.edge = await startEdgeProxy(this.port, this.backendPort);
this.timeline.add("edge_proxy_started", { port: this.port, target: this.backendPort });
}
await this.prepareServerEnvironment(this.serverRoot);
const child = await this.spawnServer();
await this.waitForHealth(child);
await this.waitForReadyPhase([]);
this.timeline.add("server_ready", { pid: child.pid, serverRoot: this.serverRoot });
}
/**
* The server environment for one checkout: its own runner binary, pinned Codex and source
* dependencies. The database, instance directory, port and secrets stay the same across a K14
* switch, as an in-place upgrade keeps them.
*/
private async prepareServerEnvironment(root: string): Promise<void> {
const { temporaryRoot } = this.paths;
const { home, configPath } = this.serverHome;
const extraEnvironment = this.extraEnvironment;
const runnerBinary = resolvePaperclipRunnerBinaryForHarness(
[{ profile: { generation: "native" } } as unknown as MatrixExecution], root);
// One provider PATH per checkout: the older build keeps its own pinned Codex.
const providerRoot = root === this.repositoryRoot ? temporaryRoot : path.join(temporaryRoot, "upgrade-from");
await mkdir(providerRoot, { recursive: true });
const source = {
...buildRunnerE2EProcessEnvironment(process.env, []),
PATH: await prepareProviderPath(root, providerRoot, process.env.PATH),
PAPERCLIP_RUNNER_E2E_EXECUTION_IDS: "[]",
PAPERCLIP_RUNNER_BINARY: runnerBinary,
PAPERCLIP_VITE_CACHE_DIR: path.join(temporaryRoot, "vite-cache"),
};
const overrides = {
NODE_ENV: "test", PORT: String(this.backendPort), XDG_CACHE_HOME: path.join(temporaryRoot, "xdg-cache"),
PAPERCLIP_HOME: home, PAPERCLIP_CONFIG: configPath, PAPERCLIP_INSTANCE_ID: "default",
...this.secrets,
PAPERCLIP_BIND: "loopback", PAPERCLIP_BIND_HOST: "127.0.0.1", PAPERCLIP_DEPLOYMENT_MODE: "local_trusted",
PAPERCLIP_DEPLOYMENT_EXPOSURE: "private", SERVE_UI: "true", PAPERCLIP_STORAGE_PROVIDER: "local_disk",
PAPERCLIP_STORAGE_LOCAL_DIR: path.join(temporaryRoot, "storage"), PAPERCLIP_SECRETS_PROVIDER: "local_encrypted",
PAPERCLIP_SECRETS_STRICT_MODE: "true", PAPERCLIP_DB_BACKUP_ENABLED: "false",
PAPERCLIP_DB_BACKUP_DIR: path.join(temporaryRoot, "backups"), PAPERCLIP_OPEN_ON_LISTEN: "false",
// The server loads a .paperclip/.env found above its cwd without overriding
// set values. A worktree-initialized checkout's file sets
// PAPERCLIP_IN_WORKTREE=true, which suppresses all scheduling: no run starts.
PAPERCLIP_IN_WORKTREE: "false",
...waveServerUrlEnvironment(this.port),
...extraEnvironment,
// K15: the generation's emulated host name and Railway deployment identity; the volume is the instance home.
...(this.redeploy ? redeployEnvironment(this.redeploy.identity, this.deployShape.freshHome ? null : home) : {}),
};
const ephemeral = this.ephemeralServer ? await ephemeralServerEnvironment({ configPath: this.paths.configPath, temporaryRoot: this.paths.temporaryRoot, extraEnvironment }) : null;
const environment = buildPaperclipServerEnvironment(source, { ...overrides, ...ephemeral });
const workspaceStorage = extraEnvironment.PAPERCLIP_STORAGE_PROVIDER === "s3"
? Object.fromEntries(Object.entries(extraEnvironment).filter(([key]) => key.startsWith("PAPERCLIP_STORAGE_"))) : undefined;
assertIsolatedServerEnvironment(environment, { temporaryRoot, paperclipHome: home, configPath, ...(workspaceStorage ? { workspaceStorage } : {}),
...(ephemeral ? { generatedSecretsMasterKey: ephemeral.PAPERCLIP_SECRETS_MASTER_KEY } : {}) });
this.environment = Object.fromEntries(Object.entries(environment).filter((entry): entry is [string, string] => entry[1] !== undefined));
await prepareSourceServerDependencies(root, this.environment, false);
}
private async spawnServer(): Promise<ChildProcess> {
if (this.shuttingDown) throw new Error("Refusing to start Paperclip during harness shutdown");
const root = this.serverRoot;
const entry = path.join(root, "tests/runner-e2e/server-entry.ts");
const control = this.crashPointControl;
const env = control ? { ...this.environment, PAPERCLIP_CRASH_POINT_CONTROL: control.controlPath,
PAPERCLIP_CRASH_POINT_RECEIPT: control.receiptPath } : this.environment;
const args = runnerE2ETypeScriptProcessArgs(root, entry, ["onboard", "--yes", "--run"]);
// K15 waves: the emulated host name of the serving deploy generation (redeploy.ts).
const child = spawn(process.execPath, this.redeploy ? redeployNodeArgs(args) : args,
{ cwd: root, env, stdio: ["ignore", "pipe", "pipe"], detached: runnerE2EServerDetached });
this.child = child;
const window = this.trackWindow(child, "primary");
child.stdout?.on("data", chunk => this.log.write(chunk));
child.stderr?.on("data", chunk => this.log.write(chunk));
child.once("error", error => { this.spawnErrors.set(child, error); });
child.once("exit", (code, signal) => {
if (window) window.exitedAt = Date.now();
this.log.write(`\n[restart-chaos] server pid=${child.pid} exited code=${code} signal=${signal}\n`);
if (control && signal === "SIGKILL" && !this.expectedStops.has(child) && !this.shuttingDown) {
const hit = readCrashPointReceipt(control.receiptPath, child.pid);
if (hit) { this.restartAfterCrashPoint(child, hit); return; }
}
if (!this.expectedStops.has(child) && !this.shuttingDown) {
this.unexpectedExits += 1;
this.serverReady = false;
this.timeline.add("server_exited_unexpectedly", { pid: child.pid, code, signal });
}
});
await this.stopper.watch(child);
this.timeline.add("server_spawned", { pid: child.pid });
return child;
}
private trackWindow(child: ChildProcess, role: ServerProcessWindow["role"]): ServerProcessWindow | null {
if (!child.pid) return null;
const window: ServerProcessWindow = { pid: child.pid, role, spawnedAt: Date.now(), exitedAt: null, frozen: [] };
this.serverWindows.push(window);
return window;
}
/** Records a freeze or thaw of a server process for the MS invariant (TM1/TM2 and MS2). */
private markFrozen(pid: number | undefined, at: number, frozen: boolean): void {
const window = [...this.serverWindows].reverse().find(candidate => candidate.pid === pid && candidate.exitedAt === null);
if (!window) return;
if (frozen) window.frozen.push({ from: at, to: null });
else { const open = window.frozen.find(span => span.to === null); if (open) open.to = at; }
}
private exited(child: ChildProcess) { return child.exitCode !== null || child.signalCode !== null || this.spawnErrors.has(child); }
private async readPhase(baseUrl = this.baseUrl): Promise<string> {
try {
const response = await fetch(`${baseUrl}/api/ready`, { signal: AbortSignal.timeout(1_000), cache: "no-store" });
return String(((await response.json()) as { status?: string }).status ?? response.status);
} catch { return "unreachable"; }
}
private async waitForHealth(child: ChildProcess, phases?: FaultReceipt["phases"], baseUrl = this.baseUrl): Promise<number> {
const deadline = Date.now() + HEALTH_TIMEOUT_MS;
while (Date.now() < deadline) {
if (this.shuttingDown) throw new Error("Harness shutdown interrupted startup");
if (this.exited(child)) throw new Error(`Paperclip server exited during startup (code=${child.exitCode} signal=${child.signalCode})`);
if (phases) {
const phase = await this.readPhase(baseUrl);
if (phase !== phases[phases.length - 1]?.phase) phases.push({ phase, at: Date.now() });
}
try {
const response = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(1_000) });
await response.body?.cancel();
if (response.ok) return Date.now();
} catch { /* still booting */ }
await delay(250);
}
throw new Error(`Paperclip server did not become healthy within ${HEALTH_TIMEOUT_MS}ms`);
}
private async waitForHealthToStop(): Promise<void> {
const deadline = Date.now() + STOP_TIMEOUT_MS;
while (Date.now() < deadline) {
try { const response = await fetch(`${this.baseUrl}/api/health`, { signal: AbortSignal.timeout(500) }); await response.body?.cancel(); }
catch { return; }
await delay(100);
}
throw new Error("Old Paperclip server stayed healthy after stop");
}
/** Records starting → recovering → ready from /api/ready (liveness gate). */
private async waitForReadyPhase(phases: FaultReceipt["phases"]): Promise<number> {
const deadline = Date.now() + READY_TIMEOUT_MS;
let last: string | null = phases[phases.length - 1]?.phase ?? null;
while (Date.now() < deadline) {
if (this.shuttingDown) throw new Error("Harness shutdown interrupted readiness wait");
const phase = await this.readPhase();
if (phase !== last) { phases.push({ phase, at: Date.now() }); last = phase; }
if (phase === "ready") {
const liveness = await fetch(`${this.baseUrl}/api/liveness`, { signal: AbortSignal.timeout(1_000) }).catch(() => null);
await liveness?.body?.cancel();
if (liveness?.ok) { this.serverReady = true; return Date.now(); }
}
await delay(250);
}
throw new Error(`Paperclip did not report ready within ${READY_TIMEOUT_MS}ms`);
}
/** Crash-point suite: run every spawn (from `start` on) with runtime arming. */
useCrashPointControl(control: CrashPointControl): void {
writeFileSync(control.controlPath, "", { mode: 0o600 });
this.crashPointControl = control;
}
/** Arms `spec` ("<name>[:<nth>]") with a fresh hit count under `token`. */
armCrashPoint(token: string, spec: string): void {
if (!this.crashPointControl) throw new Error("Crash-point control is not enabled");
writeFileSync(this.crashPointControl.controlPath, `${token} ${spec}\n`, { mode: 0o600 });
this.timeline.add("crash_point_armed", { token, spec });
}
disarmCrashPoint(): void {
if (this.crashPointControl) writeFileSync(this.crashPointControl.controlPath, "", { mode: 0o600 });
}
/**
* The armed server killed itself at its crash point. Treat it like K1: kill
* what is left of its owned process tree (local runners), start a
* replacement without the point armed, and wait for ready.
*/
private restartAfterCrashPoint(previous: ChildProcess, hit: { name: string; hit: number; pid: number; token?: string }): void {
// The replacement reads the same control file: disarm before it starts.
this.disarmCrashPoint();
this.faultInProgress = true;
this.serverReady = false;
this.faultCount += 1;
const now = Date.now();
const receipt: FaultReceipt = {
faultId: `f${String(this.faultCount).padStart(2, "0")}`, kind: this.crashPointHoldMs ? "K4" : "K1", requestedBy: "crash-point",
signal: "SIGKILL", holdMs: this.crashPointHoldMs,
loadAvg1m: Math.round(os.loadavg()[0]! * 100) / 100, crashPoint: hit,
requestedAt: now, startedAt: now, stoppedAt: null, restartedAt: null, healthyAt: null, readyAt: null,
phases: [], serverLogOffsetAtStart: this.serverLogOffset(), serverLogOffsetAtReady: null, error: null,
};
this.timeline.add("crash_point_fired", { faultId: receipt.faultId, point: hit.name, hit: hit.hit, pid: hit.pid });
this.log.write(`\n[restart-chaos] ${receipt.faultId} crash point ${hit.name} hit ${hit.hit} killed pid=${hit.pid}\n`);
let cleanupError: string | null = null;
const run = this.faultChain.then(async () => {
try {
// The leader is already dead; this kills its owned descendants, as K1 does.
// A cleanup that cannot prove the tree stopped fails the fault (and the
// wave); the replacement still starts so the tasks can drain.
await this.stopper.crashForRestart(previous).catch(error => {
cleanupError = `owned process tree cleanup failed: ${error instanceof Error ? error.message : String(error)}`;
this.log.write(`[restart-chaos] crash-point ${cleanupError}\n`);
});
if (this.child === previous) this.child = null;
await this.waitForHealthToStop();
receipt.stoppedAt = Date.now();
if (this.crashPointHoldMs) {
this.timeline.add("crash_point_hold", { faultId: receipt.faultId, holdMs: this.crashPointHoldMs });
await delay(this.crashPointHoldMs);
}
receipt.restartedAt = Date.now();
const child = await this.spawnServer();
receipt.healthyAt = await this.waitForHealth(child, receipt.phases);
receipt.readyAt = await this.waitForReadyPhase(receipt.phases);
receipt.serverLogOffsetAtReady = this.serverLogOffset();
this.timeline.add("fault_ready", { faultId: receipt.faultId, replacementPid: child.pid, readyAfterMs: receipt.readyAt - receipt.startedAt });
} catch (error) {
receipt.error = error instanceof Error ? error.message : String(error);
this.timeline.add("fault_failed", { faultId: receipt.faultId, error: receipt.error });
} finally {
if (cleanupError && !receipt.error) receipt.error = cleanupError;
this.faultInProgress = false;
this.onCrashPoint?.(receipt);
}
});
this.faultChain = run.catch(() => undefined);
}
/** Faults are serialized: one control plane, one outage at a time. */
fault(kind: FaultKind, holdMs: number, requestedBy: string, trigger?: { trigger: TriggerId; delayMs: number },
restartSteps?: readonly RestartStep[]): Promise<FaultReceipt> {
const requestedAt = Date.now();
const run = this.faultChain.then(() => this.executeFault(kind, holdMs, requestedBy, requestedAt, trigger, restartSteps));
this.faultChain = run.catch(() => undefined);
return run;
}
private async executeFault(kind: FaultKind, holdMs: number, requestedBy: string, requestedAt: number,
trigger?: { trigger: TriggerId; delayMs: number }, restartSteps?: readonly RestartStep[]): Promise<FaultReceipt> {
const previous = this.child;
this.faultCount += 1;
const receipt: FaultReceipt = {
faultId: `f${String(this.faultCount).padStart(2, "0")}`, kind, requestedBy, signal: FAULT_SIGNAL[kind], holdMs,
...(trigger ? { trigger: trigger.trigger, triggerDelayMs: trigger.delayMs } : {}),
loadAvg1m: Math.round(os.loadavg()[0]! * 100) / 100,
requestedAt, startedAt: Date.now(), stoppedAt: null, restartedAt: null, healthyAt: null, readyAt: null,
phases: [], serverLogOffsetAtStart: this.serverLogOffset(), serverLogOffsetAtReady: null, error: null,
};
// RR: the planned steps and the first kill are recorded before it, so a failed first restart keeps them for replay.
if (isRepeatedRestartFault(kind)) receipt.repeatedRestart = this.repeatedRestartRecord(kind, receipt, restartSteps, previous?.pid ?? null);
this.faultInProgress = true;
this.serverReady = false;
this.timeline.add("fault_started", { faultId: receipt.faultId, fault: kind, signal: receipt.signal, loadAvg1m: receipt.loadAvg1m, holdMs, requestedBy, previousPid: previous?.pid });
this.log.write(`\n[restart-chaos] ${receipt.faultId} ${kind} ${receipt.signal} hold=${holdMs}ms requestedBy=${requestedBy} at ${new Date().toISOString()}\n`);
// CR1 and CR2 with a hold replace the controller as a K15 deploy does (class-r.ts).
const redeploys = kind === "K15" || classRRedeploys(kind, holdMs);
try {
if (!previous?.pid || this.exited(previous)) throw new Error("No live Paperclip server to fault");
let outage: ControllerOutageReceipt | undefined;
if (isClassRFault(kind)) {
if (!this.classR) throw new Error(`${kind} needs the class R hooks of a Daytona wave (main.ts)`);
receipt.classR = { kind, redeploy: redeploys, captures: [] };
if (kind === "CR2" && holdMs > 0) receipt.signal = "SIGTERM";
// The sandbox just before the fault, while the controller still holds it; the fault starts after it.
receipt.classR.captures.push(await this.classR.capture(`${receipt.faultId}-before`, CLASS_R_PRE_FAULT_DEADLINE_MS));
receipt.startedAt = Date.now();
}
if (kind === "TM1" || kind === "TM2") await this.freeze(kind, previous, holdMs, receipt);
else if (isMultiServerFault(kind)) await this.multiServer(kind, previous, holdMs, receipt);
// `--k15-overlap-seconds`: the next deploy boots beside the old server; its hold is the overlap.
else if (kind === "K15" && this.deployShape.overlap) await this.rollingRedeploy(previous, holdMs, receipt);
else if (kind === "CR3") {
// The server keeps running; its Daytona plugin worker is stopped for the hold (class-r.ts).
const stall = await runPluginStall({ serverPid: previous.pid, holdMs, cancelled: () => this.shuttingDown,
record: (event, data) => this.timeline.add(event, { faultId: receipt.faultId, ...data }) });
receipt.classR!.pluginStall = stall;
receipt.stoppedAt = stall.frozenAt;
receipt.restartedAt = stall.thawedAt;
if (stall.error) throw new Error(stall.error);
receipt.healthyAt = await this.waitForHealth(previous, receipt.phases);
}
else if (kind === "CR2" && !redeploys) {
// The controller stays up: only the provider inside the task's sandbox dies.
receipt.classR!.providerKill = await this.classR!.killProvider(requestedBy);
receipt.healthyAt = Date.now();
}
else outage = await replaceController({
requestId: `chaos-${receipt.faultId}-${requestedBy}`,
previousPid: previous.pid,
outage: { signal: receipt.signal === "SIGTERM" ? "SIGTERM" : "SIGKILL", holdMs },
// A K4 hold may outlast the Runner's 7-minute reconnect grace on
// purpose (--k4-hold-max-seconds); the replacement still gets the
// normal startup allowance after the hold.
readyByMs: holdMs + CONTROLLER_READY_BY_MS,
stop: async signal => {
if (signal === "SIGKILL") await this.stopper.crashForRestart(previous);
else await this.stopper(previous, "SIGTERM");
if (this.child === previous) this.child = null;
},
waitForUnhealthy: async () => { await this.waitForHealthToStop(); receipt.stoppedAt = Date.now(); this.timeline.add("fault_server_down", { faultId: receipt.faultId }); },
// K1/K2/K4 keep the controller runtime cache; K11 will discard it.
discardCache: async () => {},
// K15: capture the sandbox side as soon as the controller is gone (redeployEvidence).
...(kind === "K15" && this.redeployEvidence ? { afterStopped: async () => { await this.redeployEvidence?.(receipt.faultId, "stopped"); } } : {}),
// CR1/CR2: the sandbox as the controller left it; CR2 then kills the provider while the controller is away.
...(receipt.classR ? { afterStopped: async () => {
receipt.classR!.captures.push(await this.classR!.capture(`${receipt.faultId}-stopped`));
if (kind === "CR2") receipt.classR!.providerKill = await this.classR!.killProvider(requestedBy);
} } : {}),
// TM3: the server is down; every database timestamp moves back by the age before the replacement starts.
...(kind === "TM3" ? { afterStopped: async () => {
const timeFault: TimeFaultRecord = { kind };
receipt.timeFault = timeFault;
timeFault.aging = await this.age(receipt.faultId);
} } : {}),
start: async () => {
receipt.restartedAt = Date.now();
// K14: the replacement is the current build, on the same database and instance directory.
if (kind === "K14") receipt.upgrade = await this.switchToCurrentBuild(receipt.faultId);
// K15: the replacement is the next deploy: a new process space and deployment id on the same volume.
if (redeploys) {
// After the hold, before the next deploy starts: what its recovery will find in the sandboxes.
if (receipt.classR) receipt.classR.captures.push(await this.classR!.capture(`${receipt.faultId}-before_start`));
else await this.redeployEvidence?.(receipt.faultId, "before_start");
receipt.redeploy = await this.advanceDeploy(receipt.faultId);
}
this.timeline.add("fault_restarting", { faultId: receipt.faultId });
const replacement = await this.spawnServer();
this.armPendingRecoveryStep(receipt, replacement.pid);
return replacement;
},
waitForReady: async replacement => {
// RR with `--rr-stop-on`: watch the first replacement from spawn; waiting for health first would let a
// short recovery pass between two 250 ms health polls. Only the last replacement is waited on to health.
if (receipt.repeatedRestart?.stopOn) {
receipt.repeatedRestart.firstStopPhase = await this.waitForCrashLoopPhase(replacement, receipt.repeatedRestart.stopOn, receipt.phases);
return;
}
receipt.healthyAt = await this.waitForHealth(replacement, receipt.phases);
},
cancelled: () => this.shuttingDown,
record: async progress => { outage = progress; },
});
if (kind === "K6") {
if (!this.child) throw new Error("K6 crash loop has no replacement server to kill");
receipt.healthyAt = await this.waitForHealth(await this.crashLoop(this.child, receipt), receipt.phases);
}
if (receipt.repeatedRestart) receipt.healthyAt = await this.repeatedRestarts(receipt.repeatedRestart, receipt);
receipt.readyAt = await this.waitForReadyPhase(receipt.phases);
receipt.serverLogOffsetAtReady = this.serverLogOffset();
this.timeline.add("fault_ready", { faultId: receipt.faultId, replacementPid: this.child?.pid ?? outage?.replacementPid,
downMs: (receipt.restartedAt ?? 0) - (receipt.stoppedAt ?? 0), readyAfterMs: receipt.readyAt - receipt.startedAt, phases: receipt.phases });
// Class R: staging failed about 40 s after adoption; watch the sandbox through that window (not awaited).
if (receipt.classR) this.classR?.watchAfterReady(receipt);
} catch (error) {
receipt.error = error instanceof Error ? error.message : String(error);
this.timeline.add("fault_failed", { faultId: receipt.faultId, error: receipt.error });
throw Object.assign(new Error(`Fault ${receipt.faultId} failed: ${receipt.error}`), { receipt });
} finally {
if (receipt.repeatedRestart) await this.writeRepeatedRestartEvidence(receipt);
this.faultInProgress = false;
}
return receipt;
}
private async age(faultId: string, onLockTimeout?: () => Promise<void>) {
const aging = await ageWaveDatabase({ connectionString: databaseUrlFromConfig(this.paths.configPath), faultId, ageMs: this.timeAgeMs,
evidenceDir: path.join(path.dirname(this.paths.serverLogPath), "time-faults"), waveStartedAt: this.createdAt,
priorAgeMs: this.agingShifts.reduce((sum, shift) => sum + shift.ageMs, 0),
...(onLockTimeout ? { onLockTimeout } : {}) });
this.agingShifts.push({ faultId, ageMs: aging.ageMs, appliedAt: aging.appliedAt, waveStartedAt: aging.waveStartedAt });
this.timeline.add("time_fault_aged", { faultId, ageMs: aging.ageMs, totalRows: aging.totalRows, triggersDisabled: aging.triggersDisabled,
lockRetries: aging.lockRetries, tables: aging.tables.map(table => `${table.table}:${table.rows}`), evidence: aging.evidencePath });
return aging;
}
/**
* TM1 freezes the server process alone; TM2 freezes it with every process under it and ages the database
* while they are stopped. Both continue the same processes after the hold: no restart. A server that exits
* on its own after it is continued (a lost-lease self-fence) is recorded and replaced, and the tasks are graded.
*/
private async freeze(kind: "TM1" | "TM2", server: ChildProcess, holdMs: number, receipt: FaultReceipt): Promise<void> {
const timeFault: TimeFaultRecord = { kind };
receipt.timeFault = timeFault;
const times = await runFreezeFault({ kind, rootPid: server.pid!, holdMs, record: timeFault, freezer: new ProcessFreezer(),
...(kind === "TM2" ? { age: (onLockTimeout: () => Promise<void>) => this.age(receipt.faultId, onLockTimeout) } : {}),
cancelled: () => this.shuttingDown,
onFrozen: pids => { this.markFrozen(server.pid, Date.now(), true); this.timeline.add("fault_server_frozen", { faultId: receipt.faultId, pids, holdMs }); },
onThawed: frozenMs => { this.markFrozen(server.pid, Date.now(), false); this.timeline.add("fault_server_thawed", { faultId: receipt.faultId, frozenMs }); },
waitHealthy: () => this.waitForHealth(server, receipt.phases),
exitStatus: () => this.exited(server) ? { code: server.exitCode, signal: server.signalCode } : null,
replaceExited: async () => {
this.timeline.add("time_fault_server_exited_after_thaw", { faultId: receipt.faultId, ...timeFault.serverExitedAfterThaw });
// Kills what is left of the exited server's tree; rejects when it cannot prove the tree stopped.
try { await this.stopper.crashForRestart(server); } finally { if (this.child === server) this.child = null; }
},
startReplacement: async () => this.waitForHealth(await this.spawnServer(), receipt.phases),
});
receipt.stoppedAt = times.stoppedAt;
receipt.restartedAt = times.restartedAt;
receipt.healthyAt = times.healthyAt;
}
/**
* MS1–MS3 (multi-server.ts): a second server B on its own port, with the primary's database, instance
* directory, storage and secrets. The primary keeps its port, so tasks and agents keep using it.
*/
private async multiServer(kind: MultiServerKind, primary: ChildProcess, holdMs: number, receipt: FaultReceipt): Promise<void> {
const record: MultiServerRecord = { kind, secondary: null, steps: [] };
receipt.multiServer = record;
await runMultiServerFault(kind, holdMs, record, {
now: () => Date.now(),
sleep: ms => delay(ms),
cancelled: () => this.shuttingDown,
secondaryFailure: () => this.secondary?.exit ?? null,
startSecondary: async current => {
const child = await this.spawnSecondary(current);
current.secondary!.readyAt = await this.waitForSecondaryReady(child, current.secondary!.port);
this.timeline.add("multi_server_secondary_ready", { faultId: receipt.faultId, pid: child.pid, port: current.secondary!.port });
},
stopSecondary: async current => {
const secondary = this.secondary;
if (!secondary) return;
// An exited B still has its owned tree cleaned up; a live one gets the graceful stop.
try { await (secondary.exit ? this.stopper.crashForRestart(secondary.child) : this.stopper(secondary.child, "SIGTERM")); }
finally {
this.secondary = null;
await new Promise<void>(resolve => secondary.log.end(resolve));
if (current.secondary) current.secondary.stoppedAt = Date.now();
this.timeline.add("multi_server_secondary_stopped", { faultId: receipt.faultId, pid: secondary.child.pid });
}
},
freezePrimary: async (freezeMs, current) => {
// TM2's tree freeze without aging: the primary and its runners stop; the time passes for real.
const timeFault: TimeFaultRecord = { kind: "TM2" };
current.primaryFreeze = timeFault;
const times = await runFreezeFault({ kind: "TM2", rootPid: primary.pid!, holdMs: freezeMs, record: timeFault, freezer: new ProcessFreezer(),
cancelled: () => this.shuttingDown,
onFrozen: pids => { this.markFrozen(primary.pid, Date.now(), true); this.timeline.add("fault_server_frozen", { faultId: receipt.faultId, pids, holdMs: freezeMs }); },
onThawed: frozenMs => { this.markFrozen(primary.pid, Date.now(), false); this.timeline.add("fault_server_thawed", { faultId: receipt.faultId, frozenMs }); },
waitHealthy: () => this.waitForHealth(primary, receipt.phases),
exitStatus: () => this.exited(primary) ? { code: primary.exitCode, signal: primary.signalCode } : null,
replaceExited: async () => {
this.timeline.add("time_fault_server_exited_after_thaw", { faultId: receipt.faultId, ...timeFault.serverExitedAfterThaw });
try { await this.stopper.crashForRestart(primary); } finally { if (this.child === primary) this.child = null; }
},
startReplacement: async () => this.waitForHealth(await this.spawnServer(), receipt.phases),
});
receipt.stoppedAt = times.stoppedAt;
receipt.restartedAt = times.restartedAt;
receipt.healthyAt = times.healthyAt;
},
restartPrimary: async current => {
const restart: NonNullable<MultiServerRecord["primaryRestart"]> = { stoppedAt: null, restartedAt: null, readyAt: null, replacementPid: null };
current.primaryRestart = restart;
await this.stopper(primary, "SIGTERM");
if (this.child === primary) this.child = null;
await this.waitForHealthToStop();
receipt.stoppedAt = restart.stoppedAt = Date.now();
this.timeline.add("fault_server_down", { faultId: receipt.faultId });
receipt.restartedAt = restart.restartedAt = Date.now();
const replacement = await this.spawnServer();
restart.replacementPid = replacement.pid ?? null;
receipt.healthyAt = await this.waitForHealth(replacement, receipt.phases);
restart.readyAt = await this.waitForReadyPhase(receipt.phases);
},
});
}
private async spawnSecondary(record: MultiServerRecord): Promise<ChildProcess> {
if (this.shuttingDown) throw new Error("Refusing to start a second server during harness shutdown");
if (this.secondary) throw new Error("A second server is already running");
const port = await reserveRunnerE2EServerPort();
const log = createWriteStream(path.join(path.dirname(this.paths.serverLogPath), "server-b.log"), { flags: "a", mode: 0o600 });
const root = this.serverRoot;
const entry = path.join(root, "tests/runner-e2e/server-entry.ts");
// Same database, instance directory, storage and secrets as the primary; its own port and API URL.
const env = { ...this.environment, PORT: String(port), ...waveServerUrlEnvironment(port) };
const child = spawn(process.execPath, runnerE2ETypeScriptProcessArgs(root, entry, ["onboard", "--yes", "--run"]),
{ cwd: root, env, stdio: ["ignore", "pipe", "pipe"], detached: runnerE2EServerDetached });
const window = this.trackWindow(child, "secondary");
record.secondary = { pid: child.pid ?? null, port, spawnedAt: Date.now(), readyAt: null, stoppedAt: null };
const secondary = { child, port, log, exit: null as string | null };
this.secondary = secondary;
child.stdout?.on("data", chunk => log.write(chunk));
child.stderr?.on("data", chunk => log.write(chunk));
child.once("error", error => { this.spawnErrors.set(child, error); });
child.once("exit", (code, signal) => {
if (window) window.exitedAt = Date.now();
log.write(`\n[restart-chaos] second server pid=${child.pid} exited code=${code} signal=${signal}\n`);
if (!this.expectedStops.has(child) && !this.shuttingDown) {
secondary.exit = `code=${code} signal=${signal}`;
this.timeline.add("multi_server_secondary_exited", { pid: child.pid, code, signal });
}
});
await this.stopper.watch(child);
this.timeline.add("multi_server_secondary_spawned", { pid: child.pid, port });
return child;
}
/** B is ready when its own /api/ready says so; it never changes the primary's readiness. */
private async waitForSecondaryReady(child: ChildProcess, port: number): Promise<number> {
const baseUrl = `http://127.0.0.1:${port}`;
await this.waitForHealth(child, undefined, baseUrl);
const deadline = Date.now() + READY_TIMEOUT_MS;
while (Date.now() < deadline) {
if (this.shuttingDown) throw new Error("Harness shutdown interrupted the second server's start");
if (this.exited(child)) throw new Error(`Second server exited during startup (code=${child.exitCode} signal=${child.signalCode})`);
if (await this.readPhase(baseUrl) === "ready") return Date.now();
await delay(250);
}
throw new Error(`Second server did not report ready within ${READY_TIMEOUT_MS}ms`);
}
/** K15: move the next server to the next deploy generation (redeploy.ts). */
private async advanceDeploy(faultId: string): Promise<NonNullable<FaultReceipt["redeploy"]>> {
if (!this.redeploy || !this.environment) throw new Error("K15 needs a redeploy wave (ChaosSupervisor.create redeployWaveId)");
const from = this.redeploy.identity;
const to = nextRedeployIdentity(this.redeploy.waveId, from);
this.redeploy.identity = to;
if (this.deployShape.freshHome) this.serverHome = await this.freshGenerationHome(faultId, to.generation);
if (this.deployShape.overlap) this.backendPort = await reserveRunnerE2EServerPort();
if (this.deployShape.freshHome || this.deployShape.overlap) await this.prepareServerEnvironment(this.serverRoot);
else Object.assign(this.environment, redeployEnvironment(to, this.paths.home));
const receipt = redeployReceipt(from, to);
this.timeline.add("redeploy_switched", { faultId, ...receipt });
this.log.write(`\n[restart-chaos] ${faultId} K15 redeploy ${from.hostname}/${from.deploymentId} -> ${to.hostname}/${to.deploymentId}\n`);
return receipt;
}
/**
* `--k15-fresh-home`: a new, empty instance home for the next generation. Only what a hosted deploy gets from
* outside its container is carried over: the configuration (database, storage) and the secrets master key.
*/
private async freshGenerationHome(faultId: string, generation: number): Promise<{ home: string; configPath: string }> {
const home = generationHome(this.paths.temporaryRoot, this.paths.home, generation);
const instanceRoot = path.join(home, "instances", "default");
await mkdir(path.join(instanceRoot, "secrets"), { recursive: true, mode: 0o700 });
const config = JSON.parse(await readFile(this.paths.configPath, "utf8")) as Record<string, any>;
const keyFile = path.join(instanceRoot, "secrets", "master.key");
await copyFile(String(config.secrets?.localEncrypted?.keyFilePath ?? path.join(path.dirname(this.paths.configPath), "secrets", "master.key")), keyFile);
config.secrets = { ...config.secrets, localEncrypted: { ...config.secrets?.localEncrypted, keyFilePath: keyFile } };
config.logging = { ...config.logging, logDir: path.join(instanceRoot, "logs") };
const configPath = path.join(instanceRoot, "config.json");
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
this.timeline.add("k15_fresh_home", { faultId, generation, home });
return { home, configPath };
}
/**
* `--k15-overlap-seconds`: the staging deploy shape. The next generation boots beside the old server on its own
* port; once it is ready, the edge sends new connections to it; the old server keeps running for the overlap and
* then gets SIGTERM. There is no outage.
*/
private async rollingRedeploy(previous: ChildProcess, overlapMs: number, receipt: FaultReceipt): Promise<void> {
if (!this.edge) throw new Error("--k15-overlap-seconds needs the edge proxy started with the wave");
const fromPort = this.backendPort;
receipt.redeploy = await this.advanceDeploy(receipt.faultId);
receipt.restartedAt = Date.now();
this.timeline.add("fault_restarting", { faultId: receipt.faultId, overlapMs });
const next = await this.spawnServer();
// Bound to the next generation: the old server keeps serving during the overlap and must not count hits.
this.armPendingRecoveryStep(receipt, next.pid);
const backendUrl = `http://127.0.0.1:${this.backendPort}`;
receipt.healthyAt = await this.waitForHealth(next, receipt.phases, backendUrl);
const deadline = Date.now() + READY_TIMEOUT_MS;
while (await this.readPhase(backendUrl) !== "ready") {
if (this.shuttingDown) throw new Error("Harness shutdown interrupted the rolling redeploy");
if (this.exited(next)) throw new Error(`Next deploy exited during startup (code=${next.exitCode} signal=${next.signalCode})`);
if (Date.now() > deadline) throw new Error(`Next deploy did not report ready within ${READY_TIMEOUT_MS}ms`);
await delay(250);
}
this.edge.target = this.backendPort;
this.timeline.add("k15_traffic_switched", { faultId: receipt.faultId, fromPort, toPort: this.backendPort, overlapMs });
for (const until = Date.now() + overlapMs; Date.now() < until && !this.shuttingDown;) await delay(Math.min(1_000, until - Date.now()));
await this.stopper(previous, "SIGTERM");
receipt.stoppedAt = Date.now();
this.timeline.add("fault_server_down", { faultId: receipt.faultId, previousPid: previous.pid, overlapMs });
await this.redeployEvidence?.(receipt.faultId, "stopped");
}
/** `--fail-recovery-step`: arm the step in fail mode for the wave's first restart fault only. */
armRecoveryStepOnFirstFault(spec: string, control: CrashPointControl): void {
this.useCrashPointControl(control);
this.pendingRecoveryStep = spec;
}
/**
* Arms the step for the replacement process only (`pid=`), and disarms the control file once its receipt
* landed, so a later server (RR, K6, another task's restart) never fails the step again.
*/
private armPendingRecoveryStep(receipt: FaultReceipt, pid: number | undefined): void {
if (!this.pendingRecoveryStep || !pid || !this.crashPointControl) return;
const token = `rs-${receipt.faultId}`;
this.armCrashPoint(token, `${this.pendingRecoveryStep} fail pid=${pid}`);
receipt.recoveryStep = { spec: this.pendingRecoveryStep, token };
this.pendingRecoveryStep = null;
const receiptPath = this.crashPointControl.receiptPath;
const watch = setInterval(() => {
let text = "";
try { text = readFileSync(receiptPath, "utf8"); } catch { return; }
if (!text.includes(`"token":"${token}"`)) return;
clearInterval(watch);
this.disarmCrashPoint();
this.timeline.add("recovery_step_disarmed", { faultId: receipt.faultId, token });
}, 500);
watch.unref();
this.recoveryStepWatch = watch;
}
/**
* K14: move the server from the older build to this checkout. Only the first K14 switches; a later
* one restarts the current build (recorded with `switched: false`).
*/
private async switchToCurrentBuild(faultId: string): Promise<NonNullable<FaultReceipt["upgrade"]>> {
const fromRoot = this.serverRoot;
const switched = fromRoot !== this.repositoryRoot;
if (switched) {
this.serverRoot = this.repositoryRoot;
await this.prepareServerEnvironment(this.repositoryRoot);
}
const upgrade = { fromRoot, fromSha: checkoutSha(fromRoot), toRoot: this.repositoryRoot, toSha: checkoutSha(this.repositoryRoot), switched };
this.timeline.add("upgrade_switched", { faultId, ...upgrade });
this.log.write(`\n[restart-chaos] ${faultId} K14 upgrade ${upgrade.fromSha ?? fromRoot} -> ${upgrade.toSha ?? this.repositoryRoot}${switched ? "" : " (already upgraded)"}\n`);
return upgrade;
}
/** RR1–RR3: the planned steps and the first kill (as K1), recorded before that kill. */
private repeatedRestartRecord(kind: RepeatedRestartKind, receipt: FaultReceipt, planned: readonly RestartStep[] | undefined,
firstPid: number | null): RepeatedRestartRecord {
const record = newRepeatedRestartRecord({ kind, planned, firstPid, startedAt: receipt.startedAt,
evidencePath: path.join(path.dirname(this.paths.serverLogPath), "repeated-restarts", `${receipt.faultId}.json`),
int: (min, max) => min + Math.floor(Math.random() * (max - min + 1)) });
return this.rrStopOn ? { ...record, stopOn: this.rrStopOn } : record;
}
/**
* RR1–RR3: the first kill has a healthy replacement. Stop it again after each planned gap, while it
* is still recovering, and start another. The sequence is one receipt: one fault window.
*/
private async repeatedRestarts(record: RepeatedRestartRecord, receipt: FaultReceipt): Promise<number> {
const first = this.child;
recordFirstRestart(record, receipt, first?.pid ?? null);
if (!first || (receipt.healthyAt === null && !record.stopOn)) throw new Error(`${record.kind} has no healthy replacement after its first kill`);
// `--rr-stop-on`: the first replacement was already watched from spawn to its stop status (`firstStopPhase`).
const waitForStopPhase = stopPhaseWatcher(first, record.firstStopPhase,
(server: ChildProcess, target: RepeatedRestartStopOn) => this.waitForCrashLoopPhase(server, target, receipt.phases));
const result = await runRepeatedRestarts({ record, steps: record.planned, server: first, healthyAt: receipt.healthyAt ?? Date.now(),
...(record.stopOn ? { stopOn: record.stopOn } : {}), hooks: {
now: () => Date.now(),
sleep: ms => delay(ms),
cancelled: () => this.shuttingDown,
pidOf: server => server.pid ?? null,
exitStatus: server => this.exited(server) ? { code: server.exitCode, signal: server.signalCode } : null,
readPhase: () => this.readPhase(),
stop: async (server, signal) => {
if (signal === "SIGKILL") await this.stopper.crashForRestart(server);
else await this.stopper(server, "SIGTERM");
if (this.child === server) this.child = null;
await this.waitForHealthToStop();
},
start: () => this.spawnServer(),
waitHealthy: server => this.waitForHealth(server, receipt.phases),
// The K6 phase poll (50 ms): fires on `recovering`, or on `ready` when recovery was too short to see.
waitForStopPhase,
onEntry: (entry, stage) => {
if (stage === "stopping") {
this.timeline.add("repeated_restart_stop", { faultId: receipt.faultId, index: entry.index, signal: entry.signal, gapMs: entry.gapMs,
pid: entry.pid, phaseAtStop: entry.phaseAtStop });
this.log.write(`\n[restart-chaos] ${receipt.faultId} ${record.kind} restart ${entry.index} ${entry.signal} pid=${entry.pid} phase=${entry.phaseAtStop} gap=${entry.gapMs}ms\n`);
} else {
this.timeline.add("repeated_restart_healthy", { faultId: receipt.faultId, index: entry.index, replacementPid: entry.replacementPid,
downMs: (entry.healthyAt ?? 0) - entry.stopAt });
}
},
} });
return result.healthyAt;
}
/** `<wave>/repeated-restarts/<faultId>.json`, written whether the sequence passed or failed; faults.json carries the same record. */
private async writeRepeatedRestartEvidence(receipt: FaultReceipt): Promise<void> {
const record = receipt.repeatedRestart!;
// A first kill that failed before its replacement was healthy still records the times it reached.
recordFirstRestart(record, receipt, null);
try {
await mkdir(path.dirname(record.evidencePath!), { recursive: true });
await writeFile(record.evidencePath!, `${JSON.stringify({ faultId: receipt.faultId, requestedBy: receipt.requestedBy, error: receipt.error, ...record }, null, 2)}\n`, { mode: 0o600 });
} catch (error) {
this.log.write(`[restart-chaos] ${receipt.faultId} could not write repeated-restart evidence: ${error instanceof Error ? error.message : String(error)}\n`);
}
}
private async waitForCrashLoopPhase(child: ChildProcess, target: (typeof CRASH_LOOP_TARGETS)[number],
phases: FaultReceipt["phases"]): Promise<string> {
const deadline = Date.now() + READY_TIMEOUT_MS;
while (Date.now() < deadline) {
if (this.shuttingDown) throw new Error("Harness shutdown interrupted the crash loop");
if (this.exited(child)) throw new Error(`Paperclip server exited during the crash loop (code=${child.exitCode} signal=${child.signalCode})`);
const phase = await this.readPhase();
if (phase !== phases[phases.length - 1]?.phase) phases.push({ phase, at: Date.now() });
if (crashLoopShouldFire(target, phase)) return phase;
await delay(PHASE_POLL_MS);
}
throw new Error(`Crash loop: phase ${target} not reached within ${READY_TIMEOUT_MS}ms`);
}
private async crashLoop(first: ChildProcess, receipt: FaultReceipt): Promise<ChildProcess> {
let current = first;
receipt.crashLoop = [];
for (const target of CRASH_LOOP_TARGETS) {
const phase = await this.waitForCrashLoopPhase(current, target, receipt.phases);
receipt.crashLoop.push({ pid: current.pid ?? null, target, phase, at: Date.now() });
this.timeline.add("fault_crash_loop_kill", { faultId: receipt.faultId, pid: current.pid, target, phase });
this.log.write(`\n[restart-chaos] ${receipt.faultId} K6 crash-loop SIGKILL pid=${current.pid} target=${target} phase=${phase}\n`);
await this.stopper.crashForRestart(current);
if (this.child === current) this.child = null;
await this.waitForHealthToStop();
current = await this.spawnServer();
}
return current;
}
/**
* Bounded recovery after a failed fault or a crash the harness did not cause:
* wait for a live server to become ready, or start a new one. Throws when the
* control plane cannot be brought back, so the wave fails instead of hanging.
*/
async ensureServing(): Promise<void> {
if (this.serverReady || this.faultInProgress) return;
const live = this.child && !this.exited(this.child) ? this.child : null;
this.timeline.add("server_recovery_attempt", { livePid: live?.pid ?? null });
const child = live ?? await this.spawnServer();
await this.waitForHealth(child);
await this.waitForReadyPhase([]);
this.timeline.add("server_recovered", { pid: child.pid });
}
async stop(): Promise<void> {
this.shuttingDown = true;
if (this.recoveryStepWatch) clearInterval(this.recoveryStepWatch);
const errors: string[] = [];
try { await this.stopper.stopAll(); } catch (error) { errors.push(error instanceof Error ? error.message : String(error)); }
await this.edge?.close().catch(error => { errors.push(error instanceof Error ? error.message : String(error)); });
try { await this.database?.cleanup(); } catch (error) { errors.push(error instanceof Error ? error.message : String(error)); }
await new Promise<void>(resolve => this.log.end(resolve));
this.timeline.add("supervisor_stopped", { errors });
if (errors.length) throw new Error(`Supervisor cleanup incomplete: ${errors.join("; ")}`);
}
}