Files
rea/src/process/ProcessOwnership.exiting.test.ts
T

180 lines
5.3 KiB
TypeScript

import { expect, it, vi } from "vitest";
import {
cleanupOwnedProcessGroup,
type ProcessIdentityObservation,
type ProcessOwnershipHost,
type ProcessTableEntry,
} from "./ProcessOwnership.js";
import {
readProcessOwnershipFacts,
settleExitingProcessIdentities,
} from "./ProcessOwnershipObservation.js";
import { processIdentityHasExited } from "./ProcessLifecycle.js";
const row: ProcessTableEntry = {
pid: 910,
parentPid: 17,
processGroupId: 910,
state: "R",
command: "[MainThread]",
};
const identity = (
lifecycle: "non-zombie" | "exiting" | "zombie" | "dead",
generation = "start:1",
): ProcessIdentityObservation => ({
state: "readable",
identity: generation,
lifecycle: { state: "observed", lifecycle },
relation: {
state: "observed",
parentPid: row.parentPid,
processGroupId: row.processGroupId,
},
});
const hostWith = (
observe: () => ProcessIdentityObservation,
): ProcessOwnershipHost => ({
listProcesses: vi.fn(async () => [row]),
environment: async () => ({}),
processIdentities: vi.fn(
async (rows: readonly ProcessTableEntry[]) =>
new Map(rows.map(({ pid }) => [pid, observe()])),
),
runTokens: vi.fn(
async (rows: readonly ProcessTableEntry[]) =>
new Map(
rows.map(({ pid }) => [
pid,
{ state: "unavailable" as const, reason: "EACCES" },
]),
),
),
signalGroup: vi.fn(),
});
it.each(["zombie", "dead", "absent"] as const)(
"settles exiting token reads only after a fresh native %s observation",
async (terminal) => {
let reads = 0;
const host = hostWith(() =>
++reads < 3
? identity("exiting")
: terminal === "absent"
? { state: "absent", reason: "not-in-snapshot" }
: identity(terminal),
);
const facts = await readProcessOwnershipFacts([row], host);
expect(processIdentityHasExited(facts[0]?.identityAfter)).toBe(true);
expect(facts[0]?.token).toEqual({ state: "unavailable", reason: "EACCES" });
expect(host.processIdentities).toHaveBeenCalledTimes(3);
expect(host.runTokens).toHaveBeenCalledTimes(1);
expect(host.listProcesses).not.toHaveBeenCalled();
expect(host.signalGroup).not.toHaveBeenCalled();
},
);
it("keeps unconfirmed exit explicit at the bounded deadline", async () => {
const host = hostWith(() => identity("exiting"));
const result = await cleanupOwnedProcessGroup(
{
runId: "run",
leaderPid: row.pid,
processGroupId: row.processGroupId,
expectedCommand: "/exact/node",
},
host,
);
expect(result).toMatchObject({
cleaned: false,
reason: "owned launcher exit could not be confirmed",
});
expect(host.signalGroup).not.toHaveBeenCalled();
});
it("settles launcher exit before comparing a stale command display", async () => {
let reads = 0;
const host = hostWith(() =>
++reads === 1 ? identity("exiting") : identity("zombie"),
);
await expect(
cleanupOwnedProcessGroup(
{
runId: "run",
leaderPid: row.pid,
processGroupId: row.processGroupId,
expectedCommand: "/exact/node",
},
host,
),
).resolves.toMatchObject({ cleaned: true, signaled: false });
expect(host.signalGroup).not.toHaveBeenCalled();
});
it("stops at PID reuse and preserves the changed generation", async () => {
let reads = 0;
const host = hostWith(() =>
++reads < 3 ? identity("exiting") : identity("exiting", "start:2"),
);
const facts = await readProcessOwnershipFacts([row], host);
expect(facts[0]).toMatchObject({
state: "replaced",
identityBefore: { identity: "start:1" },
identityAfter: { identity: "start:2" },
});
expect(host.processIdentities).toHaveBeenCalledTimes(3);
expect(host.signalGroup).not.toHaveBeenCalled();
});
it("preserves unknown exit evidence without inferring terminal absence", async () => {
const host = hostWith(() => ({
state: "unavailable",
reason: "native identity denied",
}));
const result = await settleExitingProcessIdentities(
[row],
host,
new Map([[row.pid, identity("exiting")]]),
);
expect(result.get(row.pid)).toEqual({
state: "unavailable",
reason: "native identity denied",
});
expect(processIdentityHasExited(result.get(row.pid))).toBe(false);
});
it("refreshes only selected exiting PIDs and preserves other native observations", async () => {
const other = { ...row, pid: 911 };
const host = hostWith(() => identity("zombie"));
const initial = new Map([
[row.pid, identity("exiting")],
[other.pid, identity("non-zombie")],
]);
const result = await settleExitingProcessIdentities(
[row, other],
host,
initial,
);
expect(host.processIdentities).toHaveBeenCalledExactlyOnceWith(
[row],
undefined,
undefined,
);
expect(result.get(other.pid)).toBe(initial.get(other.pid));
expect(host.listProcesses).not.toHaveBeenCalled();
});
it("preserves caller cancellation during native exit settlement", async () => {
const abort = new AbortController();
const reason = new Error("caller cancelled exit observation");
const host = hostWith(() => identity("exiting"));
const pending = settleExitingProcessIdentities(
[row],
host,
new Map([[row.pid, identity("exiting")]]),
abort.signal,
);
abort.abort(reason);
await expect(pending).rejects.toBe(reason);
expect(host.processIdentities).not.toHaveBeenCalled();
});