Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
184 lines
7.2 KiB
TypeScript
184 lines
7.2 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, it } from "vitest";
|
|
import { createHost, ipcLink, isChordError } from "../src/index.ts";
|
|
import { type ControlMessage, createHostManager, type Driver, forkDriver, processLock } from "../src/manager.ts";
|
|
import { Echo } from "./fixtures/echo.ts";
|
|
|
|
const entry = fileURLToPath(new URL("./fixtures/worker.ts", import.meta.url));
|
|
const scratch = (name: string) => join(tmpdir(), `chord2-${process.pid}-${name}`);
|
|
|
|
const alive = (pid: number): boolean => {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const driver = (env: Record<string, string> = {}, lock?: string): Driver =>
|
|
forkDriver({ entry, env: { HOST_NAME: "w1", ...env }, init: { greeting: "hi" }, lock });
|
|
|
|
describe("processLock", () => {
|
|
// A socket path over 104 bytes failed with EINVAL on macOS when TMPDIR was deep (./test.sh below macOS's TMPDIR).
|
|
it("admits one holder when the temporary directory is too deep for a socket path", async () => {
|
|
const deep = join(scratch("deep"), "a".repeat(60), "b".repeat(60));
|
|
mkdirSync(deep, { recursive: true });
|
|
const previous = process.env.TMPDIR;
|
|
process.env.TMPDIR = deep;
|
|
try {
|
|
const name = `deep-${process.pid}`;
|
|
const release = await processLock(name);
|
|
await assert.rejects(processLock(name), (error) => isChordError(error) && error.reason === "locked");
|
|
await release();
|
|
await (await processLock(name))();
|
|
} finally {
|
|
if (previous === undefined) delete process.env.TMPDIR;
|
|
else process.env.TMPDIR = previous;
|
|
rmSync(scratch("deep"), { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("host manager (real child processes)", () => {
|
|
// A child host that forks its own children used to pass its CHORD2_LOCK on, so they failed with "locked".
|
|
it("a child without a lock does not inherit the parent's lock", async () => {
|
|
const name = `chord2-test-${process.pid}-inherited`;
|
|
const release = await processLock(name);
|
|
const previous = process.env.CHORD2_LOCK;
|
|
process.env.CHORD2_LOCK = name;
|
|
const manager = createHostManager({ driver: driver() });
|
|
try {
|
|
await manager.start();
|
|
assert.equal(manager.status, "running");
|
|
} finally {
|
|
if (previous === undefined) delete process.env.CHORD2_LOCK;
|
|
else process.env.CHORD2_LOCK = previous;
|
|
await manager.stop();
|
|
await release();
|
|
}
|
|
});
|
|
|
|
it("starts a child host, links it, stops it gracefully", async () => {
|
|
const manager = createHostManager({ driver: driver() });
|
|
const instance = await manager.start();
|
|
assert.equal(manager.status, "running");
|
|
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
|
server.accept(instance.link, { principal: { worker: "w1" }, kind: "host", name: "w1" });
|
|
const echo = server.hold(Echo, {});
|
|
assert.equal(await echo.echo("x"), `hi x from ${instance.pid}`);
|
|
assert.equal(await manager.start(), instance);
|
|
await manager.stop();
|
|
assert.equal(manager.status, "stopped");
|
|
assert.deepEqual(await instance.exited, { code: 0, signal: null });
|
|
await server.close();
|
|
});
|
|
|
|
it("a child-owned kernel lock admits one live process; replace waits for the exit before the next start", async () => {
|
|
const lock = `chord2-test-${process.pid}-lock`;
|
|
const pids: number[] = [];
|
|
const base = driver({}, lock);
|
|
const watched: Driver = {
|
|
spawn() {
|
|
// Exit before replace: the previous child is gone when the next one spawns.
|
|
for (const pid of pids) assert.ok(!alive(pid), `pid ${pid} still alive`);
|
|
const instance = base.spawn();
|
|
pids.push(instance.pid);
|
|
return instance;
|
|
},
|
|
};
|
|
const first = createHostManager({ driver: watched });
|
|
const second = createHostManager({ driver: driver({}, lock) });
|
|
const a = await first.start();
|
|
// Both managers race for the same lock; only the kernel decides, so the second child cannot start.
|
|
await assert.rejects(second.start(), (error) => isChordError(error, "unavailable") && error.reason === "locked");
|
|
const b = await first.replace();
|
|
assert.notEqual(a.pid, b.pid);
|
|
await first.stop();
|
|
await second.start();
|
|
await second.stop();
|
|
});
|
|
|
|
it("a graceful exit still takes the child's descendants with it", async () => {
|
|
const file = scratch("grandchild-graceful");
|
|
rmSync(file, { force: true });
|
|
const manager = createHostManager({ driver: driver({ GRANDCHILD_FILE: file }) });
|
|
await manager.start();
|
|
const grandchild = Number(readFileSync(file, "utf8"));
|
|
assert.ok(alive(grandchild));
|
|
await manager.stop();
|
|
for (let i = 0; i < 50 && alive(grandchild); i++) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
assert.ok(!alive(grandchild));
|
|
rmSync(file, { force: true });
|
|
});
|
|
|
|
it("idleFor counts from the busy → idle transition", async () => {
|
|
const listeners = new Set<(message: ControlMessage) => void>();
|
|
const fake: Driver = {
|
|
spawn: () => ({
|
|
pid: 0,
|
|
link: ipcLink({ connected: false, send: () => false, on: () => undefined, off: () => undefined }),
|
|
exited: new Promise(() => {}),
|
|
send: () => {},
|
|
killTree: () => {},
|
|
onControl: (listener) => listeners.add(listener),
|
|
}),
|
|
};
|
|
const emit = (message: ControlMessage) => {
|
|
for (const listener of listeners) listener(message);
|
|
};
|
|
const manager = createHostManager({ driver: fake });
|
|
const started = manager.start();
|
|
for (let i = 0; i < 20 && listeners.size === 0; i++) await new Promise((resolve) => setImmediate(resolve));
|
|
emit({ type: "ready" });
|
|
await started;
|
|
emit({ type: "status", activeCalls: 1 });
|
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
assert.equal(manager.liveness.idleFor, 0);
|
|
emit({ type: "status", activeCalls: 0 });
|
|
assert.ok(manager.liveness.idleFor < 60, `idleFor ${manager.liveness.idleFor}`);
|
|
});
|
|
|
|
it("kills the process tree when a child ignores shutdown past the grace period", async () => {
|
|
const file = scratch("grandchild");
|
|
rmSync(file, { force: true });
|
|
const manager = createHostManager({
|
|
driver: driver({ FIXTURE_MODE: "ignore-shutdown", GRANDCHILD_FILE: file }),
|
|
grace: 300,
|
|
});
|
|
const instance = await manager.start();
|
|
const grandchild = Number(readFileSync(file, "utf8"));
|
|
assert.ok(alive(grandchild));
|
|
await manager.stop();
|
|
assert.equal((await instance.exited).signal, "SIGKILL");
|
|
for (let i = 0; i < 50 && alive(grandchild); i++) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
assert.ok(!alive(grandchild));
|
|
rmSync(file, { force: true });
|
|
});
|
|
|
|
it("reports a fatal child as a failed start", async () => {
|
|
const manager = createHostManager({ driver: driver({ FIXTURE_MODE: "fatal" }) });
|
|
await assert.rejects(manager.start(), /fixture refuses to start/);
|
|
assert.equal(manager.status, "stopped");
|
|
});
|
|
|
|
it("stops an idle child when keepAlive says so", async () => {
|
|
const exits: unknown[] = [];
|
|
const manager = createHostManager({
|
|
driver: driver(),
|
|
keepAlive: (liveness) => !liveness.booting && liveness.idleFor < 150,
|
|
checkInterval: 50,
|
|
onExit: (exit) => exits.push(exit),
|
|
});
|
|
await manager.start();
|
|
for (let i = 0; i < 100 && manager.status !== "stopped"; i++)
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
assert.equal(manager.status, "stopped");
|
|
assert.equal(exits.length, 1);
|
|
});
|
|
});
|