Files
stack/packages/conversation/tests/ctrl-child.mjs
T
jason.woltjeandClaude Opus 5.5 717e3404f4 feat(conversation): release a stopped cohort scope on start and recover, no kill after a proven stop (#1537)
Row 51, owner Dewey, candidate c34039dc (5 files). A claim recorded
stopped on a cohort proof whose scope is still listed is released on
start (classify's stopped branch and the free path's session and seat
heads) and on a confirmed recover, after every recover check. A release
that ended unavailable or still listed is retried; the proof check stays
in #releaseScope. close({ killEngine: true }) no longer signals the
recorded PID after a proven stop. Tests R7-R12 kill relok, closeany,
noprooffkind and nopush; R9 pins the recover order.

Reviews: Filbert approve (27070, 27074), Darkwing changes then approve
(27071, 27075). Gate green on 032b5408 plus the candidate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 02:06:20 -05:00

90 lines
4.3 KiB
JavaScript
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.
#!/usr/bin/env node
// A controller in its own process, for the crash fixtures (#1507: W5, W6,
// W12–W15, W20, H18, H21–H23, K-series). A test can't kill the controller it
// runs in, and the claim's owner check sees the test's own pid as live, so
// these fixtures run the controller here.
//
// argv[2] is JSON: { fx, launcher: "pgroup" | "scope", dieAt, dieAtState,
// holdAt, timeouts, host, fakeEnv, verifier }. Lines on stdout are JSON:
// { ready, ... } after start, { held: name } at a held barrier,
// { dying: name } just before a SIGKILL at `dieAt`, or at `dieAtState`'s
// barrier when the claim patch there has that state. Lines on stdin:
// "go" releases a held barrier; "close" closes and exits; "kill-engine"
// closes with the engine killed; "launch <eligibility>" launches after a
// recover; "evidence" and "proof" print the controller's evidence and its
// verified cohort proof.
import { createInterface } from "node:readline";
import { join } from "node:path";
import { Controller, TEST_ENGINE } from "../src/controller.mjs";
import { AUTHORITY, PgroupLauncher, ScopeLauncher, systemdUnits } from "../src/cohort.mjs";
import { FixtureVerifier } from "../src/records.mjs";
import { defaultHost } from "../src/claim.mjs";
const cfg = JSON.parse(process.argv[2]);
const out = (v) => process.stdout.write(JSON.stringify(v) + "\n");
const dieAt = new Map(Object.entries(cfg.dieAt ?? {}));
const holdAt = new Set(cfg.holdAt ?? []);
let release = null;
// The test's stdin pipe is line-oriented control, not engine input.
const rl = createInterface({ input: process.stdin });
let ctrl = null;
rl.on("line", async (line) => {
if (line === "go" && release) {
const r = release;
release = null;
r();
} else if (line === "close" || line === "kill-engine") {
await ctrl?.close({ killEngine: line === "kill-engine" });
out({ closed: true });
process.exit(0);
} else if (line === "evidence") out({ evidence: ctrl?.evidence ?? null, binding: ctrl?.binding ?? null });
else if (line === "proof") out({ proof: ctrl?.stoppedProof?.proof ?? null });
else if (line.startsWith("launch ")) {
// The launcher's call after `recover` (Q15), never a client command.
try {
const r = await ctrl.launch(line.slice(7));
out({ launched: true, binding: r.binding, incarnation: ctrl.incarnation });
} catch (err) {
out({ launched: false, error: String(err.code ?? "error"), message: String(err.message) });
}
}
});
// `dieAt` maps a barrier name to the occurrence to die at (1 = first).
const seen = new Map();
async function barrier(name, detail) {
const n = (seen.get(name) ?? 0) + 1;
seen.set(name, n);
if (cfg.trace) out({ barrier: name, n });
if (dieAt.get(name) === n || (cfg.dieAtState?.[name] && detail?.patch?.state === cfg.dieAtState[name])) {
out({ dying: name, n, detail });
process.kill(process.pid, "SIGKILL");
await new Promise(() => {});
}
if (holdAt.has(`${name}#${n}`) || (n === 1 && holdAt.has(name))) {
out({ held: name, n, detail });
await new Promise((r) => (release = r));
}
}
const host = cfg.host ? { machineId: () => cfg.host.machineId ?? defaultHost.machineId(), bootId: () => cfg.host.bootId ?? defaultHost.bootId() } : undefined;
const fx = cfg.fx;
const fakePi = join(import.meta.dirname, "fake-pi.mjs");
try {
ctrl = new Controller({
fixtureRoot: fx.base, claimRoot: fx.claimRoot, socketDir: cfg.socketDir ?? fx.socketDir, sessionFile: fx.sessionFile, seat: fx.seat,
launcher: cfg.launcher === "scope" ? new ScopeLauncher() : new PgroupLauncher(),
engine: { cwd: fx.proj }, [TEST_ENGINE]: { command: process.execPath, preArgs: [fakePi], env: { ...process.env, ...(cfg.fakeEnv ?? {}) } },
verifier: cfg.verifier === false ? null : new FixtureVerifier({ authorities: [AUTHORITY] }),
units: cfg.units === "absent" ? { lookup: async () => ({ state: "absent" }) } : systemdUnits,
host, barrier, timeouts: cfg.timeouts,
});
const started = await ctrl.start();
out({ ready: true, started, socketPath: ctrl.socketPath, incarnation: ctrl.incarnation, pid: process.pid, binding: ctrl.binding, claim: ctrl.claim ? { claimId: ctrl.claim.claimId, record: ctrl.claim.record } : null });
} catch (err) {
out({ error: String(err.code ?? "error"), message: String(err.message) });
process.exit(3);
}