182 lines
8.2 KiB
TypeScript
182 lines
8.2 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { EventEmitter } from "node:events";
|
|
import test from "node:test";
|
|
import {
|
|
runOwnedFixtureHook,
|
|
startOwnedFixtureProcess,
|
|
type FixtureChildProcess,
|
|
type FixtureProcessAdapter,
|
|
type FixtureProcessIdentity,
|
|
} from "./fixture-process.ts";
|
|
|
|
class FakeChild extends EventEmitter implements FixtureChildProcess {
|
|
readonly pid: number;
|
|
readonly stdout = new EventEmitter();
|
|
readonly stderr = new EventEmitter();
|
|
constructor(pid: number) { super(); this.pid = pid; }
|
|
}
|
|
|
|
class FakeAdapter implements FixtureProcessAdapter {
|
|
readonly platform = "linux" as const;
|
|
readonly signals: Array<{ group: number; signal: string }> = [];
|
|
readonly identities = new Map<number, FixtureProcessIdentity>();
|
|
readonly groups = new Map<number, FixtureProcessIdentity[]>();
|
|
readonly child: FakeChild;
|
|
termLeavesDescendant = false;
|
|
autoExit = false;
|
|
spawnCount = 0;
|
|
|
|
constructor(pid = 40_001) {
|
|
this.child = new FakeChild(pid);
|
|
const root = { pid, pgrp: pid, session: pid, startTime: "root-start", state: "R" };
|
|
this.identities.set(pid, root);
|
|
this.groups.set(pid, [root]);
|
|
}
|
|
|
|
spawn(): FixtureChildProcess {
|
|
this.spawnCount += 1;
|
|
queueMicrotask(() => {
|
|
this.child.emit("spawn");
|
|
if (this.autoExit) this.child.emit("exit", 0, null);
|
|
});
|
|
return this.child;
|
|
}
|
|
|
|
identity(pid: number): FixtureProcessIdentity | undefined { return this.identities.get(pid); }
|
|
groupMembers(group: number): readonly FixtureProcessIdentity[] | undefined { return this.groups.get(group); }
|
|
signalGroup(group: number, signal: "SIGTERM" | "SIGKILL"): void {
|
|
this.signals.push({ group, signal });
|
|
if (signal === "SIGTERM" && this.termLeavesDescendant) {
|
|
const root = this.groups.get(group)?.find((member) => member.pid === this.child.pid);
|
|
this.groups.set(group, root ? [{ ...root, state: "Z" }, { pid: group + 1, pgrp: group, session: group, startTime: "descendant", state: "R" }] : []);
|
|
this.identities.delete(this.child.pid);
|
|
this.child.emit("exit", 143, "SIGTERM");
|
|
} else {
|
|
this.groups.set(group, []);
|
|
this.identities.delete(this.child.pid);
|
|
this.child.emit("exit", 137, "SIGKILL");
|
|
}
|
|
}
|
|
}
|
|
|
|
const signal = () => new AbortController().signal;
|
|
|
|
function start(adapter: FakeAdapter, overrides: Partial<Parameters<typeof startOwnedFixtureProcess>[0]> = {}) {
|
|
return startOwnedFixtureProcess({ argv: ["fixture", "--foreground"], cwd: "/tmp/fixture", env: { FIXTURE: "1" }, adapter, termTimeoutMs: 20, killTimeoutMs: 20, ...overrides }, signal());
|
|
}
|
|
|
|
test("a successful one-shot cleans a descendant after its root exits", async () => {
|
|
const adapter = new FakeAdapter();
|
|
adapter.termLeavesDescendant = true;
|
|
adapter.autoExit = true;
|
|
const result = await runOwnedFixtureHook({ argv: ["fixture"], cwd: "/tmp/fixture", env: {}, adapter, timeoutMs: 100, termTimeoutMs: 10, killTimeoutMs: 10 }, signal());
|
|
assert.equal(result.exit.code, 0);
|
|
assert.deepEqual(adapter.signals.map((item) => item.signal), ["SIGTERM", "SIGKILL"]);
|
|
assert.equal(adapter.groups.get(adapter.child.pid)?.length, 0);
|
|
});
|
|
|
|
test("cancellation uses bounded owned cleanup and does not expose the abort reason", async () => {
|
|
const adapter = new FakeAdapter();
|
|
adapter.termLeavesDescendant = true;
|
|
const controller = new AbortController();
|
|
const running = runOwnedFixtureHook({ argv: ["fixture", "--secret-token"], cwd: "/tmp/fixture", env: { TOKEN: "private" }, adapter, timeoutMs: 100, termTimeoutMs: 10, killTimeoutMs: 10 }, controller.signal);
|
|
controller.abort(new Error("private secret and raw argv must not escape"));
|
|
await assert.rejects(running, (error: unknown) => {
|
|
assert.match(String(error), /fixture hook cancelled/);
|
|
assert.doesNotMatch(String(error), /private|secret-token/);
|
|
return true;
|
|
});
|
|
assert.deepEqual(adapter.signals.map((item) => item.signal), ["SIGTERM", "SIGKILL"]);
|
|
});
|
|
|
|
test("deadline escalates from SIGTERM to SIGKILL", async () => {
|
|
const adapter = new FakeAdapter();
|
|
adapter.termLeavesDescendant = true;
|
|
await assert.rejects(runOwnedFixtureHook({ argv: ["fixture"], cwd: "/tmp/fixture", env: {}, adapter, timeoutMs: 5, termTimeoutMs: 10, killTimeoutMs: 10 }, signal()), /fixture hook exceeded its deadline/);
|
|
assert.deepEqual(adapter.signals.map((item) => item.signal), ["SIGTERM", "SIGKILL"]);
|
|
});
|
|
|
|
test("invalid, self, and non-integer PIDs are rejected before identity probes or signals", async () => {
|
|
for (const pid of [0, -1, 1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, process.pid]) {
|
|
const adapter = new FakeAdapter(pid);
|
|
let probes = 0;
|
|
const identity = adapter.identity.bind(adapter);
|
|
adapter.identity = (candidate) => { probes += 1; return identity(candidate); };
|
|
const handle = start(adapter);
|
|
adapter.child.emit("exit", 0, null);
|
|
await handle.stop();
|
|
assert.equal(probes, 0, `identity was probed for pid ${String(pid)}`);
|
|
assert.deepEqual(adapter.signals, []);
|
|
}
|
|
});
|
|
|
|
test("PID reuse or an unrelated process-group member fails closed without signaling", async () => {
|
|
const adapter = new FakeAdapter();
|
|
const handle = start(adapter);
|
|
adapter.groups.set(adapter.child.pid, [{ pid: adapter.child.pid, pgrp: adapter.child.pid, session: adapter.child.pid, startTime: "reused", state: "R" }]);
|
|
adapter.identities.set(adapter.child.pid, { pid: adapter.child.pid, pgrp: adapter.child.pid, session: adapter.child.pid, startTime: "reused", state: "R" });
|
|
adapter.child.emit("exit", 0, null);
|
|
await assert.rejects(handle.stop(), /ownership could not be confirmed/);
|
|
assert.deepEqual(adapter.signals, []);
|
|
});
|
|
|
|
test("uncertain process-group inspection fails closed without signaling", async () => {
|
|
const adapter = new FakeAdapter();
|
|
const original = adapter.groupMembers.bind(adapter);
|
|
adapter.groupMembers = () => undefined;
|
|
const handle = start(adapter);
|
|
adapter.child.emit("exit", 0, null);
|
|
await assert.rejects(handle.stop(), /liveness could not be confirmed/);
|
|
assert.deepEqual(adapter.signals, []);
|
|
adapter.groupMembers = original;
|
|
});
|
|
|
|
test("an unrelated process-group identity fails closed", async () => {
|
|
const adapter = new FakeAdapter();
|
|
const handle = start(adapter);
|
|
adapter.groups.set(adapter.child.pid, [
|
|
{ pid: adapter.child.pid, pgrp: adapter.child.pid, session: adapter.child.pid, startTime: "root-start", state: "Z" },
|
|
{ pid: 40_999, pgrp: adapter.child.pid, session: adapter.child.pid + 1, startTime: "foreign", state: "R" },
|
|
]);
|
|
adapter.identities.delete(adapter.child.pid);
|
|
adapter.child.emit("exit", 0, null);
|
|
await assert.rejects(handle.stop(), /ownership could not be confirmed/);
|
|
assert.deepEqual(adapter.signals, []);
|
|
});
|
|
|
|
test("missing executable is safe and does not leak output to process stdout", async () => {
|
|
const adapter = new FakeAdapter();
|
|
adapter.spawn = function spawn() {
|
|
this.spawnCount += 1;
|
|
queueMicrotask(() => {
|
|
this.groups.set(this.child.pid, []);
|
|
this.identities.delete(this.child.pid);
|
|
this.child.emit("error", new Error("ENOENT /secret/private-executable"));
|
|
});
|
|
return this.child;
|
|
};
|
|
const result = await assert.rejects(runOwnedFixtureHook({ argv: ["missing", "raw-secret"], cwd: "/tmp/fixture", env: { PASSWORD: "private" }, adapter, timeoutMs: 100 }, signal()), /fixture hook failed to start/);
|
|
assert.equal(result, undefined);
|
|
assert.deepEqual(adapter.signals, []);
|
|
});
|
|
|
|
test("stdout and stderr are captured with finite bounds", async () => {
|
|
const adapter = new FakeAdapter();
|
|
const handle = start(adapter, { maxStdoutBytes: 4, maxStderrBytes: 3 });
|
|
adapter.child.stdout.emit("data", "abcdef");
|
|
adapter.child.stderr.emit("data", "wxyz");
|
|
adapter.groups.set(adapter.child.pid, []);
|
|
adapter.identities.delete(adapter.child.pid);
|
|
adapter.child.emit("exit", 0, null);
|
|
await handle.stop();
|
|
assert.deepEqual(await handle.stdout, { text: "abcd", truncated: true });
|
|
assert.deepEqual(await handle.stderr, { text: "wxy", truncated: true });
|
|
});
|
|
|
|
test("unsupported platforms fail before spawning", () => {
|
|
const adapter = new FakeAdapter() as FakeAdapter & { platform: NodeJS.Platform };
|
|
Object.defineProperty(adapter, "platform", { value: "win32" });
|
|
assert.throws(() => startOwnedFixtureProcess({ argv: ["fixture"], cwd: "/tmp", env: {}, adapter }, signal()), /unavailable on this platform/);
|
|
assert.equal(adapter.spawnCount, 0);
|
|
});
|