180 lines
5.3 KiB
TypeScript
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();
|
|
});
|