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

+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;
}
};