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:
morluto authored and GitHub committed 2026-10-08 04:46:46 +08:00
1 parent 63ba7424c0
commit 63cf28fd8f
51 files changed
+5506 -316

No files matched your search

+361
View File
@@ -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();
},
);
});
+171
View File
@@ -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
View File
@@ -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;
}
};
+204 -26
View File
@@ -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);
+6
View File
@@ -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", {
+503 -35
View File
@@ -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 }),
};
+293 -3
View File
@@ -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",
+251 -41
View File
@@ -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 }),
}),
);
}
};