mirror of
https://github.com/morluto/rea.git
synced 2026-10-28 18:35:53 +00:00
fix(process): preserve partial evidence and verify detached cleanup (#1011)
* test(ci): allow transport delivery in source identity regression * fix(process): preserve partial evidence and verify detached cleanup * fix(process): scope Linux token sweeps by stable process identity * fix(process): retain environment permission failure codes * fix(process): wait for signaled processes before verifying cleanup * fix(process): verify sampled detached groups during cleanup * fix(electron): preserve prelaunch identities through cleanup * fix(process): retry growing Darwin process table snapshots * fix(process): retain compatibility with saved v3 capture evidence * fix(process): migrate saved v3 captures during reconstruction verification * fix(process): preserve cancellation during Darwin helper compilation * fix(process): propagate cancellation through ownership baselines * perf(javascript): cache canonical export comparison sort keys * fix(process): cancel provider ownership preparation before spawn * fix(process): isolate native compiler waiter cancellation
This commit is contained in:
1 parent
63ba7424c0
commit
63cf28fd8f
51 files changed
+5506
-316
No files matched your search
@@ -0,0 +1,361 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { z } from "zod";
|
||||
|
||||
import { safeParseJson } from "../domain/safeJson.js";
|
||||
import { execFileOutput } from "./ExecFileOutput.js";
|
||||
import type {
|
||||
ProcessIdentityObservation,
|
||||
ProcessRunTokenObservation,
|
||||
ProcessTableEntry,
|
||||
} from "./ProcessOwnership.js";
|
||||
|
||||
const responseSchema = z.strictObject({
|
||||
results: z.array(
|
||||
z.discriminatedUnion("state", [
|
||||
z.strictObject({
|
||||
pid: z.number().int().positive(),
|
||||
state: z.literal("readable"),
|
||||
run_id: z.string().optional(),
|
||||
reason: z.null().optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
pid: z.number().int().positive(),
|
||||
state: z.literal("unavailable"),
|
||||
run_id: z.union([z.string(), z.null()]).optional(),
|
||||
reason: z.string().min(1),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
});
|
||||
const identityResponseSchema = z.strictObject({
|
||||
results: z.array(
|
||||
z.discriminatedUnion("state", [
|
||||
z.strictObject({
|
||||
pid: z.number().int().positive(),
|
||||
state: z.literal("readable"),
|
||||
identity: z.string().min(1),
|
||||
reason: z.null().optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
pid: z.number().int().positive(),
|
||||
state: z.literal("unavailable"),
|
||||
identity: z.null().optional(),
|
||||
reason: z.string().min(1),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
const sourceFiles = [
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"../../bridge/process/ProcessRunTokenReader.swift",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"../../bridge/process/ProcessRunTokenReaderMain.swift",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
/** The native Darwin process-ownership reader could not be prepared. */
|
||||
export class DarwinProcessOwnershipInspectionError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "DarwinProcessOwnershipInspectionError";
|
||||
}
|
||||
}
|
||||
|
||||
interface ProcessReaderCompilation {
|
||||
readonly controller: AbortController;
|
||||
promise: Promise<string>;
|
||||
waiters: number;
|
||||
aborting: boolean;
|
||||
settled: boolean;
|
||||
}
|
||||
|
||||
const abortReason = (signal: AbortSignal): unknown => {
|
||||
if (signal.reason !== undefined) return signal.reason;
|
||||
const error = new Error("The operation was aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
};
|
||||
|
||||
const isExpectedAbort = (cause: unknown, signal: AbortSignal): boolean =>
|
||||
signal.aborted &&
|
||||
(cause === signal.reason ||
|
||||
(cause instanceof Error && cause.name === "AbortError"));
|
||||
|
||||
const waitForCompilation = async (
|
||||
promise: Promise<string>,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<string> => {
|
||||
if (signal === undefined) return promise;
|
||||
if (signal.aborted) throw abortReason(signal);
|
||||
return new Promise((resolve, reject) => {
|
||||
let completed = false;
|
||||
const finish = (callback: () => void) => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
callback();
|
||||
};
|
||||
const onAbort = () => finish(() => reject(abortReason(signal)));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
promise.then(
|
||||
(value) => finish(() => resolve(value)),
|
||||
(cause: unknown) => finish(() => reject(cause)),
|
||||
);
|
||||
if (signal.aborted) onAbort();
|
||||
});
|
||||
};
|
||||
|
||||
/** Compile and retain the narrow Darwin sysctl reader for this REA process. */
|
||||
export const createDarwinProcessRunTokenReader = (
|
||||
options: { readonly xcrun?: string } = {},
|
||||
) => {
|
||||
let root: string | undefined;
|
||||
let executable: string | undefined;
|
||||
let compilation: ProcessReaderCompilation | undefined;
|
||||
let closePromise: Promise<void> | undefined;
|
||||
let closed = false;
|
||||
let exitCleanupInstalled = false;
|
||||
|
||||
const cleanupAtExit = () => {
|
||||
if (root !== undefined) rmSync(root, { recursive: true, force: true });
|
||||
};
|
||||
const cleanupRoot = async (target = root): Promise<void> => {
|
||||
if (target === undefined) return;
|
||||
await rm(target, { recursive: true, force: true });
|
||||
if (root !== target) return;
|
||||
root = undefined;
|
||||
executable = undefined;
|
||||
if (exitCleanupInstalled) process.removeListener("exit", cleanupAtExit);
|
||||
exitCleanupInstalled = false;
|
||||
};
|
||||
const runCompilation = async (signal: AbortSignal): Promise<string> => {
|
||||
let operationRoot: string | undefined;
|
||||
try {
|
||||
operationRoot = await mkdtemp(join(tmpdir(), "rea-process-token-"));
|
||||
root = operationRoot;
|
||||
process.once("exit", cleanupAtExit);
|
||||
exitCleanupInstalled = true;
|
||||
if (signal.aborted || closed) throw abortReason(signal);
|
||||
const output = join(operationRoot, "reader");
|
||||
try {
|
||||
await execFileOutput(
|
||||
options.xcrun ?? "/usr/bin/xcrun",
|
||||
[
|
||||
"swiftc",
|
||||
"-module-cache-path",
|
||||
join(operationRoot, "modules"),
|
||||
...sourceFiles,
|
||||
"-o",
|
||||
output,
|
||||
],
|
||||
{
|
||||
timeout: 60_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (isExpectedAbort(cause, signal)) throw cause;
|
||||
const code =
|
||||
cause instanceof Error && "code" in cause
|
||||
? String(cause.code)
|
||||
: "unknown";
|
||||
throw new DarwinProcessOwnershipInspectionError(
|
||||
`macOS process ownership inspection requires the Apple Swift compiler via xcrun (compiler result: ${code})`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
if (signal.aborted || closed) throw abortReason(signal);
|
||||
executable = output;
|
||||
return output;
|
||||
} catch (cause: unknown) {
|
||||
const ownsRoot = operationRoot !== undefined && root === operationRoot;
|
||||
if (operationRoot !== undefined) {
|
||||
if (ownsRoot) await cleanupRoot(operationRoot);
|
||||
else await rm(operationRoot, { recursive: true, force: true });
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
const beginCompilation = (): ProcessReaderCompilation => {
|
||||
const controller = new AbortController();
|
||||
const operation: ProcessReaderCompilation = {
|
||||
controller,
|
||||
promise: runCompilation(controller.signal).finally(() => {
|
||||
operation.settled = true;
|
||||
if (compilation === operation) compilation = undefined;
|
||||
}),
|
||||
waiters: 0,
|
||||
aborting: false,
|
||||
settled: false,
|
||||
};
|
||||
compilation = operation;
|
||||
return operation;
|
||||
};
|
||||
|
||||
const compile = async (signal?: AbortSignal): Promise<string> => {
|
||||
if (closed) throw new Error("Darwin process token reader is closed");
|
||||
signal?.throwIfAborted();
|
||||
while (true) {
|
||||
if (closed) throw new Error("Darwin process token reader is closed");
|
||||
signal?.throwIfAborted();
|
||||
if (executable !== undefined) return executable;
|
||||
if (compilation === undefined && root !== undefined) {
|
||||
await cleanupRoot();
|
||||
continue;
|
||||
}
|
||||
let operation = compilation;
|
||||
if (operation?.aborting === true) {
|
||||
try {
|
||||
await waitForCompilation(operation.promise, signal);
|
||||
} catch (cause: unknown) {
|
||||
if (signal !== undefined && isExpectedAbort(cause, signal))
|
||||
throw cause;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
operation ??= beginCompilation();
|
||||
operation.waiters += 1;
|
||||
try {
|
||||
return await waitForCompilation(operation.promise, signal);
|
||||
} finally {
|
||||
operation.waiters -= 1;
|
||||
if (
|
||||
operation.waiters === 0 &&
|
||||
!operation.settled &&
|
||||
!operation.aborting
|
||||
) {
|
||||
operation.aborting = true;
|
||||
operation.controller.abort();
|
||||
await operation.promise.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const read = async (
|
||||
processes: readonly ProcessTableEntry[],
|
||||
): Promise<ReadonlyMap<number, ProcessRunTokenObservation>> => {
|
||||
if (processes.length === 0) return new Map();
|
||||
const binary = await compile();
|
||||
const pids = processes.map(({ pid }) => String(pid));
|
||||
const { stdout } = await execFileOutput(binary, pids, { timeout: 15_000 });
|
||||
const decoded = safeParseJson(stdout);
|
||||
if (!decoded.ok)
|
||||
throw new Error(
|
||||
`macOS process token helper returned invalid JSON: ${decoded.error}`,
|
||||
{ cause: decoded.cause },
|
||||
);
|
||||
const parsed = responseSchema.safeParse(decoded.value);
|
||||
if (!parsed.success)
|
||||
throw new Error(
|
||||
"macOS process token helper returned an invalid response",
|
||||
{
|
||||
cause: parsed.error,
|
||||
},
|
||||
);
|
||||
const requested = new Set(processes.map(({ pid }) => pid));
|
||||
const observations = new Map<number, ProcessRunTokenObservation>();
|
||||
for (const result of parsed.data.results) {
|
||||
if (!requested.has(result.pid) || observations.has(result.pid))
|
||||
throw new Error(
|
||||
"macOS process token helper returned an unexpected PID set",
|
||||
);
|
||||
observations.set(
|
||||
result.pid,
|
||||
result.state === "readable"
|
||||
? { state: "readable", runId: result.run_id }
|
||||
: {
|
||||
state: "unavailable",
|
||||
reason:
|
||||
result.reason ?? "process token observation is unavailable",
|
||||
},
|
||||
);
|
||||
}
|
||||
if (observations.size !== requested.size)
|
||||
throw new Error(
|
||||
"macOS process token helper returned an incomplete PID set",
|
||||
);
|
||||
return observations;
|
||||
};
|
||||
|
||||
const identities = async (
|
||||
processes: readonly ProcessTableEntry[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReadonlyMap<number, ProcessIdentityObservation>> => {
|
||||
if (processes.length === 0) return new Map();
|
||||
const binary = await compile(signal);
|
||||
const pids = processes.map(({ pid }) => String(pid));
|
||||
const { stdout } = await execFileOutput(binary, ["--identities", ...pids], {
|
||||
timeout: 15_000,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
const decoded = safeParseJson(stdout);
|
||||
if (!decoded.ok)
|
||||
throw new Error(
|
||||
`macOS process identity helper returned invalid JSON: ${decoded.error}`,
|
||||
{ cause: decoded.cause },
|
||||
);
|
||||
const parsed = identityResponseSchema.safeParse(decoded.value);
|
||||
if (!parsed.success)
|
||||
throw new Error(
|
||||
"macOS process identity helper returned an invalid response",
|
||||
{
|
||||
cause: parsed.error,
|
||||
},
|
||||
);
|
||||
const requested = new Set(processes.map(({ pid }) => pid));
|
||||
const observations = new Map<number, ProcessIdentityObservation>();
|
||||
for (const result of parsed.data.results) {
|
||||
if (!requested.has(result.pid) || observations.has(result.pid))
|
||||
throw new Error(
|
||||
"macOS process identity helper returned an unexpected PID set",
|
||||
);
|
||||
observations.set(
|
||||
result.pid,
|
||||
result.state === "readable"
|
||||
? { state: "readable", identity: result.identity }
|
||||
: { state: "unavailable", reason: result.reason },
|
||||
);
|
||||
}
|
||||
if (observations.size !== requested.size)
|
||||
throw new Error(
|
||||
"macOS process identity helper returned an incomplete PID set",
|
||||
);
|
||||
return observations;
|
||||
};
|
||||
|
||||
const close = (): Promise<void> => {
|
||||
if (closePromise !== undefined) return closePromise;
|
||||
closed = true;
|
||||
const pendingClose = (async () => {
|
||||
const operation = compilation;
|
||||
if (operation !== undefined && !operation.settled) {
|
||||
operation.aborting = true;
|
||||
operation.controller.abort();
|
||||
await operation.promise.catch(() => undefined);
|
||||
}
|
||||
await cleanupRoot();
|
||||
})().catch((cause: unknown) => {
|
||||
if (closePromise === pendingClose) closePromise = undefined;
|
||||
throw cause;
|
||||
});
|
||||
closePromise = pendingClose;
|
||||
return closePromise;
|
||||
};
|
||||
|
||||
return { read, identities, prepare: compile, close };
|
||||
};
|
||||
@@ -0,0 +1,481 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanupOwnedProcessGroup,
|
||||
verifyNoTokenOwnedProcesses,
|
||||
type ProcessOwnershipHost,
|
||||
type ProcessTableEntry,
|
||||
} from "./ProcessOwnership.js";
|
||||
import {
|
||||
createSystemProcessOwnershipHost,
|
||||
parseLinuxProcessStartTime,
|
||||
} from "./ProcessOwnershipObservation.js";
|
||||
|
||||
const itOnLinux = process.platform === "linux" ? it : it.skip;
|
||||
|
||||
describe("Linux process start identities", () => {
|
||||
itOnLinux("reads Linux process start identities from proc stat", async () => {
|
||||
const host = createSystemProcessOwnershipHost("linux", {});
|
||||
expect(host.processIdentities).toBeDefined();
|
||||
expect(host.captureBaseline).toBeDefined();
|
||||
const observedProcess = {
|
||||
pid: process.pid,
|
||||
parentPid: process.ppid,
|
||||
processGroupId: process.pid,
|
||||
state: "S",
|
||||
command: process.execPath,
|
||||
} satisfies ProcessTableEntry;
|
||||
const identities = await host.processIdentities?.([observedProcess]);
|
||||
expect(identities?.get(observedProcess.pid)).toMatchObject({
|
||||
state: "readable",
|
||||
identity: expect.stringMatching(/^linux-starttime:\d+$/u),
|
||||
});
|
||||
const baseline = await host.captureBaseline?.();
|
||||
expect(baseline).toContainEqual({
|
||||
pid: observedProcess.pid,
|
||||
identity: expect.stringMatching(/^linux-starttime:\d+$/u),
|
||||
});
|
||||
});
|
||||
|
||||
it("parses stat start time after a command name containing parentheses", () => {
|
||||
const fields = Array.from({ length: 20 }, (_, index) =>
|
||||
index === 0 ? "S" : index === 19 ? "987654" : "0",
|
||||
);
|
||||
expect(
|
||||
parseLinuxProcessStartTime(
|
||||
`42 (worker ) old ) name) ${fields.join(" ")}`,
|
||||
),
|
||||
).toBe("987654");
|
||||
expect(
|
||||
parseLinuxProcessStartTime("42 (worker) S too-short"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("capture-local process ownership", () => {
|
||||
it("validates and cleans an exact-token owner without a capture baseline", async () => {
|
||||
const process: ProcessTableEntry = {
|
||||
pid: 904,
|
||||
parentPid: 1,
|
||||
processGroupId: 904,
|
||||
state: "S",
|
||||
command: "generic-darwin-owner",
|
||||
};
|
||||
const signalGroup = vi.fn();
|
||||
const host: ProcessOwnershipHost = {
|
||||
listProcesses: () => Promise.resolve([process]),
|
||||
environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
|
||||
runTokens: (entries) =>
|
||||
Promise.resolve(
|
||||
new Map(
|
||||
entries.map(({ pid }) => [
|
||||
pid,
|
||||
{ state: "readable", runId: "run-token" } as const,
|
||||
]),
|
||||
),
|
||||
),
|
||||
processIdentities: (entries) =>
|
||||
Promise.resolve(
|
||||
new Map(
|
||||
entries.map(({ pid }) => [
|
||||
pid,
|
||||
{ state: "readable", identity: "owner-start" } as const,
|
||||
]),
|
||||
),
|
||||
),
|
||||
signalGroup,
|
||||
};
|
||||
|
||||
await expect(
|
||||
cleanupOwnedProcessGroup(
|
||||
{
|
||||
runId: "run-token",
|
||||
leaderPid: process.pid,
|
||||
processGroupId: process.processGroupId,
|
||||
sweepTokenOwnedProcesses: true,
|
||||
},
|
||||
host,
|
||||
),
|
||||
).resolves.toEqual({ cleaned: true, signaled: true });
|
||||
expect(signalGroup).toHaveBeenCalledWith(process.processGroupId, "SIGKILL");
|
||||
});
|
||||
|
||||
it("cleans verified owners before reporting an unrelated opaque process", async () => {
|
||||
const owned: ProcessTableEntry = {
|
||||
pid: 905,
|
||||
parentPid: 1,
|
||||
processGroupId: 905,
|
||||
state: "S",
|
||||
command: "owned-detached-child",
|
||||
};
|
||||
const opaque: ProcessTableEntry = {
|
||||
pid: 906,
|
||||
parentPid: 1,
|
||||
processGroupId: 906,
|
||||
state: "S",
|
||||
command: "unrelated-process",
|
||||
};
|
||||
const malformed: ProcessTableEntry = {
|
||||
...opaque,
|
||||
pid: 907,
|
||||
processGroupId: 907,
|
||||
command: "malformed-procargs-process",
|
||||
};
|
||||
const sysctlDenied: ProcessTableEntry = {
|
||||
...opaque,
|
||||
pid: 908,
|
||||
processGroupId: 908,
|
||||
command: "sysctl-denied-process",
|
||||
};
|
||||
const signalGroup = vi.fn();
|
||||
const host: ProcessOwnershipHost = {
|
||||
listProcesses: () =>
|
||||
Promise.resolve([owned, opaque, malformed, sysctlDenied]),
|
||||
environment: (pid) =>
|
||||
pid === owned.pid
|
||||
? Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" })
|
||||
: Promise.reject(new Error("permission denied")),
|
||||
runTokens: (entries) =>
|
||||
Promise.resolve(
|
||||
new Map(
|
||||
entries.map(({ pid }) => [
|
||||
pid,
|
||||
pid === owned.pid
|
||||
? { state: "readable", runId: "run-token" as const }
|
||||
: {
|
||||
state: "unavailable",
|
||||
reason:
|
||||
pid === malformed.pid
|
||||
? ("malformed_procargs" as const)
|
||||
: pid === sysctlDenied.pid
|
||||
? ("sysctl_failed_1" as const)
|
||||
: ("environment_unavailable" as const),
|
||||
},
|
||||
]),
|
||||
),
|
||||
),
|
||||
signalGroup,
|
||||
};
|
||||
|
||||
await expect(
|
||||
cleanupOwnedProcessGroup(
|
||||
{
|
||||
runId: "run-token",
|
||||
leaderPid: 100,
|
||||
processGroupId: 100,
|
||||
sweepTokenOwnedProcesses: true,
|
||||
},
|
||||
host,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
cleaned: false,
|
||||
reason:
|
||||
"process ownership token could not be read for 3 live process(es): environment_unavailable=1, malformed_procargs=1, sysctl_errno_1=1",
|
||||
});
|
||||
expect(signalGroup).toHaveBeenCalledWith(owned.processGroupId, "SIGKILL");
|
||||
expect(signalGroup).not.toHaveBeenCalledWith(
|
||||
opaque.processGroupId,
|
||||
"SIGKILL",
|
||||
);
|
||||
expect(signalGroup).not.toHaveBeenCalledWith(
|
||||
malformed.processGroupId,
|
||||
"SIGKILL",
|
||||
);
|
||||
expect(signalGroup).not.toHaveBeenCalledWith(
|
||||
sysctlDenied.processGroupId,
|
||||
"SIGKILL",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capture-local process identity baselines", () => {
|
||||
it("checks reused and unknown PIDs while skipping unchanged identities", async () => {
|
||||
const tokenReads: number[][] = [];
|
||||
const processes: readonly ProcessTableEntry[] = [
|
||||
{
|
||||
pid: 899,
|
||||
parentPid: 1,
|
||||
processGroupId: 899,
|
||||
state: "S",
|
||||
command: "stable",
|
||||
},
|
||||
{
|
||||
pid: 900,
|
||||
parentPid: 1,
|
||||
processGroupId: 900,
|
||||
state: "S",
|
||||
command: "reused",
|
||||
},
|
||||
{
|
||||
pid: 902,
|
||||
parentPid: 1,
|
||||
processGroupId: 902,
|
||||
state: "S",
|
||||
command: "unknown",
|
||||
},
|
||||
];
|
||||
const host: ProcessOwnershipHost = {
|
||||
listProcesses: () => Promise.resolve(processes),
|
||||
environment: () => Promise.resolve({}),
|
||||
processIdentities: (entries) =>
|
||||
Promise.resolve(
|
||||
new Map(
|
||||
entries.map(({ pid }) => [
|
||||
pid,
|
||||
pid === 902
|
||||
? { state: "unavailable", reason: "fixture" }
|
||||
: {
|
||||
state: "readable",
|
||||
identity: pid === 899 ? "stable-start" : "reused-start",
|
||||
},
|
||||
]),
|
||||
),
|
||||
),
|
||||
runTokens: (entries) => {
|
||||
tokenReads.push(entries.map(({ pid }) => pid));
|
||||
return Promise.resolve(
|
||||
new Map(
|
||||
entries.map(({ pid }) => [
|
||||
pid,
|
||||
{
|
||||
state: "readable",
|
||||
runId: pid === 900 ? "run-token" : undefined,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
},
|
||||
signalGroup: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
verifyNoTokenOwnedProcesses("run-token", host, [
|
||||
{ pid: 899, identity: "stable-start" },
|
||||
{ pid: 900, identity: "old-start" },
|
||||
{ pid: 902, identity: null },
|
||||
]),
|
||||
).resolves.toEqual({
|
||||
cleaned: false,
|
||||
reason: "process identity could not be revalidated",
|
||||
failures: [
|
||||
{
|
||||
pid: 902,
|
||||
reason: "process-identity-unavailable",
|
||||
diagnostic: "process identity was unavailable during token scan",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(tokenReads).toEqual([[900]]);
|
||||
});
|
||||
|
||||
it("revalidates fallback environment owners before signaling after PID reuse", async () => {
|
||||
let identityReads = 0;
|
||||
const process: ProcessTableEntry = {
|
||||
pid: 910,
|
||||
parentPid: 1,
|
||||
processGroupId: 910,
|
||||
state: "S",
|
||||
command: "fallback-environment-owner",
|
||||
};
|
||||
const signalGroup = vi.fn();
|
||||
const host: ProcessOwnershipHost = {
|
||||
listProcesses: () => Promise.resolve([process]),
|
||||
environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
|
||||
processIdentities: (entries) => {
|
||||
identityReads += 1;
|
||||
const identity = identityReads < 3 ? "owned-start" : "reused-start";
|
||||
return Promise.resolve(
|
||||
new Map(
|
||||
entries.map(({ pid }) => [
|
||||
pid,
|
||||
{ state: "readable", identity } as const,
|
||||
]),
|
||||
),
|
||||
);
|
||||
},
|
||||
signalGroup,
|
||||
};
|
||||
|
||||
await expect(
|
||||
cleanupOwnedProcessGroup(
|
||||
{
|
||||
runId: "run-token",
|
||||
leaderPid: 100,
|
||||
processGroupId: 100,
|
||||
sweepTokenOwnedProcesses: true,
|
||||
captureBaseline: [{ pid: process.pid, identity: "old-start" }],
|
||||
},
|
||||
host,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
cleaned: false,
|
||||
reason: "process identity could not be revalidated",
|
||||
});
|
||||
expect(signalGroup).not.toHaveBeenCalled();
|
||||
expect(identityReads).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("process identity during token validation", () => {
|
||||
it("revalidates identity after the final environment token read", async () => {
|
||||
let environmentReads = 0;
|
||||
let reusedDuringFinalRead = false;
|
||||
const process: ProcessTableEntry = {
|
||||
pid: 912,
|
||||
parentPid: 1,
|
||||
processGroupId: 912,
|
||||
state: "S",
|
||||
command: "reused-during-final-token-read",
|
||||
};
|
||||
const signalGroup = vi.fn();
|
||||
const host: ProcessOwnershipHost = {
|
||||
listProcesses: () => Promise.resolve([process]),
|
||||
environment: () => {
|
||||
environmentReads += 1;
|
||||
if (environmentReads === 3) reusedDuringFinalRead = true;
|
||||
return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" });
|
||||
},
|
||||
processIdentities: (entries) => {
|
||||
const identity = reusedDuringFinalRead ? "reused-start" : "owned-start";
|
||||
return Promise.resolve(
|
||||
new Map(
|
||||
entries.map(({ pid }) => [
|
||||
pid,
|
||||
{ state: "readable", identity } as const,
|
||||
]),
|
||||
),
|
||||
);
|
||||
},
|
||||
signalGroup,
|
||||
};
|
||||
|
||||
await expect(
|
||||
cleanupOwnedProcessGroup(
|
||||
{
|
||||
runId: "run-token",
|
||||
leaderPid: 100,
|
||||
processGroupId: 100,
|
||||
sweepTokenOwnedProcesses: true,
|
||||
captureBaseline: [{ pid: process.pid, identity: "old-start" }],
|
||||
},
|
||||
host,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
cleaned: false,
|
||||
reason: "process identity could not be revalidated",
|
||||
});
|
||||
expect(signalGroup).not.toHaveBeenCalled();
|
||||
expect(reusedDuringFinalRead).toBe(true);
|
||||
});
|
||||
|
||||
it("fails closed when a candidate PID changes identity during token reading", async () => {
|
||||
let identityReads = 0;
|
||||
const process: ProcessTableEntry = {
|
||||
pid: 911,
|
||||
parentPid: 1,
|
||||
processGroupId: 911,
|
||||
state: "S",
|
||||
command: "reused-during-token-read",
|
||||
};
|
||||
const host: ProcessOwnershipHost = {
|
||||
listProcesses: () => Promise.resolve([process]),
|
||||
environment: () => Promise.resolve({}),
|
||||
processIdentities: (entries) => {
|
||||
identityReads += 1;
|
||||
const identity = identityReads === 1 ? "new-start" : "reused-start";
|
||||
return Promise.resolve(
|
||||
new Map(
|
||||
entries.map(({ pid }) => [
|
||||
pid,
|
||||
{ state: "readable", identity } as const,
|
||||
]),
|
||||
),
|
||||
);
|
||||
},
|
||||
runTokens: (entries) =>
|
||||
Promise.resolve(
|
||||
new Map(
|
||||
entries.map(({ pid }) => [
|
||||
pid,
|
||||
{ state: "readable", runId: undefined } as const,
|
||||
]),
|
||||
),
|
||||
),
|
||||
signalGroup: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
verifyNoTokenOwnedProcesses("run-token", host, [
|
||||
{ pid: process.pid, identity: "old-start" },
|
||||
]),
|
||||
).resolves.toEqual({
|
||||
cleaned: false,
|
||||
reason: "process identity could not be revalidated",
|
||||
failures: [
|
||||
{
|
||||
pid: process.pid,
|
||||
reason: "process-identity-unavailable",
|
||||
diagnostic:
|
||||
"process identity changed or became unavailable during token validation",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("process identity before signaling", () => {
|
||||
it("refuses to signal when PID identity changes after token validation", async () => {
|
||||
const signalGroup = vi.fn();
|
||||
let identityReads = 0;
|
||||
const process: ProcessTableEntry = {
|
||||
pid: 903,
|
||||
parentPid: 1,
|
||||
processGroupId: 903,
|
||||
state: "S",
|
||||
command: "reused-before-signal",
|
||||
};
|
||||
const host: ProcessOwnershipHost = {
|
||||
listProcesses: () => Promise.resolve([process]),
|
||||
environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
|
||||
processIdentities: (entries) => {
|
||||
identityReads += 1;
|
||||
const identity = identityReads < 3 ? "owned-start" : "reused-start";
|
||||
return Promise.resolve(
|
||||
new Map(
|
||||
entries.map(({ pid }) => [
|
||||
pid,
|
||||
{ state: "readable", identity } as const,
|
||||
]),
|
||||
),
|
||||
);
|
||||
},
|
||||
runTokens: (entries) =>
|
||||
Promise.resolve(
|
||||
new Map(
|
||||
entries.map(({ pid }) => [
|
||||
pid,
|
||||
{ state: "readable", runId: "run-token" } as const,
|
||||
]),
|
||||
),
|
||||
),
|
||||
signalGroup,
|
||||
};
|
||||
|
||||
await expect(
|
||||
cleanupOwnedProcessGroup(
|
||||
{
|
||||
runId: "run-token",
|
||||
leaderPid: 100,
|
||||
processGroupId: 100,
|
||||
sweepTokenOwnedProcesses: true,
|
||||
captureBaseline: [{ pid: process.pid, identity: "old-start" }],
|
||||
},
|
||||
host,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
cleaned: false,
|
||||
reason: "process identity could not be revalidated",
|
||||
});
|
||||
expect(signalGroup).not.toHaveBeenCalled();
|
||||
expect(identityReads).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -180,3 +180,38 @@ describe("owned process-group cleanup discovery", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("process environment permission diagnostics", () => {
|
||||
it.each(["EACCES", "EPERM"])(
|
||||
"preserves %s while refusing to signal an unverifiable live process",
|
||||
async (code) => {
|
||||
const signalGroup = vi.fn();
|
||||
const diagnostic = `${code}: permission denied, open '/proc/900/environ'`;
|
||||
const adapter: ProcessOwnershipHost = {
|
||||
listProcesses: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
pid: 900,
|
||||
parentPid: 1,
|
||||
processGroupId: 900,
|
||||
state: "S",
|
||||
command: "unverifiable-process",
|
||||
},
|
||||
]),
|
||||
environment: () => Promise.reject(new Error(diagnostic)),
|
||||
signalGroup,
|
||||
};
|
||||
await expect(
|
||||
cleanupOwnedProcessGroup(
|
||||
{ ...ownership, sweepTokenOwnedProcesses: true },
|
||||
adapter,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
cleaned: false,
|
||||
reason: `process ownership token could not be read for 1 live process(es): environment_errno_${code}=1`,
|
||||
failures: [{ pid: 900, reason: "environment-unreadable", diagnostic }],
|
||||
});
|
||||
expect(signalGroup).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -64,6 +64,177 @@ describe("owned process-group cleanup discovery", () => {
|
||||
});
|
||||
expect(signalGroup).toHaveBeenCalledWith(100, "SIGKILL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("opaque process neighbors during cleanup", () => {
|
||||
it("cleans verified run-owned groups while reporting an opaque neighbor", async () => {
|
||||
const processes = [
|
||||
{ pid: 100, parentPid: 1, processGroupId: 100, command: "capture" },
|
||||
{
|
||||
pid: 200,
|
||||
parentPid: 1,
|
||||
processGroupId: 200,
|
||||
command: "detached-child",
|
||||
},
|
||||
{
|
||||
pid: 900,
|
||||
parentPid: 1,
|
||||
processGroupId: 900,
|
||||
command: "opaque-neighbor",
|
||||
},
|
||||
].map((process) => ({ ...process, state: "S" }));
|
||||
const signalGroup = vi.fn();
|
||||
const identities = new Map(
|
||||
processes.map(({ pid }) => [
|
||||
pid,
|
||||
{ state: "readable" as const, identity: `start-${String(pid)}` },
|
||||
]),
|
||||
);
|
||||
const adapter: ProcessOwnershipHost = {
|
||||
listProcesses: () => Promise.resolve(processes),
|
||||
environment: (pid) =>
|
||||
Promise.resolve({
|
||||
REA_PROCESS_RUN_ID: pid === 900 ? "unowned" : "run-token",
|
||||
}),
|
||||
processIdentities: () => Promise.resolve(identities),
|
||||
runTokens: (members) =>
|
||||
Promise.resolve(
|
||||
new Map(
|
||||
members.map(({ pid }) => [
|
||||
pid,
|
||||
pid === 900
|
||||
? {
|
||||
state: "unavailable" as const,
|
||||
reason: "environment_unavailable",
|
||||
}
|
||||
: { state: "readable" as const, runId: "run-token" },
|
||||
]),
|
||||
),
|
||||
),
|
||||
signalGroup,
|
||||
};
|
||||
|
||||
const result = await cleanupOwnedProcessGroup(
|
||||
{
|
||||
...ownership,
|
||||
sweepTokenOwnedProcesses: true,
|
||||
captureBaseline: [],
|
||||
},
|
||||
adapter,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
cleaned: false,
|
||||
reason: expect.stringContaining("environment_unavailable=1"),
|
||||
});
|
||||
expect(signalGroup.mock.calls).toEqual([
|
||||
[100, "SIGKILL"],
|
||||
[200, "SIGKILL"],
|
||||
]);
|
||||
expect(signalGroup).not.toHaveBeenCalledWith(900, "SIGKILL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sampled detached process groups during cleanup", () => {
|
||||
it("signals only token-authenticated sampled groups and reports an unowned sample", async () => {
|
||||
const processes = [
|
||||
{
|
||||
pid: 100,
|
||||
parentPid: 1,
|
||||
processGroupId: 100,
|
||||
state: "S",
|
||||
command: "capture",
|
||||
},
|
||||
{
|
||||
pid: 200,
|
||||
parentPid: 1,
|
||||
processGroupId: 200,
|
||||
state: "S",
|
||||
command: "sanitized-detached-child",
|
||||
},
|
||||
];
|
||||
const signalGroup = vi.fn();
|
||||
const identities = new Map([
|
||||
[100, { state: "readable" as const, identity: "start-100" }],
|
||||
[200, { state: "readable" as const, identity: "start-200" }],
|
||||
]);
|
||||
const adapter: ProcessOwnershipHost = {
|
||||
listProcesses: () => Promise.resolve(processes),
|
||||
environment: (pid) =>
|
||||
Promise.resolve(pid === 100 ? { REA_PROCESS_RUN_ID: "run-token" } : {}),
|
||||
processIdentities: () => Promise.resolve(identities),
|
||||
runTokens: (members) =>
|
||||
Promise.resolve(
|
||||
new Map(
|
||||
members.map(({ pid }) => [
|
||||
pid,
|
||||
{
|
||||
state: "readable" as const,
|
||||
runId: pid === 100 ? "run-token" : undefined,
|
||||
},
|
||||
]),
|
||||
),
|
||||
),
|
||||
signalGroup,
|
||||
};
|
||||
|
||||
const result = await cleanupOwnedProcessGroup(
|
||||
{
|
||||
...ownership,
|
||||
sweepTokenOwnedProcesses: true,
|
||||
sampledProcessGroupIds: [200],
|
||||
},
|
||||
adapter,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
cleaned: false,
|
||||
reason: "process tree contains an unowned or PID-reused process",
|
||||
failures: [{ pid: 200, reason: "run-token-mismatch" }],
|
||||
});
|
||||
expect(signalGroup).toHaveBeenCalledTimes(1);
|
||||
expect(signalGroup).toHaveBeenCalledWith(100, "SIGKILL");
|
||||
expect(signalGroup).not.toHaveBeenCalledWith(200, "SIGKILL");
|
||||
});
|
||||
|
||||
it("reports a live sampled group after the launcher has exited without signaling it", async () => {
|
||||
const process = {
|
||||
pid: 200,
|
||||
parentPid: 1,
|
||||
processGroupId: 200,
|
||||
state: "S",
|
||||
command: "sanitized-detached-child",
|
||||
};
|
||||
const signalGroup = vi.fn();
|
||||
const adapter: ProcessOwnershipHost = {
|
||||
listProcesses: () => Promise.resolve([process]),
|
||||
environment: () => Promise.resolve({}),
|
||||
runTokens: () =>
|
||||
Promise.resolve(
|
||||
new Map([[200, { state: "readable" as const, runId: undefined }]]),
|
||||
),
|
||||
signalGroup,
|
||||
};
|
||||
|
||||
const result = await cleanupOwnedProcessGroup(
|
||||
{
|
||||
...ownership,
|
||||
sweepTokenOwnedProcesses: true,
|
||||
sampledProcessGroupIds: [200],
|
||||
},
|
||||
adapter,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
cleaned: false,
|
||||
reason: "process tree contains an unowned or PID-reused process",
|
||||
failures: [{ pid: 200, reason: "run-token-mismatch" }],
|
||||
});
|
||||
expect(signalGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rooted process-group cleanup", () => {
|
||||
it("validates and signals rooted descendant groups once, root first", async () => {
|
||||
const processes = [
|
||||
{ pid: 100, parentPid: 1, processGroupId: 100 },
|
||||
|
||||
+369
-35
@@ -2,8 +2,8 @@ import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import {
|
||||
createSystemProcessOwnershipHost,
|
||||
errorMessage,
|
||||
systemProcessOwnershipHost as systemHost,
|
||||
} from "./ProcessOwnershipObservation.js";
|
||||
import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js";
|
||||
import { descendantsOf, liveProcesses } from "./ProcessOwnershipProcessTree.js";
|
||||
@@ -21,8 +21,21 @@ export interface OwnedProcessGroup {
|
||||
readonly expectedParentPid?: number;
|
||||
/** Scan all live processes for this run token during cleanup. */
|
||||
readonly sweepTokenOwnedProcesses?: boolean;
|
||||
/** Prelaunch process identities for this capture; absent on generic callers. */
|
||||
readonly captureBaseline?: ProcessOwnershipBaseline;
|
||||
/**
|
||||
* Groups observed in capture samples. Observation adds cleanup candidates but
|
||||
* does not authorize signaling; every live member still needs token proof.
|
||||
*/
|
||||
readonly sampledProcessGroupIds?: readonly number[];
|
||||
}
|
||||
|
||||
/** Capture-local baseline; null means the PID identity could not be established. */
|
||||
export type ProcessOwnershipBaseline = readonly {
|
||||
readonly pid: number;
|
||||
readonly identity: string | null;
|
||||
}[];
|
||||
|
||||
/** One entry from an operating-system process-table snapshot. */
|
||||
export interface ProcessTableEntry {
|
||||
readonly pid: number;
|
||||
@@ -32,6 +45,11 @@ export interface ProcessTableEntry {
|
||||
readonly command: string;
|
||||
}
|
||||
|
||||
/** Native process start identity; unavailable identities must never be skipped. */
|
||||
export type ProcessIdentityObservation =
|
||||
| { readonly state: "readable"; readonly identity: string }
|
||||
| { readonly state: "unavailable"; readonly reason: string };
|
||||
|
||||
/** Token-verified process lineage retained for one owned provider run. */
|
||||
export interface OwnedProcessLineage {
|
||||
readonly runId: string;
|
||||
@@ -64,11 +82,32 @@ export type ProcessLineageObservation =
|
||||
/** Narrow operating-system seam used to inspect processes and signal groups. */
|
||||
export interface ProcessOwnershipHost {
|
||||
readonly platform?: NodeJS.Platform;
|
||||
listProcesses(): Promise<readonly ProcessTableEntry[]>;
|
||||
/** Prepare required native identity inspection before launching a child. */
|
||||
prepare?(signal?: AbortSignal): Promise<void>;
|
||||
/** Release owned native inspection helpers and their temporary files. */
|
||||
close?(): Promise<void>;
|
||||
/** List current processes; startup inspection may be cancelled by the caller. */
|
||||
listProcesses(signal?: AbortSignal): Promise<readonly ProcessTableEntry[]>;
|
||||
environment(pid: number): Promise<Readonly<Record<string, string>>>;
|
||||
/** Read only run-token values in one host operation when the OS supports it. */
|
||||
runTokens?(
|
||||
processes: readonly ProcessTableEntry[],
|
||||
): Promise<ReadonlyMap<number, ProcessRunTokenObservation>>;
|
||||
/** Read stable identities; omit the signal during cleanup so it can finish. */
|
||||
processIdentities?(
|
||||
processes: readonly ProcessTableEntry[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReadonlyMap<number, ProcessIdentityObservation>>;
|
||||
/** Snapshot identities before launch, honoring startup cancellation. */
|
||||
captureBaseline?(signal?: AbortSignal): Promise<ProcessOwnershipBaseline>;
|
||||
signalGroup(processGroupId: number, signal: NodeJS.Signals): void;
|
||||
}
|
||||
|
||||
/** Whether one live process exposed its ownership token to the host. */
|
||||
export type ProcessRunTokenObservation =
|
||||
| { readonly state: "readable"; readonly runId: string | undefined }
|
||||
| { readonly state: "unavailable"; readonly reason: string };
|
||||
|
||||
/** Narrow Windows P0 seam for bounded process-tree termination. */
|
||||
export interface WindowsProcessTreeHost {
|
||||
terminateTree(rootPid: number): Promise<"terminated" | "missing">;
|
||||
@@ -77,7 +116,10 @@ export interface WindowsProcessTreeHost {
|
||||
/** Per-member reason that token-verified cleanup failed closed. */
|
||||
interface ProcessOwnershipValidationFailure {
|
||||
readonly pid: number;
|
||||
readonly reason: "environment-unreadable" | "run-token-mismatch";
|
||||
readonly reason:
|
||||
| "environment-unreadable"
|
||||
| "run-token-mismatch"
|
||||
| "process-identity-unavailable";
|
||||
readonly diagnostic?: string;
|
||||
}
|
||||
|
||||
@@ -89,7 +131,10 @@ export type ProcessCleanupResult =
|
||||
readonly reason: string;
|
||||
readonly failures?: readonly {
|
||||
readonly pid: number;
|
||||
readonly reason: "environment-unreadable" | "run-token-mismatch";
|
||||
readonly reason:
|
||||
| "environment-unreadable"
|
||||
| "run-token-mismatch"
|
||||
| "process-identity-unavailable";
|
||||
readonly diagnostic?: string;
|
||||
}[];
|
||||
};
|
||||
@@ -128,8 +173,6 @@ export const cleanupWindowsProcessTree = async (
|
||||
}
|
||||
};
|
||||
|
||||
const systemHost = createSystemProcessOwnershipHost();
|
||||
|
||||
const systemWindowsProcessTreeHost: WindowsProcessTreeHost = {
|
||||
async terminateTree(rootPid) {
|
||||
try {
|
||||
@@ -181,6 +224,7 @@ export const cleanupOwnedProcessGroup = async (
|
||||
processGroupId,
|
||||
ownership,
|
||||
host,
|
||||
plan.tokenOwnedIdentities,
|
||||
);
|
||||
if ("cleaned" in revalidation) return revalidation;
|
||||
if (revalidation.empty) continue;
|
||||
@@ -197,6 +241,8 @@ export const cleanupOwnedProcessGroup = async (
|
||||
};
|
||||
}
|
||||
}
|
||||
if (plan.unresolved.length > 0)
|
||||
return cleanupValidationFailure(plan.unresolved);
|
||||
return { cleaned: true, signaled };
|
||||
};
|
||||
|
||||
@@ -204,6 +250,7 @@ export const cleanupOwnedProcessGroup = async (
|
||||
export const verifyNoTokenOwnedProcesses = async (
|
||||
runId: string,
|
||||
host: ProcessOwnershipHost = systemHost,
|
||||
captureBaseline?: ProcessOwnershipBaseline,
|
||||
): Promise<ProcessCleanupResult> => {
|
||||
const processTable = await readLiveProcessTable(host);
|
||||
if (!processTable.available)
|
||||
@@ -215,6 +262,7 @@ export const verifyNoTokenOwnedProcesses = async (
|
||||
processTable.processes,
|
||||
runId,
|
||||
host,
|
||||
captureBaseline,
|
||||
);
|
||||
if (scan.failures.length > 0) return cleanupValidationFailure(scan.failures);
|
||||
return scan.owned.length === 0
|
||||
@@ -243,6 +291,8 @@ const readLiveProcessTable = async (
|
||||
|
||||
interface OwnedCleanupPlan {
|
||||
readonly signalOrder: readonly number[];
|
||||
readonly tokenOwnedIdentities: ReadonlyMap<number, string | null>;
|
||||
readonly unresolved: readonly ProcessOwnershipValidationFailure[];
|
||||
}
|
||||
|
||||
const createOwnedCleanupPlan = async (
|
||||
@@ -250,12 +300,21 @@ const createOwnedCleanupPlan = async (
|
||||
processes: readonly ProcessTableEntry[],
|
||||
host: ProcessOwnershipHost,
|
||||
): Promise<OwnedCleanupPlan | ProcessCleanupResult> => {
|
||||
const sampledProcessGroupIds = new Set(
|
||||
(ownership.sampledProcessGroupIds ?? []).filter(
|
||||
(processGroupId) =>
|
||||
Number.isSafeInteger(processGroupId) && processGroupId > 0,
|
||||
),
|
||||
);
|
||||
const tokenOwned =
|
||||
ownership.sweepTokenOwnedProcesses === true
|
||||
? await scanTokenOwnedProcesses(processes, ownership.runId, host)
|
||||
: { owned: [], failures: [] };
|
||||
if (tokenOwned.failures.length > 0)
|
||||
return cleanupValidationFailure(tokenOwned.failures);
|
||||
? await scanTokenOwnedProcesses(
|
||||
processes,
|
||||
ownership.runId,
|
||||
host,
|
||||
ownership.captureBaseline,
|
||||
)
|
||||
: { owned: [], failures: [], identities: new Map() };
|
||||
const tokenOwnedGroupIds = new Set(
|
||||
tokenOwned.owned.map(({ processGroupId }) => processGroupId),
|
||||
);
|
||||
@@ -266,9 +325,18 @@ const createOwnedCleanupPlan = async (
|
||||
if (
|
||||
launcher === undefined &&
|
||||
rootMembers.length === 0 &&
|
||||
tokenOwned.owned.length === 0
|
||||
tokenOwned.owned.length === 0 &&
|
||||
!processes.some(({ processGroupId }) =>
|
||||
sampledProcessGroupIds.has(processGroupId),
|
||||
)
|
||||
)
|
||||
return { cleaned: true, signaled: false };
|
||||
return tokenOwned.failures.length > 0
|
||||
? {
|
||||
signalOrder: [],
|
||||
tokenOwnedIdentities: tokenOwned.identities,
|
||||
unresolved: tokenOwned.failures,
|
||||
}
|
||||
: { cleaned: true, signaled: false };
|
||||
let descendants: readonly ProcessTableEntry[] = [];
|
||||
if (launcher !== undefined) {
|
||||
const identityFailure = launcherIdentityFailure(launcher, ownership);
|
||||
@@ -282,6 +350,11 @@ const createOwnedCleanupPlan = async (
|
||||
processGroupIds.add(descendant.processGroupId);
|
||||
for (const process of tokenOwned.owned)
|
||||
processGroupIds.add(process.processGroupId);
|
||||
for (const processGroupId of sampledProcessGroupIds)
|
||||
processGroupIds.add(processGroupId);
|
||||
const signalableGroups = new Set<number>();
|
||||
const unresolved = [...tokenOwned.failures];
|
||||
const unverifiableSampledGroups = new Set<number>();
|
||||
for (const processGroupId of processGroupIds) {
|
||||
if (processGroupId === ownership.processGroupId) continue;
|
||||
const groupLeader = processes.find(
|
||||
@@ -290,29 +363,69 @@ const createOwnedCleanupPlan = async (
|
||||
);
|
||||
if (
|
||||
(groupLeader === undefined || !descendantPids.has(groupLeader.pid)) &&
|
||||
!tokenOwnedGroupIds.has(processGroupId)
|
||||
!tokenOwnedGroupIds.has(processGroupId) &&
|
||||
!sampledProcessGroupIds.has(processGroupId)
|
||||
)
|
||||
return {
|
||||
cleaned: false,
|
||||
reason:
|
||||
"descendant process-group leader identity could not be verified",
|
||||
};
|
||||
if (
|
||||
(groupLeader === undefined || !descendantPids.has(groupLeader.pid)) &&
|
||||
!tokenOwnedGroupIds.has(processGroupId)
|
||||
)
|
||||
unverifiableSampledGroups.add(processGroupId);
|
||||
}
|
||||
const liveMembers = processes.filter(({ processGroupId }) =>
|
||||
processGroupIds.has(processGroupId),
|
||||
);
|
||||
const failures = await processOwnershipFailures(
|
||||
liveMembers,
|
||||
ownership.runId,
|
||||
host,
|
||||
);
|
||||
if (failures.length > 0) return cleanupValidationFailure(failures);
|
||||
for (const processGroupId of processGroupIds) {
|
||||
const members = liveMembers.filter(
|
||||
({ processGroupId: observedGroupId }) =>
|
||||
observedGroupId === processGroupId,
|
||||
);
|
||||
if (members.length === 0) continue;
|
||||
const failures = await processOwnershipFailures(
|
||||
members,
|
||||
ownership.runId,
|
||||
host,
|
||||
tokenOwned.identities,
|
||||
);
|
||||
if (failures.length === 0 && unverifiableSampledGroups.has(processGroupId))
|
||||
unresolved.push(
|
||||
...members.map(({ pid }) => ({
|
||||
pid,
|
||||
reason: "process-identity-unavailable" as const,
|
||||
diagnostic:
|
||||
"sampled process-group leader identity could not be verified",
|
||||
})),
|
||||
);
|
||||
const scanFailures = tokenOwned.failures.filter(({ pid }) =>
|
||||
members.some((member) => member.pid === pid),
|
||||
);
|
||||
if (failures.length > 0) unresolved.push(...failures);
|
||||
if (
|
||||
failures.length === 0 &&
|
||||
scanFailures.length === 0 &&
|
||||
!unverifiableSampledGroups.has(processGroupId)
|
||||
)
|
||||
signalableGroups.add(processGroupId);
|
||||
}
|
||||
if (ownership.sweepTokenOwnedProcesses !== true && unresolved.length > 0)
|
||||
return cleanupValidationFailure(unresolved);
|
||||
return {
|
||||
signalOrder: [ownership.processGroupId].concat(
|
||||
[...processGroupIds]
|
||||
.filter((processGroupId) => processGroupId !== ownership.processGroupId)
|
||||
.sort((left, right) => left - right),
|
||||
),
|
||||
signalOrder: [...signalableGroups]
|
||||
.filter((groupId) => processGroupIds.has(groupId))
|
||||
.sort((left, right) =>
|
||||
left === ownership.processGroupId
|
||||
? -1
|
||||
: right === ownership.processGroupId
|
||||
? 1
|
||||
: left - right,
|
||||
),
|
||||
tokenOwnedIdentities: tokenOwned.identities,
|
||||
unresolved,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -320,6 +433,7 @@ const revalidateOwnedProcessGroup = async (
|
||||
processGroupId: number,
|
||||
ownership: OwnedProcessGroup,
|
||||
host: ProcessOwnershipHost,
|
||||
expectedIdentities: ReadonlyMap<number, string | null>,
|
||||
): Promise<{ readonly empty: boolean } | ProcessCleanupResult> => {
|
||||
let members: readonly ProcessTableEntry[];
|
||||
try {
|
||||
@@ -337,6 +451,7 @@ const revalidateOwnedProcessGroup = async (
|
||||
members,
|
||||
ownership.runId,
|
||||
host,
|
||||
expectedIdentities,
|
||||
);
|
||||
if (failures.length > 0) return cleanupValidationFailure(failures);
|
||||
if (processGroupId === ownership.processGroupId) {
|
||||
@@ -363,20 +478,102 @@ const revalidateOwnedProcessGroup = async (
|
||||
|
||||
const cleanupValidationFailure = (
|
||||
failures: readonly ProcessOwnershipValidationFailure[],
|
||||
): ProcessCleanupResult => ({
|
||||
cleaned: false,
|
||||
reason: failures.some(({ reason }) => reason === "run-token-mismatch")
|
||||
? "process tree contains an unowned or PID-reused process"
|
||||
: "process ownership could not be revalidated",
|
||||
failures,
|
||||
});
|
||||
): ProcessCleanupResult => {
|
||||
const unreadable = failures.filter(
|
||||
({ reason }) => reason === "environment-unreadable",
|
||||
);
|
||||
if (failures.some(({ reason }) => reason === "run-token-mismatch"))
|
||||
return {
|
||||
cleaned: false,
|
||||
reason: "process tree contains an unowned or PID-reused process",
|
||||
failures,
|
||||
};
|
||||
if (failures.some(({ reason }) => reason === "process-identity-unavailable"))
|
||||
return {
|
||||
cleaned: false,
|
||||
reason: "process identity could not be revalidated",
|
||||
failures,
|
||||
};
|
||||
if (unreadable.length > 0) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const { diagnostic } of unreadable) {
|
||||
const category = sanitizedTokenReadFailure(diagnostic);
|
||||
counts.set(category, (counts.get(category) ?? 0) + 1);
|
||||
}
|
||||
const breakdown = [...counts]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([category, count]) => `${category}=${String(count)}`)
|
||||
.join(", ");
|
||||
return {
|
||||
cleaned: false,
|
||||
reason: `process ownership token could not be read for ${String(unreadable.length)} live process(es): ${breakdown}`,
|
||||
failures,
|
||||
};
|
||||
}
|
||||
return {
|
||||
cleaned: false,
|
||||
reason: "process ownership could not be revalidated",
|
||||
failures,
|
||||
};
|
||||
};
|
||||
|
||||
const sanitizedTokenReadFailure = (diagnostic: string | undefined): string => {
|
||||
if (diagnostic === "environment_unavailable") return diagnostic;
|
||||
const environmentErrno = /^(EACCES|EPERM|ENOENT|ESRCH):/u.exec(
|
||||
diagnostic ?? "",
|
||||
);
|
||||
if (environmentErrno?.[1] !== undefined)
|
||||
return `environment_errno_${environmentErrno[1]}`;
|
||||
if (diagnostic === "apple_vector_unavailable") return diagnostic;
|
||||
if (diagnostic === "ambiguous_environment_boundary") return diagnostic;
|
||||
if (diagnostic === "malformed_procargs") return diagnostic;
|
||||
if (diagnostic === "process_identity_changed_during_token_read")
|
||||
return diagnostic;
|
||||
if (diagnostic === "process_not_in_snapshot") return diagnostic;
|
||||
const processTableFailure = /^process_table_failed_(\d+)$/u.exec(
|
||||
diagnostic ?? "",
|
||||
);
|
||||
if (processTableFailure?.[1] !== undefined)
|
||||
return `process_table_errno_${processTableFailure[1]}`;
|
||||
const sysctlFailure = /^sysctl_failed_(\d+)$/u.exec(diagnostic ?? "");
|
||||
if (sysctlFailure?.[1] !== undefined)
|
||||
return `sysctl_errno_${sysctlFailure[1]}`;
|
||||
if (diagnostic === "reader_failure") return diagnostic;
|
||||
if (diagnostic === "duplicate_run_token") return diagnostic;
|
||||
if (diagnostic === "invalid_run_token_encoding") return diagnostic;
|
||||
return "other_unavailable";
|
||||
};
|
||||
|
||||
const processOwnershipFailures = async (
|
||||
members: readonly ProcessTableEntry[],
|
||||
runId: string,
|
||||
host: ProcessOwnershipHost,
|
||||
expectedIdentities: ReadonlyMap<number, string | null> = new Map(),
|
||||
): Promise<readonly ProcessOwnershipValidationFailure[]> => {
|
||||
const failures: ProcessOwnershipValidationFailure[] = [];
|
||||
if (expectedIdentities.size > 0 && host.processIdentities !== undefined) {
|
||||
const expectedMembers = members.filter(({ pid }) =>
|
||||
expectedIdentities.has(pid),
|
||||
);
|
||||
const observed = await host.processIdentities(expectedMembers);
|
||||
for (const member of expectedMembers) {
|
||||
const expected = expectedIdentities.get(member.pid);
|
||||
const current = observed.get(member.pid);
|
||||
if (
|
||||
expected === null ||
|
||||
current?.state !== "readable" ||
|
||||
current.identity !== expected
|
||||
) {
|
||||
if (await processIsGone(host, member.pid)) continue;
|
||||
failures.push({
|
||||
pid: member.pid,
|
||||
reason: "process-identity-unavailable",
|
||||
diagnostic:
|
||||
"process identity changed or became unavailable before signal",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const member of members) {
|
||||
try {
|
||||
if ((await host.environment(member.pid)).REA_PROCESS_RUN_ID !== runId)
|
||||
@@ -400,25 +597,123 @@ const processOwnershipFailures = async (
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
failures.length === 0 &&
|
||||
expectedIdentities.size > 0 &&
|
||||
host.processIdentities !== undefined
|
||||
) {
|
||||
const expectedMembers = members.filter(({ pid }) =>
|
||||
expectedIdentities.has(pid),
|
||||
);
|
||||
const observed = await host.processIdentities(expectedMembers);
|
||||
for (const member of expectedMembers) {
|
||||
const expected = expectedIdentities.get(member.pid);
|
||||
const current = observed.get(member.pid);
|
||||
if (
|
||||
expected !== null &&
|
||||
current?.state === "readable" &&
|
||||
current.identity === expected
|
||||
)
|
||||
continue;
|
||||
if (await processIsGone(host, member.pid)) continue;
|
||||
failures.push({
|
||||
pid: member.pid,
|
||||
reason: "process-identity-unavailable",
|
||||
diagnostic:
|
||||
"process identity changed or became unavailable during token validation",
|
||||
});
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
};
|
||||
|
||||
interface TokenOwnedProcessScan {
|
||||
readonly owned: readonly ProcessTableEntry[];
|
||||
readonly failures: readonly ProcessOwnershipValidationFailure[];
|
||||
readonly identities: ReadonlyMap<number, string | null>;
|
||||
}
|
||||
|
||||
const scanTokenOwnedProcesses = async (
|
||||
processes: readonly ProcessTableEntry[],
|
||||
runId: string,
|
||||
host: ProcessOwnershipHost,
|
||||
captureBaseline?: ProcessOwnershipBaseline,
|
||||
): Promise<TokenOwnedProcessScan> => {
|
||||
const owned: ProcessTableEntry[] = [];
|
||||
let candidates = processes;
|
||||
let candidateIdentities: ReadonlyMap<number, ProcessIdentityObservation> =
|
||||
new Map();
|
||||
if (host.processIdentities !== undefined) {
|
||||
const current = await host.processIdentities(processes);
|
||||
candidateIdentities = current;
|
||||
if (captureBaseline !== undefined) {
|
||||
const baseline = new Map(
|
||||
captureBaseline.map(({ pid, identity }) => [pid, identity]),
|
||||
);
|
||||
candidates = processes.filter((process) => {
|
||||
const before = baseline.get(process.pid);
|
||||
const now = current.get(process.pid);
|
||||
return !(
|
||||
before !== undefined &&
|
||||
before !== null &&
|
||||
now?.state === "readable" &&
|
||||
now.identity === before
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
const failures: ProcessOwnershipValidationFailure[] = [];
|
||||
for (const process of processes) {
|
||||
if (captureBaseline !== undefined && host.processIdentities !== undefined) {
|
||||
const stableCandidates: ProcessTableEntry[] = [];
|
||||
for (const process of candidates) {
|
||||
if (candidateIdentities.get(process.pid)?.state === "readable") {
|
||||
stableCandidates.push(process);
|
||||
continue;
|
||||
}
|
||||
if (!(await processIsGone(host, process.pid)))
|
||||
failures.push({
|
||||
pid: process.pid,
|
||||
reason: "process-identity-unavailable",
|
||||
diagnostic: "process identity was unavailable during token scan",
|
||||
});
|
||||
}
|
||||
candidates = stableCandidates;
|
||||
}
|
||||
let bulkTokens: ReadonlyMap<number, ProcessRunTokenObservation> | undefined;
|
||||
if (host.runTokens !== undefined)
|
||||
bulkTokens = await host.runTokens(candidates);
|
||||
const owned: ProcessTableEntry[] = [];
|
||||
const ownedIdentities = new Map<number, string | null>();
|
||||
for (const process of candidates) {
|
||||
const bulkToken = bulkTokens?.get(process.pid);
|
||||
if (bulkToken !== undefined) {
|
||||
if (bulkToken.state === "readable") {
|
||||
if (bulkToken.runId === runId) {
|
||||
owned.push(process);
|
||||
const identity = candidateIdentities.get(process.pid);
|
||||
ownedIdentities.set(
|
||||
process.pid,
|
||||
identity?.state === "readable" ? identity.identity : null,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!(await processIsGone(host, process.pid)))
|
||||
failures.push({
|
||||
pid: process.pid,
|
||||
reason: "environment-unreadable",
|
||||
diagnostic: bulkToken.reason,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if ((await host.environment(process.pid)).REA_PROCESS_RUN_ID === runId)
|
||||
if ((await host.environment(process.pid)).REA_PROCESS_RUN_ID === runId) {
|
||||
owned.push(process);
|
||||
const identity = candidateIdentities.get(process.pid);
|
||||
ownedIdentities.set(
|
||||
process.pid,
|
||||
identity?.state === "readable" ? identity.identity : null,
|
||||
);
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
try {
|
||||
const live = liveProcesses(await host.listProcesses());
|
||||
@@ -438,5 +733,44 @@ const scanTokenOwnedProcesses = async (
|
||||
});
|
||||
}
|
||||
}
|
||||
return { owned, failures };
|
||||
if (candidates.length > 0 && host.processIdentities !== undefined) {
|
||||
const afterRead = await host.processIdentities(candidates);
|
||||
const stillOwned: ProcessTableEntry[] = [];
|
||||
const ownedPids = new Set(owned.map(({ pid }) => pid));
|
||||
for (const process of candidates) {
|
||||
const before = candidateIdentities.get(process.pid);
|
||||
const current = afterRead.get(process.pid);
|
||||
if (
|
||||
before?.state === "readable" &&
|
||||
current?.state === "readable" &&
|
||||
current.identity === before.identity
|
||||
) {
|
||||
if (ownedPids.has(process.pid)) stillOwned.push(process);
|
||||
continue;
|
||||
}
|
||||
if (await processIsGone(host, process.pid)) continue;
|
||||
failures.push({
|
||||
pid: process.pid,
|
||||
reason: "process-identity-unavailable",
|
||||
diagnostic:
|
||||
"process identity changed or became unavailable during token validation",
|
||||
});
|
||||
}
|
||||
return { owned: stillOwned, failures, identities: ownedIdentities };
|
||||
}
|
||||
return { owned, failures, identities: ownedIdentities };
|
||||
};
|
||||
|
||||
const processIsGone = async (
|
||||
host: ProcessOwnershipHost,
|
||||
pid: number,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
return !liveProcesses(await host.listProcesses()).some(
|
||||
(process) => process.pid === pid,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
void cause;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -3,11 +3,14 @@ import { readFile } from "node:fs/promises";
|
||||
import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js";
|
||||
import { descendantsOf, liveProcesses } from "./ProcessOwnershipProcessTree.js";
|
||||
import { execFileOutput } from "./ExecFileOutput.js";
|
||||
import { createDarwinProcessRunTokenReader } from "./DarwinProcessRunTokenReader.js";
|
||||
import type {
|
||||
OwnedProcessGroup,
|
||||
ProcessGroupObservation,
|
||||
ProcessLineageObservation,
|
||||
ProcessOwnershipHost,
|
||||
ProcessIdentityObservation,
|
||||
ProcessOwnershipBaseline,
|
||||
ProcessTableEntry,
|
||||
} from "./ProcessOwnership.js";
|
||||
|
||||
@@ -144,18 +147,53 @@ export const parseProcessEnvironment = (
|
||||
}),
|
||||
);
|
||||
|
||||
/** Read Linux stat field 22, tolerating spaces and closing parentheses in comm. */
|
||||
export const parseLinuxProcessStartTime = (
|
||||
value: string,
|
||||
): string | undefined => {
|
||||
const commandEnd = value.lastIndexOf(")");
|
||||
if (commandEnd < 0) return undefined;
|
||||
const fields = value
|
||||
.slice(commandEnd + 1)
|
||||
.trim()
|
||||
.split(/\s+/u);
|
||||
const startTime = fields[19];
|
||||
return startTime !== undefined && /^\d+$/u.test(startTime)
|
||||
? startTime
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const isExpectedAbort = (
|
||||
cause: unknown,
|
||||
signal: AbortSignal | undefined,
|
||||
): boolean =>
|
||||
signal?.aborted === true &&
|
||||
(cause === signal.reason ||
|
||||
(cause instanceof Error && cause.name === "AbortError"));
|
||||
|
||||
/** Create the operating-system process inspector for an explicit host context. */
|
||||
export const createSystemProcessOwnershipHost = (
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
hostEnvironment: NodeJS.ProcessEnv = process.env,
|
||||
): ProcessOwnershipHost => ({
|
||||
platform,
|
||||
async listProcesses() {
|
||||
options: {
|
||||
/** Override the compiler executable used by the Darwin native reader. */
|
||||
readonly darwinXcrun?: string;
|
||||
} = {},
|
||||
): ProcessOwnershipHost => {
|
||||
const darwinTokens =
|
||||
platform === "darwin"
|
||||
? createDarwinProcessRunTokenReader(
|
||||
options.darwinXcrun === undefined
|
||||
? {}
|
||||
: { xcrun: options.darwinXcrun },
|
||||
)
|
||||
: undefined;
|
||||
const listProcesses = async (signal?: AbortSignal) => {
|
||||
if (platform === "win32") return [];
|
||||
const { stdout } = await execFileOutput(
|
||||
"ps",
|
||||
["-axo", "pid=,ppid=,pgid=,stat=,command="],
|
||||
{ env: hostEnvironment },
|
||||
{ env: hostEnvironment, ...(signal === undefined ? {} : { signal }) },
|
||||
);
|
||||
return stdout
|
||||
.split("\n")
|
||||
@@ -168,35 +206,175 @@ export const createSystemProcessOwnershipHost = (
|
||||
state: match[4] ?? "",
|
||||
command: match[5] ?? "",
|
||||
}));
|
||||
},
|
||||
async environment(pid) {
|
||||
};
|
||||
const processIdentities: NonNullable<
|
||||
ProcessOwnershipHost["processIdentities"]
|
||||
> = async (processes, signal) => {
|
||||
if (processes.length === 0) return new Map();
|
||||
if (platform === "linux")
|
||||
return parseProcessEnvironment(
|
||||
await readFile(`/proc/${pid}/environ`, "utf8"),
|
||||
return new Map<number, ProcessIdentityObservation>(
|
||||
await Promise.all(
|
||||
processes.map(async ({ pid }) => {
|
||||
try {
|
||||
const stat = await readFile(`/proc/${String(pid)}/stat`, {
|
||||
encoding: "utf8",
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
const identity = parseLinuxProcessStartTime(stat);
|
||||
return [
|
||||
pid,
|
||||
identity === undefined
|
||||
? ({
|
||||
state: "unavailable",
|
||||
reason: "malformed_proc_stat",
|
||||
} as const)
|
||||
: ({
|
||||
state: "readable",
|
||||
identity: `linux-starttime:${identity}`,
|
||||
} as const),
|
||||
] as const;
|
||||
} catch (cause: unknown) {
|
||||
if (isExpectedAbort(cause, signal)) throw cause;
|
||||
return [
|
||||
pid,
|
||||
{ state: "unavailable", reason: errorMessage(cause) } as const,
|
||||
] as const;
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (darwinTokens === undefined) return new Map();
|
||||
try {
|
||||
return await darwinTokens.identities(processes, signal);
|
||||
} catch (cause: unknown) {
|
||||
if (isExpectedAbort(cause, signal)) throw cause;
|
||||
const reason = errorMessage(cause);
|
||||
return new Map(
|
||||
processes.map(({ pid }) => [
|
||||
pid,
|
||||
{ state: "unavailable", reason } as const,
|
||||
]),
|
||||
);
|
||||
const { stdout } = await execFileOutput("ps", ["eww", "-p", String(pid)], {
|
||||
env: hostEnvironment,
|
||||
});
|
||||
const observedEnvironment: Record<string, string> = {};
|
||||
for (const match of stdout.matchAll(
|
||||
/(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=([^\s]*)/gu,
|
||||
)) {
|
||||
const name = match[1];
|
||||
if (name !== undefined) observedEnvironment[name] = match[2] ?? "";
|
||||
}
|
||||
return observedEnvironment;
|
||||
},
|
||||
signalGroup(processGroupId, signal) {
|
||||
process.kill(-processGroupId, signal);
|
||||
},
|
||||
});
|
||||
};
|
||||
const captureBaseline = async (
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProcessOwnershipBaseline> => {
|
||||
if (platform !== "linux" && darwinTokens === undefined) return [];
|
||||
const processes = liveProcesses(await listProcesses(signal));
|
||||
let identities = await processIdentities(processes, signal);
|
||||
const unreadable = processes.filter(
|
||||
({ pid }) => identities.get(pid)?.state !== "readable",
|
||||
);
|
||||
if (unreadable.length > 0) {
|
||||
const stillLive = liveProcesses(await listProcesses(signal)).filter(
|
||||
({ pid }) => unreadable.some((entry) => entry.pid === pid),
|
||||
);
|
||||
const retried = await processIdentities(stillLive, signal);
|
||||
identities = new Map([...identities, ...retried]);
|
||||
const unresolved = stillLive.filter(
|
||||
({ pid }) => identities.get(pid)?.state !== "readable",
|
||||
);
|
||||
const liveAfterRetry = liveProcesses(await listProcesses(signal));
|
||||
const stillUnresolved = unresolved.filter(({ pid }) =>
|
||||
liveAfterRetry.some((entry) => entry.pid === pid),
|
||||
);
|
||||
if (stillUnresolved.length > 0)
|
||||
throw new Error(
|
||||
`process identity snapshot is unavailable for ${String(stillUnresolved.length)} live processes`,
|
||||
);
|
||||
}
|
||||
return processes.map(({ pid }) => {
|
||||
const observation = identities.get(pid);
|
||||
return {
|
||||
pid,
|
||||
identity:
|
||||
observation?.state === "readable" ? observation.identity : null,
|
||||
};
|
||||
});
|
||||
};
|
||||
return {
|
||||
platform,
|
||||
prepare: async (signal) => {
|
||||
await darwinTokens?.prepare(signal);
|
||||
},
|
||||
listProcesses,
|
||||
async environment(pid) {
|
||||
if (platform === "linux")
|
||||
return parseProcessEnvironment(
|
||||
await readFile(`/proc/${pid}/environ`, "utf8"),
|
||||
);
|
||||
if (platform === "darwin") {
|
||||
const process = (await listProcesses()).find(
|
||||
(entry) => entry.pid === pid,
|
||||
);
|
||||
if (process === undefined)
|
||||
throw new Error(`process ${String(pid)} is not live`);
|
||||
const observation = (await darwinTokens?.read([process]))?.get(pid);
|
||||
if (observation === undefined || observation.state === "unavailable")
|
||||
throw new Error(
|
||||
observation?.reason ?? "process run token could not be read",
|
||||
);
|
||||
return observation.runId === undefined
|
||||
? {}
|
||||
: { REA_PROCESS_RUN_ID: observation.runId };
|
||||
}
|
||||
const { stdout } = await execFileOutput(
|
||||
"ps",
|
||||
["eww", "-p", String(pid)],
|
||||
{
|
||||
env: hostEnvironment,
|
||||
},
|
||||
);
|
||||
const observedEnvironment: Record<string, string> = {};
|
||||
for (const match of stdout.matchAll(
|
||||
/(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=([^\s]*)/gu,
|
||||
)) {
|
||||
const name = match[1];
|
||||
if (name !== undefined) observedEnvironment[name] = match[2] ?? "";
|
||||
}
|
||||
return observedEnvironment;
|
||||
},
|
||||
async runTokens(processes) {
|
||||
if (platform !== "darwin" || processes.length === 0) return new Map();
|
||||
try {
|
||||
return (await darwinTokens?.read(processes)) ?? new Map();
|
||||
} catch (cause: unknown) {
|
||||
void cause;
|
||||
return new Map(
|
||||
processes.map(({ pid }) => [
|
||||
pid,
|
||||
{ state: "unavailable", reason: "reader_failure" } as const,
|
||||
]),
|
||||
);
|
||||
}
|
||||
},
|
||||
...(platform !== "linux" && darwinTokens === undefined
|
||||
? {}
|
||||
: {
|
||||
processIdentities,
|
||||
captureBaseline,
|
||||
...(darwinTokens === undefined ? {} : { close: darwinTokens.close }),
|
||||
}),
|
||||
signalGroup(processGroupId, signal) {
|
||||
process.kill(-processGroupId, signal);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const systemHost = createSystemProcessOwnershipHost();
|
||||
export const systemProcessOwnershipHost = createSystemProcessOwnershipHost();
|
||||
|
||||
/** Prepare native token inspection before REA launches a captured child. */
|
||||
export const prepareProcessOwnershipInspection = async (
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
await systemProcessOwnershipHost.prepare?.(signal);
|
||||
};
|
||||
|
||||
/** Observe one group without signaling it, failing closed on identity doubt. */
|
||||
export const observeOwnedProcessGroup = async (
|
||||
ownership: OwnedProcessGroup,
|
||||
host: ProcessOwnershipHost = systemHost,
|
||||
host: ProcessOwnershipHost = systemProcessOwnershipHost,
|
||||
): Promise<ProcessGroupObservation> =>
|
||||
observeOwnedProcessGroupWithHost(ownership, host);
|
||||
|
||||
@@ -209,7 +387,7 @@ export const observeOwnedProcessGroup = async (
|
||||
*/
|
||||
export const observeOwnedProcessLineage = async (
|
||||
ownership: OwnedProcessGroup,
|
||||
host: ProcessOwnershipHost = systemHost,
|
||||
host: ProcessOwnershipHost = systemProcessOwnershipHost,
|
||||
): Promise<ProcessLineageObservation> =>
|
||||
observeOwnedProcessLineageWithHost(ownership, host);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
OwnedProcessGroup,
|
||||
ProcessCleanupResult,
|
||||
} from "./ProcessOwnership.js";
|
||||
import { prepareProcessOwnershipInspection } from "./ProcessOwnershipObservation.js";
|
||||
|
||||
const DEFAULT_TERMINATION_GRACE_MS = 250;
|
||||
const DEFAULT_KILL_GRACE_MS = 1_000;
|
||||
@@ -29,6 +30,8 @@ export interface OwnedProviderProcessSpawnOptions {
|
||||
readonly hostEnvironment?: NodeJS.ProcessEnv;
|
||||
/** Opt into a writable protocol stream; other providers retain ignored stdin. */
|
||||
readonly stdin?: "pipe";
|
||||
/** Cancels native ownership preparation before provider process creation. */
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Spawned process paired with the identity proof required for group cleanup. */
|
||||
@@ -132,6 +135,9 @@ export type ProviderProcessStopResult =
|
||||
export const spawnOwnedProviderProcess = async (
|
||||
options: OwnedProviderProcessSpawnOptions,
|
||||
): Promise<SpawnedOwnedProviderProcess> => {
|
||||
options.signal?.throwIfAborted();
|
||||
await prepareProcessOwnershipInspection(options.signal);
|
||||
options.signal?.throwIfAborted();
|
||||
const platform = options.platform ?? process.platform;
|
||||
const hostEnvironment = options.hostEnvironment ?? process.env;
|
||||
const environment = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { tmpdir } from "node:os";
|
||||
import { prepareProcessOwnershipInspection } from "../ProcessOwnershipObservation.js";
|
||||
|
||||
export type ProcessCaptureCapability =
|
||||
| { readonly available: true; readonly backend: "node-pty" }
|
||||
@@ -20,6 +21,7 @@ export const processCaptureOwnershipUnavailableReason = (
|
||||
export interface ProcessCaptureProbeOptions {
|
||||
readonly platform?: NodeJS.Platform;
|
||||
readonly loadPty?: () => Promise<typeof import("@lydell/node-pty")>;
|
||||
readonly prepareOwnershipInspector?: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Probe the native PTY seam and explain loader failures at their source. */
|
||||
@@ -56,6 +58,22 @@ export const probeProcessCaptureCapability = async (
|
||||
backend: "node-pty",
|
||||
reason: ownershipReason,
|
||||
};
|
||||
if (platform === "darwin") {
|
||||
try {
|
||||
await (
|
||||
options.prepareOwnershipInspector ?? prepareProcessOwnershipInspection
|
||||
)();
|
||||
} catch (cause: unknown) {
|
||||
return {
|
||||
available: false,
|
||||
backend: "node-pty",
|
||||
reason:
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "macOS process ownership inspection could not be prepared",
|
||||
};
|
||||
}
|
||||
}
|
||||
try {
|
||||
const { spawn } = await (
|
||||
options.loadPty ?? (() => import("@lydell/node-pty"))
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type {
|
||||
PartialProcessCaptureObservation,
|
||||
ProcessCaptureCleanupReport,
|
||||
} from "../../domain/process/processCapture.js";
|
||||
|
||||
interface ProcessCaptureErrorOptions extends ErrorOptions {
|
||||
readonly userMessage?: string;
|
||||
readonly userCategory?: "cancelled";
|
||||
readonly reason?: "capture_failed" | "cleanup_incomplete" | "cancelled";
|
||||
readonly cleanupResources?: readonly string[];
|
||||
readonly executionFailure?: string;
|
||||
readonly partialObservation?: PartialProcessCaptureObservation;
|
||||
readonly cleanupReport?: ProcessCaptureCleanupReport;
|
||||
}
|
||||
|
||||
/** Typed application failure produced by controlled process capture. */
|
||||
@@ -16,6 +23,11 @@ export class ProcessCaptureError extends AnalysisError {
|
||||
readonly reason: NonNullable<ProcessCaptureErrorOptions["reason"]>;
|
||||
override readonly cleanupIncomplete: boolean;
|
||||
override readonly cleanupResources: readonly string[];
|
||||
override readonly executionFailure: string | undefined;
|
||||
override readonly partialObservation:
|
||||
| PartialProcessCaptureObservation
|
||||
| undefined;
|
||||
override readonly cleanupReport: ProcessCaptureCleanupReport | undefined;
|
||||
|
||||
constructor(message: string, options?: ProcessCaptureErrorOptions) {
|
||||
super(message, options);
|
||||
@@ -26,9 +38,22 @@ export class ProcessCaptureError extends AnalysisError {
|
||||
(options?.userCategory === "cancelled" ? "cancelled" : "capture_failed");
|
||||
this.cleanupIncomplete = this.reason === "cleanup_incomplete";
|
||||
this.cleanupResources = options?.cleanupResources ?? [];
|
||||
this.executionFailure = options?.executionFailure;
|
||||
this.partialObservation = options?.partialObservation;
|
||||
this.cleanupReport = options?.cleanupReport;
|
||||
}
|
||||
}
|
||||
|
||||
/** Return one non-recursive, domain-neutral summary of an execution failure. */
|
||||
export const describeProcessCaptureExecutionFailure = (
|
||||
failure: unknown,
|
||||
): string | undefined => {
|
||||
if (failure instanceof Error) return failure.message || failure.name;
|
||||
if (typeof failure === "string") return failure;
|
||||
if (failure === undefined) return undefined;
|
||||
return "Process capture failed with a non-error rejection.";
|
||||
};
|
||||
|
||||
/** Caller-visible cancellation without exposing capture implementation state. */
|
||||
export const processCaptureCancelled = (): ProcessCaptureError =>
|
||||
new ProcessCaptureError("process capture was cancelled", {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { constants, statSync, type BigIntStats } from "node:fs";
|
||||
import { open, mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { IPty } from "@lydell/node-pty";
|
||||
@@ -12,6 +12,11 @@ import type {
|
||||
UnverifiedProcessCapture,
|
||||
ProcessSample,
|
||||
ProcessSettlement,
|
||||
ProcessCaptureCleanupReport,
|
||||
PartialProcessCaptureObservation,
|
||||
IncompleteProcessCaptureObservations,
|
||||
PartialFilesystemSnapshot,
|
||||
PartialProcessObservationField,
|
||||
ProcessScenario,
|
||||
ProcessCaptureEventJournalEntry,
|
||||
RecordProcessCaptureEvent,
|
||||
@@ -19,6 +24,8 @@ import type {
|
||||
} from "../../domain/process/processCapture.js";
|
||||
import {
|
||||
digestProcessCommitment,
|
||||
parseProcessCapture,
|
||||
partialProcessCaptureObservationSchema,
|
||||
processComparisonContract,
|
||||
processScenarioCommitment,
|
||||
} from "../../domain/process/processCapture.js";
|
||||
@@ -26,9 +33,21 @@ import { PRODUCT_IDENTITY } from "../../identity.js";
|
||||
import type { SnapshotResult } from "./FilesystemSnapshot.js";
|
||||
import { snapshotRoots } from "./FilesystemSnapshot.js";
|
||||
import { classifyFilesystemEffects } from "./ProcessFilesystemEffects.js";
|
||||
import { cleanupOwnedProcessGroup } from "../ProcessOwnership.js";
|
||||
import { observeOwnedProcessGroup } from "../ProcessOwnershipObservation.js";
|
||||
import { ProcessCaptureError } from "./ProcessCaptureError.js";
|
||||
import {
|
||||
cleanupOwnedProcessGroup,
|
||||
verifyNoTokenOwnedProcesses,
|
||||
type ProcessCleanupResult,
|
||||
type ProcessOwnershipBaseline,
|
||||
} from "../ProcessOwnership.js";
|
||||
import {
|
||||
observeOwnedProcessGroup,
|
||||
prepareProcessOwnershipInspection,
|
||||
systemProcessOwnershipHost,
|
||||
} from "../ProcessOwnershipObservation.js";
|
||||
import {
|
||||
describeProcessCaptureExecutionFailure,
|
||||
ProcessCaptureError,
|
||||
} from "./ProcessCaptureError.js";
|
||||
import { assertNotCancelled } from "./ProcessScenarioRuntimeValidation.js";
|
||||
import {
|
||||
normalizeProcessElapsedTime,
|
||||
@@ -92,6 +111,107 @@ export type PendingProcessCapture = Omit<
|
||||
readonly settlement: ObservedProcessSettlement;
|
||||
};
|
||||
|
||||
/** Mutable collection of facts available if completion stops before a capture exists. */
|
||||
export interface ProcessCaptureObservationBuffer {
|
||||
target_pid: PartialProcessObservationField<number>;
|
||||
frames: PartialProcessObservationField<readonly TerminalFrame[]>;
|
||||
rendered_frames: PartialProcessObservationField<
|
||||
UnverifiedProcessCapture["rendered_frames"]
|
||||
>;
|
||||
interaction_events: PartialProcessObservationField<
|
||||
UnverifiedProcessCapture["interaction_events"]
|
||||
>;
|
||||
exit: IncompleteProcessCaptureObservations["exit"];
|
||||
settlement: IncompleteProcessCaptureObservations["settlement"];
|
||||
process_samples: PartialProcessObservationField<readonly ProcessSample[]>;
|
||||
filesystem_snapshots: {
|
||||
before: PartialProcessObservationField<PartialFilesystemSnapshot>;
|
||||
after: PartialProcessObservationField<PartialFilesystemSnapshot>;
|
||||
};
|
||||
event_journal: PartialProcessObservationField<
|
||||
readonly ProcessCaptureEventJournalEntry[]
|
||||
>;
|
||||
manifest: IncompleteProcessCaptureObservations["manifest"];
|
||||
}
|
||||
|
||||
/** Seed explicit observation availability before capture completion begins. */
|
||||
export const createProcessCaptureObservationBuffer = (options: {
|
||||
readonly frames: readonly TerminalFrame[];
|
||||
readonly interactions: readonly InteractionEvent[];
|
||||
readonly samples: readonly ProcessSample[];
|
||||
readonly eventJournal: readonly ProcessCaptureEventJournalEntry[];
|
||||
readonly before: SnapshotResult;
|
||||
}): ProcessCaptureObservationBuffer => ({
|
||||
target_pid: {
|
||||
state: "unavailable",
|
||||
reason: "No PTY process was spawned before the run failed.",
|
||||
},
|
||||
frames: { state: "available", value: options.frames },
|
||||
rendered_frames: {
|
||||
state: "unavailable",
|
||||
reason:
|
||||
"Rendered terminal frames were not collected before capture completion finished.",
|
||||
},
|
||||
interaction_events: { state: "available", value: options.interactions },
|
||||
exit: {
|
||||
state: "unavailable",
|
||||
reason: "Terminal exit was not observed before the run failed.",
|
||||
},
|
||||
settlement: {
|
||||
state: "unavailable",
|
||||
reason:
|
||||
"Process settlement was not observed before capture completion finished.",
|
||||
},
|
||||
process_samples: { state: "available", value: options.samples },
|
||||
filesystem_snapshots: {
|
||||
before: {
|
||||
state: "available",
|
||||
value: {
|
||||
files: options.before.files,
|
||||
truncated: options.before.truncated,
|
||||
},
|
||||
},
|
||||
after: {
|
||||
state: "unavailable",
|
||||
reason:
|
||||
"Final filesystem snapshot was not collected before capture completion finished.",
|
||||
},
|
||||
},
|
||||
event_journal: { state: "available", value: options.eventJournal },
|
||||
manifest: {
|
||||
state: "unavailable",
|
||||
reason:
|
||||
"Capture manifest was not produced before capture completion finished.",
|
||||
},
|
||||
});
|
||||
|
||||
const normalizePartialProcessObservations = (
|
||||
observations: IncompleteProcessCaptureObservations,
|
||||
context: { readonly scenario: ProcessScenario; readonly rootPid?: number },
|
||||
): IncompleteProcessCaptureObservations => {
|
||||
const targetPid = observations.target_pid;
|
||||
const target_pid =
|
||||
targetPid.state === "available" && context.rootPid !== undefined
|
||||
? {
|
||||
state: "available" as const,
|
||||
value: context.scenario.normalization.pids ? 1 : context.rootPid,
|
||||
}
|
||||
: targetPid;
|
||||
const samples = observations.process_samples;
|
||||
const process_samples =
|
||||
samples.state === "available" && context.rootPid !== undefined
|
||||
? {
|
||||
state: "available" as const,
|
||||
value: normalizeProcessSamples(
|
||||
samples.value,
|
||||
context.scenario,
|
||||
context.rootPid,
|
||||
),
|
||||
}
|
||||
: samples;
|
||||
return { ...observations, target_pid, process_samples };
|
||||
};
|
||||
|
||||
/** Wait for trailing PTY callbacks before the capture becomes immutable. */
|
||||
export const settleProcessCaptureJournal = async (
|
||||
journal: readonly ProcessCaptureEventJournalEntry[],
|
||||
@@ -152,6 +272,7 @@ export const buildCaptureResult = (
|
||||
),
|
||||
truncated: options.truncated,
|
||||
limitations: [
|
||||
"The executable digest is a prelaunch file sample; matching path metadata immediately after spawn does not prove an atomic operating-system image binding.",
|
||||
"Process trees are sampled and may omit short-lived descendants.",
|
||||
...(options.samplingPartial
|
||||
? ["Process-tree sampling ended with an incomplete observation."]
|
||||
@@ -167,6 +288,11 @@ export const buildCaptureResult = (
|
||||
reason:
|
||||
"Process trees are sampled and may omit short-lived descendants.",
|
||||
},
|
||||
{
|
||||
scope: "process",
|
||||
reason:
|
||||
"The executable digest is a prelaunch file sample; operating-system image binding is not atomically observed.",
|
||||
},
|
||||
{
|
||||
scope: "environment",
|
||||
reason: "Inherited host environment variables are not recorded.",
|
||||
@@ -190,16 +316,163 @@ export const buildCaptureResult = (
|
||||
};
|
||||
};
|
||||
|
||||
const hashFile = async (path: string): Promise<string> => {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
||||
return hash.digest("hex");
|
||||
interface ExecutableFileIdentity {
|
||||
readonly dev: bigint;
|
||||
readonly ino: bigint;
|
||||
readonly mode: bigint;
|
||||
readonly size: bigint;
|
||||
readonly mtimeNs: bigint;
|
||||
readonly ctimeNs: bigint;
|
||||
}
|
||||
|
||||
export interface SelectedExecutableObservation {
|
||||
readonly sha256: string | null;
|
||||
readonly identity: ExecutableFileIdentity | null;
|
||||
readonly reason: string | null;
|
||||
}
|
||||
|
||||
export interface LaunchedExecutableObservation {
|
||||
readonly selectedSha256: string | null;
|
||||
readonly sha256: string | null;
|
||||
readonly state: "path_metadata_unchanged" | "unknown";
|
||||
readonly reason: string | null;
|
||||
}
|
||||
|
||||
const identityFromStats = (stats: BigIntStats): ExecutableFileIdentity => ({
|
||||
dev: stats.dev,
|
||||
ino: stats.ino,
|
||||
mode: stats.mode,
|
||||
size: stats.size,
|
||||
mtimeNs: stats.mtimeNs,
|
||||
ctimeNs: stats.ctimeNs,
|
||||
});
|
||||
|
||||
const sameExecutableIdentity = (
|
||||
left: ExecutableFileIdentity,
|
||||
right: ExecutableFileIdentity,
|
||||
): boolean =>
|
||||
left.dev === right.dev &&
|
||||
left.ino === right.ino &&
|
||||
left.mode === right.mode &&
|
||||
left.size === right.size &&
|
||||
left.mtimeNs === right.mtimeNs &&
|
||||
left.ctimeNs === right.ctimeNs;
|
||||
|
||||
const filesystemFailure = (cause: unknown): string | undefined => {
|
||||
if (!(cause instanceof Error) || !("code" in cause) || !("syscall" in cause))
|
||||
return undefined;
|
||||
return `Executable file observation failed: ${cause.message}`;
|
||||
};
|
||||
|
||||
/** Sample the caller-selected executable contents and stable file identity before launch. */
|
||||
export const observeSelectedExecutable = async (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SelectedExecutableObservation> => {
|
||||
assertNotCancelled(signal);
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
||||
const openedStats = await handle.stat({ bigint: true });
|
||||
if (!openedStats.isFile())
|
||||
return {
|
||||
sha256: null,
|
||||
identity: null,
|
||||
reason: "Selected executable is not a regular file.",
|
||||
};
|
||||
const openedIdentity = identityFromStats(openedStats);
|
||||
const selectedPathIdentity = identityFromStats(
|
||||
await stat(path, { bigint: true }),
|
||||
);
|
||||
if (!sameExecutableIdentity(openedIdentity, selectedPathIdentity))
|
||||
return {
|
||||
sha256: null,
|
||||
identity: null,
|
||||
reason: "Selected executable path changed while it was being opened.",
|
||||
};
|
||||
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of handle.createReadStream({
|
||||
autoClose: false,
|
||||
signal,
|
||||
})) {
|
||||
assertNotCancelled(signal);
|
||||
hash.update(chunk);
|
||||
}
|
||||
assertNotCancelled(signal);
|
||||
const finalIdentity = identityFromStats(
|
||||
await handle.stat({ bigint: true }),
|
||||
);
|
||||
if (!sameExecutableIdentity(openedIdentity, finalIdentity))
|
||||
return {
|
||||
sha256: null,
|
||||
identity: null,
|
||||
reason: "Selected executable changed while its contents were sampled.",
|
||||
};
|
||||
return {
|
||||
sha256: hash.digest("hex"),
|
||||
identity: openedIdentity,
|
||||
reason: null,
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
assertNotCancelled(signal);
|
||||
const reason = filesystemFailure(cause);
|
||||
if (reason !== undefined) return { sha256: null, identity: null, reason };
|
||||
throw cause;
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
};
|
||||
|
||||
/** Compare the selected file metadata with the path immediately after PTY spawn. */
|
||||
export const observeLaunchedExecutable = (
|
||||
path: string,
|
||||
selected: SelectedExecutableObservation,
|
||||
): LaunchedExecutableObservation => {
|
||||
if (selected.identity === null || selected.sha256 === null)
|
||||
return {
|
||||
selectedSha256: selected.sha256,
|
||||
sha256: null,
|
||||
state: "unknown",
|
||||
reason: selected.reason ?? "Selected executable identity is unavailable.",
|
||||
};
|
||||
try {
|
||||
const currentStats = statSync(path, { bigint: true });
|
||||
const currentIdentity = identityFromStats(currentStats);
|
||||
if (
|
||||
currentStats.isFile() &&
|
||||
sameExecutableIdentity(selected.identity, currentIdentity)
|
||||
)
|
||||
return {
|
||||
selectedSha256: selected.sha256,
|
||||
sha256: selected.sha256,
|
||||
state: "path_metadata_unchanged",
|
||||
reason: null,
|
||||
};
|
||||
return {
|
||||
selectedSha256: selected.sha256,
|
||||
sha256: null,
|
||||
state: "unknown",
|
||||
reason: "Executable path metadata changed between sampling and spawn.",
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
const reason = filesystemFailure(cause);
|
||||
if (reason !== undefined)
|
||||
return {
|
||||
selectedSha256: selected.sha256,
|
||||
sha256: null,
|
||||
state: "unknown",
|
||||
reason,
|
||||
};
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
export const createRunManifest = async (
|
||||
scenario: ProcessScenario,
|
||||
startedAt: Date,
|
||||
completedAt: Date,
|
||||
executable: LaunchedExecutableObservation,
|
||||
host: {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly architecture: NodeJS.Architecture;
|
||||
@@ -208,10 +481,9 @@ export const createRunManifest = async (
|
||||
architecture: process.arch,
|
||||
},
|
||||
): Promise<UnverifiedProcessCapture["manifest"]> => {
|
||||
const executableSha256 = await hashFile(scenario.executable);
|
||||
const scenarioCommitment = processScenarioCommitment(
|
||||
scenario,
|
||||
executableSha256,
|
||||
executable.selectedSha256 ?? undefined,
|
||||
);
|
||||
const comparisonContract = processComparisonContract(scenario);
|
||||
return {
|
||||
@@ -226,7 +498,12 @@ export const createRunManifest = async (
|
||||
comparison_contract: comparisonContract,
|
||||
full_scenario_sha256: digestProcessCommitment(scenarioCommitment),
|
||||
comparison_contract_sha256: digestProcessCommitment(comparisonContract),
|
||||
executable_sha256: executableSha256,
|
||||
selected_executable_sha256: executable.selectedSha256,
|
||||
executable_sha256: executable.sha256,
|
||||
executable_identity: {
|
||||
state: executable.state,
|
||||
reason: executable.reason,
|
||||
},
|
||||
normalization_sha256: digestProcessCommitment(scenario.normalization),
|
||||
};
|
||||
};
|
||||
@@ -339,50 +616,142 @@ export const awaitTerminalExit = async ({
|
||||
export interface ProcessCaptureCleanupHost {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly cleanupProcessGroup: typeof cleanupOwnedProcessGroup;
|
||||
readonly verifyTokenOwnedProcesses: typeof verifyNoTokenOwnedProcesses;
|
||||
readonly removeTemporaryRoot: (path: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const processCaptureCleanupHost: ProcessCaptureCleanupHost = {
|
||||
platform: process.platform,
|
||||
cleanupProcessGroup: cleanupOwnedProcessGroup,
|
||||
verifyTokenOwnedProcesses: verifyNoTokenOwnedProcesses,
|
||||
removeTemporaryRoot: (path) => rm(path, { recursive: true, force: true }),
|
||||
};
|
||||
|
||||
const PROCESS_CLEANUP_VERIFICATION_GRACE_MS = 1_000;
|
||||
const PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS = 25;
|
||||
|
||||
const verifyTokenOwnedProcessesUntilSettled = async (
|
||||
verify: () => Promise<ProcessCleanupResult>,
|
||||
): Promise<ProcessCleanupResult> => {
|
||||
const deadline = Date.now() + PROCESS_CLEANUP_VERIFICATION_GRACE_MS;
|
||||
let result = await verify();
|
||||
while (!result.cleaned) {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) return result;
|
||||
await new Promise<void>((resolve) =>
|
||||
setTimeout(
|
||||
resolve,
|
||||
Math.min(PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS, remaining),
|
||||
),
|
||||
);
|
||||
result = await verify();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const releaseProcessResources = async (options: {
|
||||
readonly timers: ReadonlySet<ProcessTimer>;
|
||||
readonly terminal: IPty | undefined;
|
||||
readonly terminal: Pick<IPty, "pid"> | undefined;
|
||||
readonly renderer: TerminalRenderer | undefined;
|
||||
readonly runId: string;
|
||||
readonly temporaryRoot: string;
|
||||
readonly capturedProcessGroupIds: readonly number[];
|
||||
readonly captureBaseline?: ProcessOwnershipBaseline;
|
||||
readonly sampledProcessGroupIds?: readonly number[];
|
||||
readonly host?: ProcessCaptureCleanupHost;
|
||||
}): Promise<string | undefined> => {
|
||||
}): Promise<ProcessCaptureCleanupReport> => {
|
||||
const host = options.host ?? processCaptureCleanupHost;
|
||||
for (const timer of options.timers) timer.cancel();
|
||||
let failure: string | undefined;
|
||||
let terminalRenderer: ProcessCaptureCleanupReport["terminal_renderer"] = {
|
||||
state: options.renderer === undefined ? "not_required" : "cleaned",
|
||||
reason: null,
|
||||
};
|
||||
try {
|
||||
await options.renderer?.dispose();
|
||||
} catch (cause: unknown) {
|
||||
void cause;
|
||||
failure ??= "terminal renderer cleanup failed";
|
||||
terminalRenderer = {
|
||||
state: "failed",
|
||||
reason:
|
||||
cause instanceof Error
|
||||
? cause.message || cause.name || "renderer dispose failed"
|
||||
: "renderer dispose failed",
|
||||
};
|
||||
}
|
||||
if (options.terminal !== undefined && host.platform !== "win32") {
|
||||
for (const processGroupId of new Set(options.capturedProcessGroupIds)) {
|
||||
let ownedProcessGroup: ProcessCaptureCleanupReport["owned_process_group"] = {
|
||||
state: options.terminal === undefined ? "not_required" : "cleaned",
|
||||
reason: null,
|
||||
};
|
||||
if (options.terminal !== undefined) {
|
||||
if (host.platform === "win32") {
|
||||
ownedProcessGroup = {
|
||||
state: "unverified",
|
||||
reason:
|
||||
"owned process cleanup is unverifiable on Windows without process-job authority",
|
||||
};
|
||||
} else {
|
||||
const cleaned = await host.cleanupProcessGroup({
|
||||
runId: options.runId,
|
||||
leaderPid: processGroupId,
|
||||
processGroupId,
|
||||
leaderPid: options.terminal.pid,
|
||||
processGroupId: options.terminal.pid,
|
||||
sweepTokenOwnedProcesses: true,
|
||||
...(options.sampledProcessGroupIds === undefined
|
||||
? {}
|
||||
: { sampledProcessGroupIds: options.sampledProcessGroupIds }),
|
||||
...(options.captureBaseline === undefined
|
||||
? {}
|
||||
: { captureBaseline: options.captureBaseline }),
|
||||
});
|
||||
if (!cleaned.cleaned) failure ??= cleaned.reason;
|
||||
if (!cleaned.cleaned) {
|
||||
ownedProcessGroup = { state: "unverified", reason: cleaned.reason };
|
||||
} else {
|
||||
const verified = await verifyTokenOwnedProcessesUntilSettled(() =>
|
||||
host.verifyTokenOwnedProcesses(
|
||||
options.runId,
|
||||
undefined,
|
||||
options.captureBaseline,
|
||||
),
|
||||
);
|
||||
if (!verified.cleaned)
|
||||
ownedProcessGroup = { state: "unverified", reason: verified.reason };
|
||||
}
|
||||
}
|
||||
}
|
||||
let temporaryRoot: ProcessCaptureCleanupReport["temporary_root"] = {
|
||||
state: "cleaned",
|
||||
reason: null,
|
||||
};
|
||||
try {
|
||||
await host.removeTemporaryRoot(options.temporaryRoot);
|
||||
} catch (cause: unknown) {
|
||||
void cause;
|
||||
failure ??= "temporary process root cleanup failed";
|
||||
temporaryRoot = {
|
||||
state: "failed",
|
||||
reason:
|
||||
cause instanceof Error
|
||||
? cause.message || cause.name || "temporary root removal failed"
|
||||
: "temporary root removal failed",
|
||||
};
|
||||
}
|
||||
return failure;
|
||||
return {
|
||||
owned_process_group: ownedProcessGroup,
|
||||
terminal_renderer: terminalRenderer,
|
||||
temporary_root: temporaryRoot,
|
||||
};
|
||||
};
|
||||
|
||||
export const cleanupReportFailure = (
|
||||
report: ProcessCaptureCleanupReport,
|
||||
): string | undefined => {
|
||||
const failures = Object.entries(report).filter(
|
||||
([, outcome]) =>
|
||||
outcome.state === "failed" || outcome.state === "unverified",
|
||||
);
|
||||
return failures.length === 0
|
||||
? undefined
|
||||
: failures
|
||||
.map(
|
||||
([resource, outcome]) =>
|
||||
`${resource}: ${outcome.reason ?? outcome.state}`,
|
||||
)
|
||||
.join("; ");
|
||||
};
|
||||
|
||||
export const captureTerminalFrames = (options: {
|
||||
@@ -428,22 +797,106 @@ export const captureTerminalFrames = (options: {
|
||||
};
|
||||
|
||||
export const resolveProcessResult = (
|
||||
capture: ProcessCapture | undefined,
|
||||
capture: PendingProcessCapture | ProcessCapture | undefined,
|
||||
executionFailure: unknown,
|
||||
cleanupFailure: string | undefined,
|
||||
cleanup: ProcessCaptureCleanupReport,
|
||||
observations?: IncompleteProcessCaptureObservations,
|
||||
partialContext?: {
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly rootPid?: number;
|
||||
},
|
||||
): ProcessCapture => {
|
||||
const cleanupFailure = cleanupReportFailure(cleanup);
|
||||
if (cleanupFailure !== undefined) {
|
||||
const cleanupResources = Object.entries(cleanup)
|
||||
.filter(
|
||||
([, outcome]) =>
|
||||
outcome.state === "failed" || outcome.state === "unverified",
|
||||
)
|
||||
.map(([resource]) => resource);
|
||||
const executionFailureReason =
|
||||
describeProcessCaptureExecutionFailure(executionFailure);
|
||||
let partialObservation: PartialProcessCaptureObservation | undefined;
|
||||
if (capture !== undefined || observations !== undefined) {
|
||||
const normalizedObservations =
|
||||
observations === undefined || partialContext === undefined
|
||||
? observations
|
||||
: normalizePartialProcessObservations(observations, partialContext);
|
||||
const partialPayload =
|
||||
capture === undefined
|
||||
? { observations: normalizedObservations }
|
||||
: { capture };
|
||||
const pendingCapture =
|
||||
capture === undefined
|
||||
? undefined
|
||||
: {
|
||||
...capture,
|
||||
settlement: {
|
||||
...capture.settlement,
|
||||
cleanup_outcome: "failed" as const,
|
||||
},
|
||||
};
|
||||
const partialResult = partialProcessCaptureObservationSchema.safeParse({
|
||||
...partialPayload,
|
||||
...(pendingCapture === undefined
|
||||
? {}
|
||||
: {
|
||||
capture: Object.fromEntries(
|
||||
Object.entries(pendingCapture).filter(
|
||||
([key]) => key !== "cleanup",
|
||||
),
|
||||
),
|
||||
}),
|
||||
cleanup,
|
||||
execution_failure: executionFailureReason ?? null,
|
||||
});
|
||||
if (partialResult.success) partialObservation = partialResult.data;
|
||||
}
|
||||
throw new ProcessCaptureError(cleanupFailure, {
|
||||
cause: executionFailure,
|
||||
reason: "cleanup_incomplete",
|
||||
cleanupResources,
|
||||
...(executionFailureReason === undefined
|
||||
? {}
|
||||
: { executionFailure: executionFailureReason }),
|
||||
...(partialObservation === undefined ? {} : { partialObservation }),
|
||||
cleanupReport: cleanup,
|
||||
});
|
||||
}
|
||||
if (executionFailure instanceof ProcessCaptureError) throw executionFailure;
|
||||
if (executionFailure !== undefined)
|
||||
if (executionFailure !== undefined) {
|
||||
const executionFailureReason =
|
||||
describeProcessCaptureExecutionFailure(executionFailure);
|
||||
throw new ProcessCaptureError("process capture failed", {
|
||||
cause: executionFailure,
|
||||
...(executionFailureReason === undefined
|
||||
? {}
|
||||
: { executionFailure: executionFailureReason }),
|
||||
});
|
||||
if (cleanupFailure !== undefined)
|
||||
throw new ProcessCaptureError(cleanupFailure, {
|
||||
reason: "cleanup_incomplete",
|
||||
});
|
||||
}
|
||||
if (capture === undefined)
|
||||
throw new ProcessCaptureError("process capture produced no result");
|
||||
return capture;
|
||||
if ("cleanup" in capture) return capture;
|
||||
|
||||
const settlement: UnverifiedProcessCapture["settlement"] =
|
||||
capture.settlement.state === "quiesced"
|
||||
? { ...capture.settlement, cleanup_outcome: "not_required" }
|
||||
: { ...capture.settlement, cleanup_outcome: "cleaned" };
|
||||
try {
|
||||
return parseProcessCapture({
|
||||
...capture,
|
||||
settlement,
|
||||
cleanup: {
|
||||
owned_process_group: "verified",
|
||||
temporary_root: "removed",
|
||||
},
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
throw new ProcessCaptureError(
|
||||
`process capture validation failed${cause instanceof Error ? `: ${cause.message}` : ""}`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const prepareProcessCapture = async (
|
||||
@@ -454,14 +907,21 @@ export const prepareProcessCapture = async (
|
||||
): Promise<{
|
||||
readonly temporaryRoot: string;
|
||||
readonly runId: string;
|
||||
readonly ownershipBaseline: ProcessOwnershipBaseline;
|
||||
readonly before: SnapshotResult;
|
||||
}> => {
|
||||
assertNotCancelled(signal);
|
||||
await host.prepareOwnershipInspector?.(signal);
|
||||
assertNotCancelled(signal);
|
||||
const before = await captureSnapshot(scenario, signal);
|
||||
const temporaryRoot = await host.createTemporaryRoot();
|
||||
try {
|
||||
assertNotCancelled(signal);
|
||||
const runId = randomUUID();
|
||||
return { temporaryRoot, runId, before };
|
||||
const ownershipBaseline =
|
||||
(await host.captureOwnershipBaseline?.(signal)) ?? [];
|
||||
assertNotCancelled(signal);
|
||||
return { temporaryRoot, runId, ownershipBaseline, before };
|
||||
} catch (cause: unknown) {
|
||||
await host.cleanup(temporaryRoot);
|
||||
throw cause;
|
||||
@@ -470,11 +930,19 @@ export const prepareProcessCapture = async (
|
||||
|
||||
/** Filesystem seam for allocating and cleaning a capture's temporary root. */
|
||||
export interface ProcessPreparationHost {
|
||||
prepareOwnershipInspector?(signal?: AbortSignal): Promise<void>;
|
||||
/** Capture the process baseline with the caller's startup cancellation. */
|
||||
captureOwnershipBaseline?(
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProcessOwnershipBaseline>;
|
||||
createTemporaryRoot(): Promise<string>;
|
||||
cleanup(path: string): Promise<void>;
|
||||
}
|
||||
|
||||
const systemProcessPreparationHost: ProcessPreparationHost = {
|
||||
prepareOwnershipInspector: prepareProcessOwnershipInspection,
|
||||
captureOwnershipBaseline: (signal) =>
|
||||
systemProcessOwnershipHost.captureBaseline?.(signal) ?? Promise.resolve([]),
|
||||
createTemporaryRoot: () => mkdtemp(join(tmpdir(), "rea-process-")),
|
||||
cleanup: (path) => rm(path, { recursive: true, force: true }),
|
||||
};
|
||||
@@ -1,11 +1,27 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { processScenarioSchema } from "../../domain/process/processScenario.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
|
||||
import { captureProcessScenario } from "./ProcessHarness.js";
|
||||
import {
|
||||
captureProcessScenario,
|
||||
normalizeCaptureFailure,
|
||||
ProcessCaptureError,
|
||||
} from "./ProcessHarness.js";
|
||||
import { settleProcessCaptureJournal } from "./ProcessCaptureLifecycle.js";
|
||||
import { processCaptureSchema } from "../../domain/process/processCapture.js";
|
||||
import {
|
||||
parseProcessCapture,
|
||||
partialProcessCaptureObservationSchema,
|
||||
processCaptureSchema,
|
||||
} from "../../domain/process/processCapture.js";
|
||||
import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../domain/process/processCapture.fixture.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { emptyProcessCapture } from "../../domain/process/processCapture.fixture.js";
|
||||
import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js";
|
||||
import {
|
||||
releaseProcessResources,
|
||||
resolveProcessResult,
|
||||
type ProcessCaptureCleanupHost,
|
||||
} from "./ProcessCaptureLifecycle.js";
|
||||
import { DarwinProcessOwnershipInspectionError } from "../DarwinProcessRunTokenReader.js";
|
||||
|
||||
it("rejects legacy replay output instead of silently discarding it", () => {
|
||||
expect(
|
||||
@@ -16,6 +32,21 @@ it("rejects legacy replay output instead of silently discarding it", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the actionable Darwin ownership compiler prerequisite", () => {
|
||||
const normalized = normalizeCaptureFailure(
|
||||
new DarwinProcessOwnershipInspectionError(
|
||||
"macOS process ownership inspection requires the Apple Swift compiler via xcrun",
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(normalized).toBeInstanceOf(ProcessCaptureError);
|
||||
expect(normalized).toMatchObject({
|
||||
message:
|
||||
"macOS process ownership inspection requires the Apple Swift compiler via xcrun",
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for terminal observations delivered after the exit callback", async () => {
|
||||
const journal: Array<{
|
||||
capture_order: number;
|
||||
@@ -41,6 +72,265 @@ it("waits for terminal observations delivered after the exit callback", async ()
|
||||
expect(journal[1]).toMatchObject({ collection: "frames", index: 1 });
|
||||
});
|
||||
|
||||
it("keeps empty cleanup exception messages actionable in the report", async () => {
|
||||
const host: ProcessCaptureCleanupHost = {
|
||||
platform: process.platform,
|
||||
cleanupProcessGroup: async () => ({
|
||||
cleaned: false,
|
||||
reason: "unused process cleanup",
|
||||
}),
|
||||
verifyTokenOwnedProcesses: async () => ({
|
||||
cleaned: false,
|
||||
reason: "unused process cleanup",
|
||||
}),
|
||||
removeTemporaryRoot: async () => {
|
||||
throw new Error("");
|
||||
},
|
||||
};
|
||||
const report = await releaseProcessResources({
|
||||
timers: new Set(),
|
||||
terminal: undefined,
|
||||
renderer: undefined,
|
||||
runId: "fixture-run",
|
||||
temporaryRoot: "/fixture/root",
|
||||
host,
|
||||
});
|
||||
|
||||
expect(report.temporary_root).toEqual({ state: "failed", reason: "Error" });
|
||||
});
|
||||
|
||||
it("waits for token-owned processes to exit after one cleanup signal", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let verificationCalls = 0;
|
||||
const exitsAt = Date.now() + 50;
|
||||
let cleanupCalls = 0;
|
||||
const host: ProcessCaptureCleanupHost = {
|
||||
platform: "linux",
|
||||
cleanupProcessGroup: async () => {
|
||||
cleanupCalls += 1;
|
||||
return { cleaned: true, signaled: true };
|
||||
},
|
||||
verifyTokenOwnedProcesses: async () => {
|
||||
verificationCalls += 1;
|
||||
return Date.now() < exitsAt
|
||||
? { cleaned: false, reason: "owned process is exiting" }
|
||||
: { cleaned: true, signaled: false };
|
||||
},
|
||||
removeTemporaryRoot: async () => undefined,
|
||||
};
|
||||
const result = releaseProcessResources({
|
||||
timers: new Set(),
|
||||
terminal: { pid: 321 },
|
||||
renderer: undefined,
|
||||
runId: "fixture-run",
|
||||
temporaryRoot: "/fixture/root",
|
||||
host,
|
||||
});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
const report = await result;
|
||||
|
||||
expect(report.owned_process_group).toEqual({
|
||||
state: "cleaned",
|
||||
reason: null,
|
||||
});
|
||||
expect(cleanupCalls).toBe(1);
|
||||
expect(verificationCalls).toBeGreaterThan(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains the last ownership failure when the verification grace expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let verificationCalls = 0;
|
||||
let cleanupCalls = 0;
|
||||
const host: ProcessCaptureCleanupHost = {
|
||||
platform: "linux",
|
||||
cleanupProcessGroup: async () => {
|
||||
cleanupCalls += 1;
|
||||
return { cleaned: true, signaled: true };
|
||||
},
|
||||
verifyTokenOwnedProcesses: async () => {
|
||||
verificationCalls += 1;
|
||||
return {
|
||||
cleaned: false,
|
||||
reason:
|
||||
verificationCalls === 1
|
||||
? "owned process has not exited yet"
|
||||
: "owned process remained after identity recheck",
|
||||
};
|
||||
},
|
||||
removeTemporaryRoot: async () => undefined,
|
||||
};
|
||||
const result = releaseProcessResources({
|
||||
timers: new Set(),
|
||||
terminal: { pid: 322 },
|
||||
renderer: undefined,
|
||||
runId: "fixture-run",
|
||||
temporaryRoot: "/fixture/root",
|
||||
host,
|
||||
});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
const report = await result;
|
||||
|
||||
expect(report.owned_process_group).toEqual({
|
||||
state: "unverified",
|
||||
reason: "owned process remained after identity recheck",
|
||||
});
|
||||
expect(cleanupCalls).toBe(1);
|
||||
expect(verificationCalls).toBeGreaterThan(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("passes sampled detached groups into cleanup even when their token is unknown", async () => {
|
||||
let sampledGroups: readonly number[] | undefined;
|
||||
const host: ProcessCaptureCleanupHost = {
|
||||
platform: "linux",
|
||||
cleanupProcessGroup: async (ownership) => {
|
||||
sampledGroups = ownership.sampledProcessGroupIds;
|
||||
return {
|
||||
cleaned: false,
|
||||
reason: "sampled group has no verifiable run token",
|
||||
};
|
||||
},
|
||||
verifyTokenOwnedProcesses: async () => ({
|
||||
cleaned: true,
|
||||
signaled: false,
|
||||
}),
|
||||
removeTemporaryRoot: async () => undefined,
|
||||
};
|
||||
|
||||
const report = await releaseProcessResources({
|
||||
timers: new Set(),
|
||||
terminal: { pid: 321 },
|
||||
renderer: undefined,
|
||||
runId: "fixture-run",
|
||||
temporaryRoot: "/fixture/root",
|
||||
sampledProcessGroupIds: [654],
|
||||
host,
|
||||
});
|
||||
|
||||
expect(sampledGroups).toEqual([654]);
|
||||
expect(report.owned_process_group).toEqual({
|
||||
state: "unverified",
|
||||
reason: "sampled group has no verifiable run token",
|
||||
});
|
||||
});
|
||||
|
||||
it("retains observations and both causes when process cleanup is unverifiable", () => {
|
||||
const verified = emptyProcessCapture();
|
||||
const capture = parseProcessCapture({
|
||||
...verified,
|
||||
frames: [{ sequence: 0, at_ms: 0, data: "observed output" }],
|
||||
process_samples: [
|
||||
{
|
||||
at_ms: 1,
|
||||
pid: 654,
|
||||
parent_pid: 321,
|
||||
command: "sanitized-detached-child",
|
||||
process_group_id: 654,
|
||||
session_id: 654,
|
||||
},
|
||||
],
|
||||
event_journal: [],
|
||||
});
|
||||
const executionFailure = new Error("capture ended after a fixture error");
|
||||
const cleanup = {
|
||||
owned_process_group: {
|
||||
state: "unverified" as const,
|
||||
reason:
|
||||
"process ownership token could not be read for 1 live process(es): environment_unavailable=1",
|
||||
},
|
||||
terminal_renderer: { state: "cleaned" as const, reason: null },
|
||||
temporary_root: { state: "cleaned" as const, reason: null },
|
||||
};
|
||||
|
||||
let error: ProcessCaptureError | undefined;
|
||||
try {
|
||||
resolveProcessResult(capture, executionFailure, cleanup);
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof ProcessCaptureError)) throw cause;
|
||||
error = cause;
|
||||
}
|
||||
expect(error).toBeDefined();
|
||||
if (error === undefined) throw new Error("expected cleanup-incomplete error");
|
||||
|
||||
const projection = projectAnalysisError(error);
|
||||
const parsedProjection = analysisErrorProjectionSchema.parse(projection);
|
||||
expect(parsedProjection).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
cleanup_report: cleanup,
|
||||
execution_failure: "capture ended after a fixture error",
|
||||
partial_observation: {
|
||||
capture: {
|
||||
frames: [{ data: "observed output" }],
|
||||
process_samples: [{ pid: 654, process_group_id: 654 }],
|
||||
settlement: { cleanup_outcome: "failed" },
|
||||
},
|
||||
execution_failure: "capture ended after a fixture error",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(error.cause).toBe(executionFailure);
|
||||
const partialObservation = error.partialObservation;
|
||||
expect(partialObservation).toBeDefined();
|
||||
if (partialObservation === undefined)
|
||||
throw new Error("expected validated partial observation");
|
||||
expect(
|
||||
partialProcessCaptureObservationSchema.safeParse({
|
||||
...partialObservation,
|
||||
cleanup: {
|
||||
owned_process_group: { state: "cleaned", reason: null },
|
||||
terminal_renderer: { state: "cleaned", reason: null },
|
||||
temporary_root: { state: "cleaned", reason: null },
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
if (!("capture" in partialObservation))
|
||||
throw new Error("expected completed partial capture observations");
|
||||
const partialCapture = partialObservation.capture;
|
||||
expect(() => parseProcessCapture(partialCapture)).toThrow();
|
||||
});
|
||||
|
||||
it("projects execution and cleanup failures when capture never completed", () => {
|
||||
const executionFailure = new Error("terminal startup failed");
|
||||
const cleanup = {
|
||||
owned_process_group: {
|
||||
state: "unverified" as const,
|
||||
reason: "process ownership token could not be read",
|
||||
},
|
||||
terminal_renderer: { state: "cleaned" as const, reason: null },
|
||||
temporary_root: { state: "cleaned" as const, reason: null },
|
||||
};
|
||||
|
||||
let error: ProcessCaptureError | undefined;
|
||||
try {
|
||||
resolveProcessResult(undefined, executionFailure, cleanup);
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof ProcessCaptureError)) throw cause;
|
||||
error = cause;
|
||||
}
|
||||
expect(error).toBeDefined();
|
||||
if (error === undefined) throw new Error("expected cleanup-incomplete error");
|
||||
|
||||
expect(error.partialObservation).toBeUndefined();
|
||||
expect(error.cause).toBe(executionFailure);
|
||||
expect(projectAnalysisError(error)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
cleanup_report: cleanup,
|
||||
execution_failure: "terminal startup failed",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed on Windows before resolving or launching scenario paths", async () => {
|
||||
const scenario = processScenarioSchema.parse({
|
||||
executable: "Z:/missing/should-never-be-resolved.exe",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
|
||||
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
describeProcessCaptureExecutionFailure,
|
||||
ProcessCaptureError,
|
||||
processCaptureCancelled,
|
||||
} from "./ProcessCaptureError.js";
|
||||
@@ -27,13 +28,19 @@ import { selectCapturedProcessGroupIds } from "../ProcessOwnershipProcessTree.js
|
||||
import {
|
||||
awaitTerminalExit,
|
||||
buildCaptureResult,
|
||||
cleanupReportFailure,
|
||||
captureTerminalFrames,
|
||||
createProcessCaptureObservationBuffer,
|
||||
createRunManifest,
|
||||
observeLaunchedExecutable,
|
||||
observeSelectedExecutable,
|
||||
observeSettlement,
|
||||
prepareProcessCapture,
|
||||
releaseProcessResources,
|
||||
resolveProcessResult,
|
||||
settleProcessCaptureJournal,
|
||||
type ProcessCaptureCleanupHost,
|
||||
type ProcessCaptureObservationBuffer,
|
||||
type PendingProcessCapture,
|
||||
} from "./ProcessCaptureLifecycle.js";
|
||||
import {
|
||||
@@ -47,6 +54,8 @@ import {
|
||||
import { makeProcessCaptureEnvironment } from "./ProcessCaptureEnvironment.js";
|
||||
import { classifyFilesystemEffects } from "./ProcessFilesystemEffects.js";
|
||||
import { processCaptureOwnershipUnavailableReason } from "./ProcessCaptureCapability.js";
|
||||
import type { ProcessOwnershipBaseline } from "../ProcessOwnership.js";
|
||||
import { DarwinProcessOwnershipInspectionError } from "../DarwinProcessRunTokenReader.js";
|
||||
export { probeProcessCaptureCapability } from "./ProcessCaptureCapability.js";
|
||||
|
||||
interface StartedCaptureRuntime {
|
||||
@@ -54,6 +63,7 @@ interface StartedCaptureRuntime {
|
||||
readonly terminal: IPty;
|
||||
readonly started: number;
|
||||
readonly startedAt: Date;
|
||||
readonly executableIdentity: ReturnType<typeof observeLaunchedExecutable>;
|
||||
readonly lastOutput: () => number;
|
||||
readonly framesTruncated: () => boolean;
|
||||
readonly stopSampler: () => Promise<{ readonly partial: boolean }>;
|
||||
@@ -65,19 +75,58 @@ const cleanupFailedStartup = async (options: {
|
||||
readonly terminal: IPty | undefined;
|
||||
readonly renderer: TerminalRenderer | undefined;
|
||||
readonly runId: string;
|
||||
readonly captureBaseline: ProcessOwnershipBaseline;
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly cleanupHost?: ProcessCaptureCleanupHost;
|
||||
readonly observations?: ProcessCaptureObservationBuffer;
|
||||
readonly temporaryRoot: string;
|
||||
}): Promise<never> => {
|
||||
const sampledProcessGroupIds =
|
||||
options.terminal !== undefined &&
|
||||
options.observations?.process_samples.state === "available"
|
||||
? selectCapturedProcessGroupIds(
|
||||
options.terminal.pid,
|
||||
options.observations.process_samples.value,
|
||||
).filter((groupId) => groupId !== options.terminal?.pid)
|
||||
: undefined;
|
||||
options.terminal?.kill("SIGKILL");
|
||||
if (options.observations !== undefined && options.renderer !== undefined) {
|
||||
try {
|
||||
options.observations.rendered_frames = {
|
||||
state: "available",
|
||||
value: await options.renderer.frames(),
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
options.observations.rendered_frames = {
|
||||
state: "unavailable",
|
||||
reason: `Rendered terminal frame collection failed: ${cause instanceof Error ? cause.message : "unknown failure"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
const cleanupFailure = await releaseProcessResources({
|
||||
...options,
|
||||
capturedProcessGroupIds:
|
||||
options.terminal === undefined ? [] : [options.terminal.pid],
|
||||
timers: options.timers,
|
||||
terminal: options.terminal,
|
||||
renderer: options.renderer,
|
||||
runId: options.runId,
|
||||
captureBaseline: options.captureBaseline,
|
||||
...(sampledProcessGroupIds === undefined ? {} : { sampledProcessGroupIds }),
|
||||
temporaryRoot: options.temporaryRoot,
|
||||
...(options.cleanupHost === undefined ? {} : { host: options.cleanupHost }),
|
||||
});
|
||||
if (cleanupFailure !== undefined)
|
||||
throw new ProcessCaptureError(cleanupFailure, {
|
||||
cause: options.cause,
|
||||
reason: "cleanup_incomplete",
|
||||
});
|
||||
if (cleanupReportFailure(cleanupFailure) !== undefined) {
|
||||
resolveProcessResult(
|
||||
undefined,
|
||||
options.cause,
|
||||
cleanupFailure,
|
||||
options.observations,
|
||||
{
|
||||
scenario: options.scenario,
|
||||
...(options.terminal === undefined
|
||||
? {}
|
||||
: { rootPid: options.terminal.pid }),
|
||||
},
|
||||
);
|
||||
}
|
||||
throw options.cause;
|
||||
};
|
||||
|
||||
@@ -102,6 +151,10 @@ interface StartCaptureRuntimeOptions {
|
||||
readonly hostEnvironment: Readonly<Record<string, string | undefined>>;
|
||||
readonly temporaryRoot: string;
|
||||
readonly runId: string;
|
||||
readonly ownershipBaseline: ProcessOwnershipBaseline;
|
||||
readonly cleanupHost?: ProcessCaptureCleanupHost;
|
||||
readonly observationBuffer: ProcessCaptureObservationBuffer;
|
||||
readonly onSpawn: (pid: number) => void;
|
||||
readonly frames: TerminalFrame[];
|
||||
readonly samples: ProcessSample[];
|
||||
readonly interactions: InteractionEvent[];
|
||||
@@ -119,6 +172,10 @@ const startCaptureRuntime = async (
|
||||
let terminal: IPty | undefined;
|
||||
try {
|
||||
const { spawn } = await import("@lydell/node-pty");
|
||||
const selectedExecutable = await observeSelectedExecutable(
|
||||
scenario.executable,
|
||||
options.signal,
|
||||
);
|
||||
const startedAt = new Date();
|
||||
const started = Date.now();
|
||||
let lastOutput = started;
|
||||
@@ -135,6 +192,15 @@ const startCaptureRuntime = async (
|
||||
rows: scenario.terminal.rows,
|
||||
name: "xterm-256color",
|
||||
});
|
||||
options.onSpawn(terminal.pid);
|
||||
options.observationBuffer.target_pid = {
|
||||
state: "available",
|
||||
value: scenario.normalization.pids ? 1 : terminal.pid,
|
||||
};
|
||||
const executableIdentity = observeLaunchedExecutable(
|
||||
scenario.executable,
|
||||
selectedExecutable,
|
||||
);
|
||||
const framesTruncated = captureTerminalFrames({
|
||||
...options,
|
||||
terminal,
|
||||
@@ -161,6 +227,7 @@ const startCaptureRuntime = async (
|
||||
terminal,
|
||||
started,
|
||||
startedAt,
|
||||
executableIdentity,
|
||||
lastOutput: () => lastOutput,
|
||||
framesTruncated,
|
||||
stopSampler,
|
||||
@@ -172,6 +239,12 @@ const startCaptureRuntime = async (
|
||||
terminal,
|
||||
renderer,
|
||||
runId: options.runId,
|
||||
captureBaseline: options.ownershipBaseline,
|
||||
scenario,
|
||||
...(options.cleanupHost === undefined
|
||||
? {}
|
||||
: { cleanupHost: options.cleanupHost }),
|
||||
observations: options.observationBuffer,
|
||||
temporaryRoot: options.temporaryRoot,
|
||||
});
|
||||
}
|
||||
@@ -181,27 +254,36 @@ const finishProcessRun = async (options: {
|
||||
readonly runtime: StartedCaptureRuntime | undefined;
|
||||
readonly timers: Set<ProcessTimer>;
|
||||
readonly runId: string;
|
||||
readonly captureBaseline: ProcessOwnershipBaseline;
|
||||
readonly cleanupHost?: ProcessCaptureCleanupHost;
|
||||
readonly temporaryRoot: string;
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly samples: readonly ProcessSample[];
|
||||
readonly stopSampler: () => Promise<{ readonly partial: boolean }>;
|
||||
readonly capture: PendingProcessCapture | undefined;
|
||||
readonly executionFailure: unknown;
|
||||
readonly observations?: ProcessCaptureObservationBuffer;
|
||||
readonly rootPid?: number;
|
||||
}): Promise<ProcessCapture> => {
|
||||
await options.stopSampler();
|
||||
const cleanupFailure = await releaseProcessResources({
|
||||
const sampledProcessGroupIds =
|
||||
options.runtime === undefined
|
||||
? undefined
|
||||
: selectCapturedProcessGroupIds(
|
||||
options.runtime.terminal.pid,
|
||||
options.samples,
|
||||
).filter((groupId) => groupId !== options.runtime?.terminal.pid);
|
||||
const cleanup = await releaseProcessResources({
|
||||
timers: options.timers,
|
||||
terminal: options.runtime?.terminal,
|
||||
renderer: options.runtime?.renderer,
|
||||
runId: options.runId,
|
||||
captureBaseline: options.captureBaseline,
|
||||
...(sampledProcessGroupIds === undefined ? {} : { sampledProcessGroupIds }),
|
||||
temporaryRoot: options.temporaryRoot,
|
||||
capturedProcessGroupIds:
|
||||
options.runtime === undefined
|
||||
? []
|
||||
: selectCapturedProcessGroupIds(
|
||||
options.runtime.terminal.pid,
|
||||
options.samples,
|
||||
),
|
||||
...(options.cleanupHost === undefined ? {} : { host: options.cleanupHost }),
|
||||
});
|
||||
const cleanupFailure = cleanupReportFailure(cleanup);
|
||||
let { capture, executionFailure } = options;
|
||||
let verifiedCapture: ProcessCapture | undefined;
|
||||
if (capture !== undefined && cleanupFailure === undefined) {
|
||||
@@ -226,18 +308,28 @@ const finishProcessRun = async (options: {
|
||||
);
|
||||
}
|
||||
}
|
||||
const rootPid = options.runtime?.terminal.pid ?? options.rootPid;
|
||||
const partialContext =
|
||||
rootPid === undefined
|
||||
? { scenario: options.scenario }
|
||||
: { scenario: options.scenario, rootPid };
|
||||
return resolveProcessResult(
|
||||
verifiedCapture,
|
||||
cleanupFailure === undefined ? verifiedCapture : capture,
|
||||
executionFailure,
|
||||
cleanupFailure,
|
||||
cleanup,
|
||||
options.observations,
|
||||
partialContext,
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeCaptureFailure = (
|
||||
/** Preserve typed process-inspection preflight failures at the capture boundary. */
|
||||
export const normalizeCaptureFailure = (
|
||||
cause: unknown,
|
||||
signal: AbortSignal | undefined,
|
||||
): unknown => {
|
||||
if (cause instanceof ProcessCaptureError) return cause;
|
||||
if (cause instanceof DarwinProcessOwnershipInspectionError)
|
||||
return new ProcessCaptureError(cause.message, { cause });
|
||||
if (
|
||||
signal?.aborted === true &&
|
||||
(cause === signal.reason ||
|
||||
@@ -262,24 +354,72 @@ const completeCapture = async (options: {
|
||||
readonly captureSnapshot: typeof snapshotRoots;
|
||||
readonly initiallyTruncated: boolean;
|
||||
readonly eventJournal: readonly ProcessCaptureEventJournalEntry[];
|
||||
readonly observationBuffer: ProcessCaptureObservationBuffer;
|
||||
readonly recordEvent: RecordProcessCaptureEvent;
|
||||
}): Promise<PendingProcessCapture> => {
|
||||
const unavailableReason = (stage: string, cause: unknown): string =>
|
||||
`${stage} failed: ${cause instanceof Error ? cause.message : "unknown failure"}`;
|
||||
const { runtime, scenario } = options;
|
||||
const { reason } = options.exit;
|
||||
if (reason === "cancelled") throw processCaptureCancelled();
|
||||
const settlement = await observeSettlement(
|
||||
options.runId,
|
||||
selectCapturedProcessGroupIds(runtime.terminal.pid, options.samples),
|
||||
scenario.settle_ms,
|
||||
options.recordEvent,
|
||||
options.hostPlatform,
|
||||
);
|
||||
let settlement: Awaited<ReturnType<typeof observeSettlement>>;
|
||||
try {
|
||||
settlement = await observeSettlement(
|
||||
options.runId,
|
||||
selectCapturedProcessGroupIds(runtime.terminal.pid, options.samples),
|
||||
scenario.settle_ms,
|
||||
options.recordEvent,
|
||||
options.hostPlatform,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
options.observationBuffer.settlement = {
|
||||
state: "unavailable",
|
||||
reason: unavailableReason("Process settlement observation", cause),
|
||||
};
|
||||
throw cause;
|
||||
}
|
||||
options.observationBuffer.settlement = {
|
||||
state: "available",
|
||||
value: settlement,
|
||||
};
|
||||
const samplingPartial = (await runtime.stopSampler()).partial;
|
||||
await settleProcessCaptureJournal(options.eventJournal);
|
||||
assertNotCancelled(options.signal);
|
||||
const after = await options.captureSnapshot(scenario, options.signal);
|
||||
let after: Awaited<ReturnType<typeof snapshotRoots>>;
|
||||
try {
|
||||
after = await options.captureSnapshot(scenario, options.signal);
|
||||
} catch (cause: unknown) {
|
||||
options.observationBuffer.filesystem_snapshots = {
|
||||
...options.observationBuffer.filesystem_snapshots,
|
||||
after: {
|
||||
state: "unavailable",
|
||||
reason: unavailableReason("Final filesystem snapshot", cause),
|
||||
},
|
||||
};
|
||||
throw cause;
|
||||
}
|
||||
options.observationBuffer.filesystem_snapshots = {
|
||||
...options.observationBuffer.filesystem_snapshots,
|
||||
after: {
|
||||
state: "available",
|
||||
value: { files: after.files, truncated: after.truncated },
|
||||
},
|
||||
};
|
||||
options.recordEvent("filesystem_checkpoints", 1);
|
||||
const renderedFrames = await runtime.renderer.frames();
|
||||
let renderedFrames: Awaited<ReturnType<TerminalRenderer["frames"]>>;
|
||||
try {
|
||||
renderedFrames = await runtime.renderer.frames();
|
||||
} catch (cause: unknown) {
|
||||
options.observationBuffer.rendered_frames = {
|
||||
state: "unavailable",
|
||||
reason: unavailableReason("Rendered terminal frame collection", cause),
|
||||
};
|
||||
throw cause;
|
||||
}
|
||||
options.observationBuffer.rendered_frames = {
|
||||
state: "available",
|
||||
value: renderedFrames,
|
||||
};
|
||||
const checkpoints: UnverifiedProcessCapture["filesystem_checkpoints"] = [
|
||||
{
|
||||
name: "before",
|
||||
@@ -296,12 +436,23 @@ const completeCapture = async (options: {
|
||||
truncated: after.truncated,
|
||||
},
|
||||
];
|
||||
const manifest = await createRunManifest(
|
||||
scenario,
|
||||
runtime.startedAt,
|
||||
new Date(),
|
||||
{ platform: options.hostPlatform, architecture: process.arch },
|
||||
);
|
||||
let manifest: Awaited<ReturnType<typeof createRunManifest>>;
|
||||
try {
|
||||
manifest = await createRunManifest(
|
||||
scenario,
|
||||
runtime.startedAt,
|
||||
new Date(),
|
||||
runtime.executableIdentity,
|
||||
{ platform: options.hostPlatform, architecture: process.arch },
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
options.observationBuffer.manifest = {
|
||||
state: "unavailable",
|
||||
reason: unavailableReason("Capture manifest creation", cause),
|
||||
};
|
||||
throw cause;
|
||||
}
|
||||
options.observationBuffer.manifest = { state: "available", value: manifest };
|
||||
const truncated =
|
||||
options.initiallyTruncated ||
|
||||
after.truncated ||
|
||||
@@ -335,23 +486,29 @@ const runProcessScenario = async (
|
||||
hostEnvironment: Readonly<Record<string, string | undefined>> = process.env,
|
||||
hostPlatform: NodeJS.Platform = process.platform,
|
||||
captureSnapshot: typeof snapshotRoots = snapshotRoots,
|
||||
cleanupHost?: ProcessCaptureCleanupHost,
|
||||
): Promise<ProcessCapture> => {
|
||||
const { temporaryRoot, runId, before } = await prepareProcessCapture(
|
||||
scenario,
|
||||
signal,
|
||||
captureSnapshot,
|
||||
);
|
||||
const { temporaryRoot, runId, ownershipBaseline, before } =
|
||||
await prepareProcessCapture(scenario, signal, captureSnapshot);
|
||||
const frames: TerminalFrame[] = [];
|
||||
const samples: ProcessSample[] = [];
|
||||
const interactions: InteractionEvent[] = [];
|
||||
const journal = createProcessCaptureJournal();
|
||||
const { entries: eventJournal, recordEvent } = journal;
|
||||
recordEvent("filesystem_checkpoints", 0);
|
||||
const observations = createProcessCaptureObservationBuffer({
|
||||
frames,
|
||||
interactions,
|
||||
samples,
|
||||
eventJournal,
|
||||
before,
|
||||
});
|
||||
let runtime: StartedCaptureRuntime | undefined;
|
||||
let actualRootPid: number | undefined;
|
||||
const timers = new Set<ProcessTimer>();
|
||||
let capture: PendingProcessCapture | undefined;
|
||||
let executionFailure: unknown;
|
||||
let stopSampler = async () => ({ partial: false });
|
||||
const interactions: InteractionEvent[] = [];
|
||||
const dispatchedEventIndexes = new Set<number>();
|
||||
|
||||
try {
|
||||
@@ -360,6 +517,12 @@ const runProcessScenario = async (
|
||||
hostEnvironment,
|
||||
temporaryRoot,
|
||||
runId,
|
||||
ownershipBaseline,
|
||||
...(cleanupHost === undefined ? {} : { cleanupHost }),
|
||||
observationBuffer: observations,
|
||||
onSpawn: (pid) => {
|
||||
actualRootPid = pid;
|
||||
},
|
||||
frames,
|
||||
samples,
|
||||
interactions,
|
||||
@@ -380,6 +543,15 @@ const runProcessScenario = async (
|
||||
dispatchedEventIndexes,
|
||||
recordEvent,
|
||||
});
|
||||
observations.exit = {
|
||||
state: "available",
|
||||
value: {
|
||||
code:
|
||||
exit.reason === "exited" && exit.exitCode >= 0 ? exit.exitCode : null,
|
||||
signal: exit.signal ?? null,
|
||||
reason: exit.reason,
|
||||
},
|
||||
};
|
||||
capture = await completeCapture({
|
||||
scenario,
|
||||
hostPlatform,
|
||||
@@ -393,6 +565,7 @@ const runProcessScenario = async (
|
||||
exit,
|
||||
initiallyTruncated: before.truncated,
|
||||
eventJournal,
|
||||
observationBuffer: observations,
|
||||
recordEvent,
|
||||
captureSnapshot,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
@@ -400,16 +573,44 @@ const runProcessScenario = async (
|
||||
} catch (cause: unknown) {
|
||||
runtime?.terminal.kill("SIGKILL");
|
||||
executionFailure = normalizeCaptureFailure(cause, signal);
|
||||
if (
|
||||
runtime !== undefined &&
|
||||
observations.rendered_frames.state === "unavailable" &&
|
||||
observations.rendered_frames.reason.startsWith(
|
||||
"Rendered terminal frames were not collected",
|
||||
)
|
||||
) {
|
||||
try {
|
||||
observations.rendered_frames = {
|
||||
state: "available",
|
||||
value: await runtime.renderer.frames(),
|
||||
};
|
||||
} catch (renderCause: unknown) {
|
||||
const renderFailure =
|
||||
renderCause instanceof Error
|
||||
? renderCause.message
|
||||
: "unknown failure";
|
||||
observations.rendered_frames = {
|
||||
state: "unavailable",
|
||||
reason: `Rendered terminal frame collection failed: ${renderFailure}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return finishProcessRun({
|
||||
runtime,
|
||||
timers,
|
||||
runId,
|
||||
captureBaseline: ownershipBaseline,
|
||||
...(cleanupHost === undefined ? {} : { cleanupHost }),
|
||||
temporaryRoot,
|
||||
scenario,
|
||||
samples,
|
||||
stopSampler,
|
||||
capture,
|
||||
executionFailure,
|
||||
observations,
|
||||
...(actualRootPid === undefined ? {} : { rootPid: actualRootPid }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -420,6 +621,7 @@ export const captureProcessScenario = async (
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
environment: Readonly<Record<string, string | undefined>> = process.env,
|
||||
captureSnapshot: typeof snapshotRoots = snapshotRoots,
|
||||
cleanupHost?: ProcessCaptureCleanupHost,
|
||||
): Promise<Result<ProcessCapture, ProcessCaptureError | AnalysisError>> => {
|
||||
const ownershipReason = processCaptureOwnershipUnavailableReason(platform);
|
||||
if (ownershipReason !== undefined)
|
||||
@@ -444,14 +646,22 @@ export const captureProcessScenario = async (
|
||||
environment,
|
||||
platform,
|
||||
captureSnapshot,
|
||||
cleanupHost,
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
const failure = normalizeCaptureFailure(cause, signal);
|
||||
const executionFailureReason =
|
||||
describeProcessCaptureExecutionFailure(cause);
|
||||
return err(
|
||||
failure instanceof ProcessCaptureError
|
||||
? failure
|
||||
: new ProcessCaptureError("process capture failed", { cause }),
|
||||
: new ProcessCaptureError("process capture failed", {
|
||||
cause,
|
||||
...(executionFailureReason === undefined
|
||||
? {}
|
||||
: { executionFailure: executionFailureReason }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user