fix(process): retain native ownership evidence through cleanup

Keep a run-scoped evidence owner from acquisition through settlement and
cleanup retry. Bind token observations to native generations and retain
related generations and unresolved identity lookups across table omissions,
reparenting and process-group changes without treating PID hints as authority.

Use native effective UID facts for withholding decisions, corroborate Linux
procfs absence, and share the Linux stat parser with sampling and debugger
identity checks. Signal only after current token and group revalidation.
Complete swept cleanup across all groups with one bounded final family check;
retain unresolved owners and successful-peer completion across retries.

Validation: npm run check, docs:check and test discovery passed. Focused
ownership/consumer coverage totals 513 passed and 1 skipped across 35 files,
including the corrected Electron cancellation test rerun independently. Darwin helper compilation, real
owned Node child cleanup, CLI/MCP capture and retained cleanup retries ran.
Real Electron applications, native Linux races/PID reuse and Windows cleanup
were not qualified. The original Linux reports remain open for that work.

Fixes #5238
Refs #4322, #5206
This commit is contained in:
morluto committed 2026-10-19 20:04:00 +08:00
1 parent 0fc8462be1
commit 49e67aa1d9
47 files changed
+5262 -1173

No files matched your search

+65 -7
View File
@@ -12,16 +12,29 @@ struct ProcessIdentityObservation: Encodable {
let pid: Int32
let state: String
let identity: String?
let effective_uid: UInt32?
let lifecycle: String?
let relation: ProcessRelationObservation?
let reason: String?
}
private struct ProcessMetadata {
struct ProcessRelationObservation: Encodable {
let state: String
let parent_pid: Int32
let process_group_id: Int32
}
struct ProcessMetadata {
let identity: String
let effectiveUid: UInt32
let lifecycle: String
let parentPid: Int32
let processGroupId: Int32
let pointerSize: Int
}
struct ProcessSnapshot {
fileprivate let metadata: [Int32: ProcessMetadata]
let metadata: [Int32: ProcessMetadata]
let failure: String?
}
@@ -75,6 +88,10 @@ func readProcessSnapshot(
process.kp_proc.p_pid,
ProcessMetadata(
identity: "\(start.tv_sec):\(start.tv_usec)",
effectiveUid: process.kp_eproc.e_ucred.cr_uid,
lifecycle: process.kp_proc.p_stat == SZOMB ? "zombie" : "non-zombie",
parentPid: process.kp_eproc.e_ppid,
processGroupId: process.kp_eproc.e_pgid,
pointerSize: process.kp_proc.p_flag & P_LP64 != 0 ? 8 : 4
)
)
@@ -87,20 +104,61 @@ func readProcessSnapshot(
}
func readProcessIdentities(pids: [Int32]) -> [ProcessIdentityObservation] {
let snapshot = readProcessSnapshot()
readProcessIdentities(pids: pids, snapshot: readProcessSnapshot())
}
func readProcessIdentities(
pids: [Int32],
snapshot: ProcessSnapshot
) -> [ProcessIdentityObservation] {
return pids.map { pid in
guard pid > 0 else {
return unavailableIdentity(pid: pid, reason: "invalid_process_id")
}
guard let metadata = snapshot.metadata[pid] else {
return unavailableIdentity(
if let failure = snapshot.failure {
return unavailableIdentity(pid: pid, reason: failure)
}
// This records absence from this successful KERN_PROC_ALL snapshot only.
// Unlike the Linux procfs path, it has no PID-specific kill(0)
// corroboration; callers must not treat it as universal or future exit
// proof.
return ProcessIdentityObservation(
pid: pid,
reason: snapshot.failure ?? "process_not_in_snapshot"
state: "absent",
identity: nil,
effective_uid: nil,
lifecycle: nil,
relation: nil,
reason: "not-in-snapshot"
)
}
return ProcessIdentityObservation(pid: pid, state: "readable", identity: metadata.identity, reason: nil)
return ProcessIdentityObservation(
pid: pid,
state: "readable",
identity: metadata.identity,
effective_uid: metadata.effectiveUid,
lifecycle: metadata.lifecycle,
relation: ProcessRelationObservation(
state: "observed",
parent_pid: metadata.parentPid,
process_group_id: metadata.processGroupId
),
reason: nil
)
}
}
private func unavailableIdentity(pid: Int32, reason: String) -> ProcessIdentityObservation {
ProcessIdentityObservation(pid: pid, state: "unavailable", identity: nil, reason: reason)
ProcessIdentityObservation(
pid: pid,
state: "unavailable",
identity: nil,
effective_uid: nil,
lifecycle: nil,
relation: nil,
reason: reason
)
}
enum RunTokenReadError: Error {
+8
View File
@@ -459,6 +459,14 @@ Each filesystem checkpoint starts with a fresh `file_bytes` budget across all
selected roots; it is not a per-file allowance. File contents are read in
64 KiB chunks for hashing and are not embedded in capture Evidence.
Ownership inspections bind run-token reads to native start identities and retain
related generations through reparenting, group changes, omitted rows, and cleanup
retries. The captured run keeps one evidence owner through settlement and cleanup.
An absent or changed token leaves a previously related generation unresolved;
current token proof is still required before signaling. Swept cleanup verifies the
remaining family after its group signals. Coverage is bounded by the native
snapshots and generations inspected; sampled group IDs select candidates.
When the host withholds an unrelated process’s ownership token, REA leaves that
process untouched and records its PID and reason in `cleanup.unverified_processes`
and process residual unknowns. Successful cleanup verifies the owned group;
+9
View File
@@ -251,6 +251,15 @@ lineage followed by a confirmed passive family census. Windows controls require
a fresh lineage observation and do not signal cached descendant PIDs after root
termination. Simulated Windows controls do not establish native Windows behavior.
Native process-ownership controls distinguish start identity, zombie lifecycle,
current parent/group relation, and token-read evidence. Retain generation-bound
candidates through smaller lineage observations, table omissions, reparenting,
group changes, and failed cleanup retries. A readable token with unavailable
identity stays unresolved. Native UID facts, rather than an earlier process-table
row, determine whether a token denial concerns another user. Darwin tests compile
the helper and exercise snapshot growth, absence, lifecycle, and UID observations;
controlled Linux procfs tests do not establish real Linux exit or PID-reuse behavior.
For atomic writes, exercise cancellation on both sides of the documented commit
point and preserve any publication receipt. A late interruption does not prove
that no output was published.
@@ -37,9 +37,12 @@ it.skipIf(process.platform === "win32")(
await once(spawned.process.stdout!, "data");
const lineage = await observeOwnedProcessLineage(spawned.ownership);
expect(lineage.status).toBe("verified");
const evidenceOwner = spawned.ownership.evidenceOwner;
if (evidenceOwner === undefined)
throw new Error("owned spawn omitted persistent ownership evidence");
const owner = new ElectronCaptureCleanup(
{ close },
spawned.ownership,
{ ...spawned.ownership, evidenceOwner },
lineage,
);
expect(await owner.release(scope)).toMatchObject({
+19 -14
View File
@@ -1,4 +1,4 @@
import { afterEach, expect, it, vi } from "vitest";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { createDeferred } from "../../tests/fixtures/binarySession.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
@@ -7,33 +7,38 @@ import * as ownershipObservation from "../process/ProcessOwnershipObservation.js
import * as processCleanup from "./ElectronProcessCleanup.js";
import * as processOwnership from "../process/ProcessOwnership.js";
import type {
OwnedProcessGroup,
PersistentOwnedProcessGroup,
ProcessLineageObservation,
} from "../process/ProcessOwnership.js";
import { ProcessOwnershipEvidenceOwner } from "../process/ProcessOwnershipEvidence.js";
import { ElectronCaptureCleanup } from "./ElectronCaptureCleanup.js";
const ownership: OwnedProcessGroup = {
runId: "authored-run-token",
leaderPid: 424242,
processGroupId: 424242,
};
let ownership: PersistentOwnedProcessGroup;
beforeEach(() => {
ownership = {
runId: "authored-run-token",
leaderPid: 424242,
processGroupId: 424242,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
};
});
const verified: ProcessLineageObservation = {
status: "verified",
observedAt: "2026-10-17T00:00:00Z",
lineage: {
runId: ownership.runId,
launcherPid: ownership.leaderPid,
runId: "authored-run-token",
launcherPid: 424242,
launcherParentPid: 1,
processGroupId: ownership.processGroupId,
processGroupId: 424242,
descendants: [],
},
};
const unavailable: ProcessLineageObservation = {
status: "unavailable",
observedAt: "2026-10-17T00:00:01Z",
runId: ownership.runId,
launcherPid: ownership.leaderPid,
processGroupId: ownership.processGroupId,
runId: "authored-run-token",
launcherPid: 424242,
processGroupId: 424242,
reason: "authored identity unavailable",
};
afterEach(() => {
@@ -311,7 +316,7 @@ it("retains a Windows family left after root termination without signaling cache
{
pid: 424243,
parentPid: ownership.leaderPid,
processGroupId: ownership.processGroupId,
processGroupId: 424242,
},
],
},
+10 -8
View File
@@ -8,7 +8,7 @@ import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.j
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { observeOwnedProcessLineage } from "../process/ProcessOwnershipObservation.js";
import type {
OwnedProcessGroup,
PersistentOwnedProcessGroup,
ProcessCleanupResult,
ProcessLineageObservation,
} from "../process/ProcessOwnership.js";
@@ -21,15 +21,17 @@ type ElectronApplication = Awaited<ReturnType<typeof electron.launch>>;
export class ElectronCaptureCleanup {
readonly #transport: AnalysisCleanupLease;
readonly #processes: AnalysisCleanupLease;
readonly #ownership: PersistentOwnedProcessGroup | undefined;
#closeOperation: Promise<void> | undefined;
#lineage: ProcessLineageObservation | undefined;
#processResult: ProcessCleanupResult | undefined;
constructor(
application: Pick<ElectronApplication, "close">,
ownership: OwnedProcessGroup | undefined,
ownership: PersistentOwnedProcessGroup | undefined,
lineage: ProcessLineageObservation | undefined,
) {
this.#ownership = ownership;
this.#lineage = lineage;
this.#transport = new AnalysisCleanupLease(async () => {
// A deadline stops this wait, not the underlying Playwright close. Reuse
@@ -49,21 +51,21 @@ export class ElectronCaptureCleanup {
);
}, ["browser_transport"]);
const resources =
ownership === undefined
this.#ownership === undefined
? ["owned_process_group"]
: [
"owned_process_group",
`run:${ownership.runId}`,
`process-group:${String(ownership.processGroupId)}`,
`run:${this.#ownership.runId}`,
`process-group:${String(this.#ownership.processGroupId)}`,
];
this.#processes = new AnalysisCleanupLease(async () => {
const cleanup =
ownership === undefined
this.#ownership === undefined
? {
cleaned: false as const,
reason: "owned Electron process identity was unavailable",
}
: await this.#cleanupProcesses(ownership);
: await this.#cleanupProcesses(this.#ownership);
this.#processResult = cleanup;
if (!cleanup.cleaned)
throw new ArtifactReaderFailure("unavailable", cleanup.reason, {
@@ -103,7 +105,7 @@ export class ElectronCaptureCleanup {
}
async #cleanupProcesses(
ownership: OwnedProcessGroup,
ownership: PersistentOwnedProcessGroup,
): Promise<ProcessCleanupResult> {
const current = await observeOwnedProcessLineage(ownership);
if (current.status === "verified") this.#lineage = current;
+378
View File
@@ -0,0 +1,378 @@
import { describe, expect, it, vi } from "vitest";
import type {
OwnedProcessGroup,
ProcessLineageObservation,
ProcessOwnershipHost,
ProcessTableEntry,
} from "../process/ProcessOwnership.js";
import { ProcessOwnershipEvidenceOwner } from "../process/ProcessOwnershipEvidence.js";
import { cleanupElectronProcesses } from "./ElectronProcessCleanup.js";
import { observeOwnedProcessLineage } from "../process/ProcessOwnershipObservation.js";
describe.skipIf(process.platform === "win32")(
"Electron cleanup ownership evidence",
() => {
it("keeps a previously token-owned helper unresolved after it reparents and changes groups", async () => {
let rows: readonly ProcessTableEntry[] = [
{
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "electron",
},
{
pid: 101,
parentPid: 100,
processGroupId: 100,
state: "S",
command: "electron-helper",
},
];
let withholdToken = false;
const evidenceOwner = new ProcessOwnershipEvidenceOwner();
const ownership: OwnedProcessGroup = {
runId: "electron-run",
leaderPid: 100,
processGroupId: 100,
expectedParentPid: 1,
expectedCommand: "electron",
sweepTokenOwnedProcesses: true,
evidenceOwner,
};
const host: ProcessOwnershipHost = {
platform: process.platform,
listProcesses: () => Promise.resolve(rows),
environment: () => Promise.resolve({}),
runTokens: (members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
withholdToken
? {
state: "unavailable" as const,
reason: "platform_binary_environment_withheld",
}
: { state: "readable" as const, runId: "electron-run" },
]),
),
),
processIdentities: (members) =>
Promise.resolve(
new Map(
members.map((member) => [
member.pid,
withholdToken && member.pid === 100
? {
state: "absent" as const,
reason:
process.platform === "darwin"
? ("not-in-snapshot" as const)
: ("not-found-in-caller-pid-namespace" as const),
}
: {
state: "readable" as const,
identity: `start-${String(member.pid)}`,
lifecycle: {
state: "observed" as const,
lifecycle: "non-zombie" as const,
},
relation: {
state: "observed" as const,
parentPid: member.parentPid,
processGroupId: member.processGroupId,
},
},
]),
),
),
signalGroup: vi.fn(() => {
rows = [
{
pid: 101,
parentPid: 1,
processGroupId: 200,
state: "S",
command: "electron-helper",
},
];
withholdToken = true;
}),
};
const lineage: ProcessLineageObservation = {
status: "verified",
observedAt: "2026-10-19T00:00:00.000Z",
lineage: {
runId: ownership.runId,
launcherPid: ownership.leaderPid,
launcherParentPid: 1,
processGroupId: ownership.processGroupId,
descendants: [{ pid: 101, parentPid: 100, processGroupId: 100 }],
},
};
const result = await cleanupElectronProcesses(ownership, lineage, {
platform: process.platform,
terminateTree: vi.fn(),
processOwnershipHost: host,
});
expect(result.cleaned).toBe(false);
if (result.cleaned)
throw new Error("expected cleanup to remain unresolved");
expect(result.reason).toContain(
"process ownership token could not be read",
);
expect(evidenceOwner.candidates).toMatchObject([
{
pid: 101,
identity: "start-101",
applicability: "token-verified",
scopeEvidence: "run-token",
relation: { parentPid: 1, processGroupId: 200 },
},
]);
});
},
);
it.each([
{ peerFails: false, withholdToken: true },
{ peerFails: true, withholdToken: true },
{ peerFails: false, withholdToken: false },
])(
"retains detached lineage evidence (initial peer failure: $peerFails, withheld token: $withholdToken)",
async ({ peerFails, withholdToken }) => {
let detached = false;
const launcher: ProcessTableEntry = {
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "electron",
};
const child: ProcessTableEntry = {
pid: 101,
parentPid: 100,
processGroupId: 100,
state: "S",
command: "helper",
};
const evidenceOwner = new ProcessOwnershipEvidenceOwner();
const ownership = {
runId: "lineage-run",
leaderPid: 100,
processGroupId: 100,
sweepTokenOwnedProcesses: true as const,
evidenceOwner,
};
let launcherExited = false;
let childExited = false;
const signalGroup = vi.fn((groupId: number) => {
if (groupId === 100) launcherExited = true;
if (groupId === 200) childExited = true;
});
const host: ProcessOwnershipHost = {
listProcesses: async () => [
...(launcherExited ? [] : [launcher]),
...(childExited
? []
: [
detached
? { ...child, parentPid: 1, processGroupId: 200 }
: child,
]),
...(!detached && peerFails ? [{ ...child, pid: 102 }] : []),
],
environment: async () => ({}),
runTokens: async (members) =>
new Map(
members.map(({ pid }) => [
pid,
(detached && withholdToken && pid === 101) ||
(peerFails && pid === 102)
? {
state: "unavailable" as const,
reason: "platform_binary_environment_withheld",
}
: { state: "readable" as const, runId: ownership.runId },
]),
),
processIdentities: async (members) =>
new Map(
members.map(({ pid }) => [
pid,
(launcherExited && pid === 100) ||
(childExited && pid === 101) ||
(detached && pid === 102)
? { state: "absent" as const, reason: "not-in-snapshot" as const }
: {
state: "readable" as const,
identity: `generation-${pid}`,
lifecycle: {
state: "observed" as const,
lifecycle: "non-zombie" as const,
},
relation: {
state: "observed" as const,
parentPid: pid === 100 || detached ? 1 : 100,
processGroupId: detached && pid === 101 ? 200 : 100,
},
},
]),
),
signalGroup,
};
const first = await observeOwnedProcessLineage(ownership, host);
expect(first.status).toBe(peerFails ? "unavailable" : "verified");
expect(evidenceOwner.candidates).toContainEqual(
expect.objectContaining({
pid: 101,
identity: "generation-101",
applicability: "token-verified",
}),
);
detached = true;
const current = await observeOwnedProcessLineage(ownership, host);
expect(current).toMatchObject({
status: "verified",
lineage: { descendants: [] },
});
const result = await cleanupElectronProcesses(ownership, current, {
platform: "darwin",
terminateTree: vi.fn(),
processOwnershipHost: host,
});
if (withholdToken) {
expect(result).toMatchObject({ cleaned: false });
expect(signalGroup).not.toHaveBeenCalledWith(200, expect.anything());
expect(evidenceOwner.candidates).toContainEqual(
expect.objectContaining({
pid: 101,
identity: "generation-101",
applicability: "token-verified",
}),
);
} else {
expect(result).toMatchObject({ cleaned: true, signaled: true });
expect(signalGroup.mock.calls).toEqual([
[100, "SIGKILL"],
[200, "SIGKILL"],
]);
expect(evidenceOwner.candidates).toEqual([]);
}
},
);
it.skipIf(process.platform === "win32")(
"signals every verified captured group before the shared final census",
async () => {
let rows: readonly ProcessTableEntry[] = [
{
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "electron",
},
{
pid: 101,
parentPid: 100,
processGroupId: 100,
state: "S",
command: "renderer",
},
{
pid: 200,
parentPid: 101,
processGroupId: 200,
state: "S",
command: "helper-group-leader",
},
{
pid: 201,
parentPid: 200,
processGroupId: 200,
state: "S",
command: "helper",
},
];
const signalGroups: number[] = [];
const ownership = {
runId: "multi-group-run",
leaderPid: 100,
processGroupId: 100,
expectedParentPid: 1,
sweepTokenOwnedProcesses: true as const,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
};
const host: ProcessOwnershipHost = {
platform: process.platform,
listProcesses: async () => rows,
environment: async () => ({}),
runTokens: async (members) =>
new Map(
members.map(({ pid }) => [
pid,
{ state: "readable" as const, runId: ownership.runId },
]),
),
processIdentities: async (members) => {
const liveByPid = new Map(rows.map((row) => [row.pid, row]));
return new Map(
members.map((member) => {
const current = liveByPid.get(member.pid);
return [
member.pid,
current === undefined
? {
state: "absent" as const,
reason: "not-in-snapshot" as const,
}
: {
state: "readable" as const,
identity: `generation-${String(member.pid)}`,
relation: {
state: "observed" as const,
parentPid: current.parentPid,
processGroupId: current.processGroupId,
},
},
] as const;
}),
);
},
signalGroup: (groupId) => {
signalGroups.push(groupId);
rows = rows.filter(({ processGroupId }) => processGroupId !== groupId);
},
};
const lineage: ProcessLineageObservation = {
status: "verified",
observedAt: "2026-10-19T00:00:00.000Z",
lineage: {
runId: ownership.runId,
launcherPid: ownership.leaderPid,
launcherParentPid: 1,
processGroupId: ownership.processGroupId,
descendants: [
{ pid: 101, parentPid: 100, processGroupId: 100 },
{ pid: 200, parentPid: 101, processGroupId: 200 },
{ pid: 201, parentPid: 200, processGroupId: 200 },
],
},
};
const result = await cleanupElectronProcesses(ownership, lineage, {
platform: process.platform,
terminateTree: vi.fn(),
processOwnershipHost: host,
});
expect(result).toMatchObject({ cleaned: true, signaled: true });
expect(signalGroups).toEqual([100, 200]);
expect(rows).toEqual([]);
},
);
+35 -27
View File
@@ -3,22 +3,27 @@ import {
cleanupWindowsProcessTree,
verifyNoTokenOwnedProcesses,
type OwnedProcessGroup,
type PersistentOwnedProcessGroup,
type ProcessCleanupResult,
type ProcessLineageObservation,
type OwnershipSweepUnverifiedProcess,
type ProcessOwnershipHost,
} from "../process/ProcessOwnership.js";
import { systemProcessOwnershipHost } from "../process/ProcessOwnershipObservation.js";
import { selectCapturedProcessGroupIds } from "../process/ProcessOwnershipProcessTree.js";
/** Clean up an Electron launch. Windows signals a process only after lineage is verified. */
export const cleanupElectronProcesses = async (
ownership: OwnedProcessGroup,
ownership: PersistentOwnedProcessGroup,
lineage: ProcessLineageObservation | undefined,
host: {
readonly platform: NodeJS.Platform;
readonly terminateTree: typeof cleanupWindowsProcessTree;
readonly processOwnershipHost?: ProcessOwnershipHost;
} = {
platform: process.platform,
terminateTree: cleanupWindowsProcessTree,
processOwnershipHost: systemProcessOwnershipHost,
},
): Promise<ProcessCleanupResult> => {
if (
@@ -36,9 +41,18 @@ export const cleanupElectronProcesses = async (
const root =
host.platform === "win32"
? { cleaned: true as const, signaled: false }
: await cleanupOwnedProcessGroup(ownership);
: await cleanupOwnedProcessGroup(ownership, host.processOwnershipHost);
if (!root.cleaned) return root;
const released = await verifyFamilyRelease(ownership, root);
if (
host.platform !== "win32" &&
ownership.sweepTokenOwnedProcesses === true
)
return root;
const released = await verifyFamilyRelease(
ownership,
root,
host.processOwnershipHost,
);
return released.cleaned
? released
: {
@@ -50,7 +64,9 @@ export const cleanupElectronProcesses = async (
// /t already includes descendants. Do not signal cached child PIDs again
// after awaiting root termination: they may have exited or been reused.
const root = await host.terminateTree(ownership.leaderPid);
return root.cleaned ? verifyFamilyRelease(ownership, root) : root;
return root.cleaned
? verifyFamilyRelease(ownership, root, host.processOwnershipHost)
: root;
}
const groupIds = selectCapturedProcessGroupIds(
ownership.leaderPid,
@@ -59,36 +75,27 @@ export const cleanupElectronProcesses = async (
process_group_id: processGroupId,
})),
);
let signaled = false;
const unverified: OwnershipSweepUnverifiedProcess[] = [];
for (const processGroupId of groupIds) {
const cleanupOwnership: OwnedProcessGroup =
processGroupId === ownership.processGroupId
? { ...ownership, leaderPid: processGroupId, processGroupId }
: {
runId: ownership.runId,
leaderPid: processGroupId,
processGroupId,
};
const result = await cleanupOwnedProcessGroup(cleanupOwnership);
if (!result.cleaned) return result;
signaled ||= result.signaled;
unverified.push(...(result.unverified ?? []));
}
return verifyFamilyRelease(ownership, {
cleaned: true,
signaled,
...(unverified.length === 0 ? {} : { unverified }),
});
const sampledProcessGroupIds = [
...new Set([...(ownership.sampledProcessGroupIds ?? []), ...groupIds]),
];
const root = await cleanupOwnedProcessGroup(
{ ...ownership, sampledProcessGroupIds },
host.processOwnershipHost,
);
if (!root.cleaned) return root;
return ownership.sweepTokenOwnedProcesses === true
? root
: verifyFamilyRelease(ownership, root, host.processOwnershipHost);
};
const verifyFamilyRelease = async (
ownership: OwnedProcessGroup,
ownership: PersistentOwnedProcessGroup,
root: Extract<ProcessCleanupResult, { readonly cleaned: true }>,
processHost: ProcessOwnershipHost | undefined,
): Promise<ProcessCleanupResult> => {
const remaining = await verifyNoTokenOwnedProcesses(
ownership.runId,
undefined,
processHost,
ownership.captureBaseline,
{
leaderPid: ownership.leaderPid,
@@ -96,6 +103,7 @@ const verifyFamilyRelease = async (
...(ownership.sampledProcessGroupIds === undefined
? {}
: { sampledProcessGroupIds: ownership.sampledProcessGroupIds }),
evidenceOwner: ownership.evidenceOwner,
},
);
const unverified = [
@@ -1,6 +1,6 @@
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { cleanupElectronProcesses } from "./ElectronProcessCleanup.js";
import { afterEach, expect, it, vi } from "vitest";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
@@ -11,10 +11,11 @@ import {
} from "../../tests/fixtures/electron/fuseImage.js";
import * as processOwnership from "../process/ProcessOwnership.js";
import { ProcessOwnershipEvidenceOwner } from "../process/ProcessOwnershipEvidence.js";
import * as ownershipObservation from "../process/ProcessOwnershipObservation.js";
import * as electronActions from "./PlaywrightElectronActiveActions.js";
import { electronActiveObservationInputSchema } from "../domain/javascript/electronActiveObservation.js";
import type { OwnedProcessGroup } from "../process/ProcessOwnership.js";
import type { PersistentOwnedProcessGroup } from "../process/ProcessOwnership.js";
import {
PlaywrightElectronActiveProvider,
safeElectronEnvironment,
@@ -75,12 +76,17 @@ it("passes required Windows runtime variables without inheriting unrelated value
});
});
const ownership = {
runId: "run",
leaderPid: 4242,
processGroupId: 4242,
expectedParentPid: 1,
} satisfies OwnedProcessGroup;
let ownership: PersistentOwnedProcessGroup;
beforeEach(() => {
ownership = {
runId: "run",
leaderPid: 4242,
processGroupId: 4242,
expectedParentPid: 1,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
};
});
const emptyHookSnapshot = () => ({
events: [],
@@ -694,8 +700,8 @@ it("cancels a completed capture when cancellation arrives during process cleanup
stubCompletedCapture();
const controller = new AbortController();
const reason = new Error("SIGTERM during process cleanup");
// The POSIX path ends with the token sweep; Windows ends with tree termination.
vi.spyOn(processOwnership, "verifyNoTokenOwnedProcesses").mockImplementation(
// POSIX cleanup owns the final sweep; Windows uses tree termination.
vi.spyOn(processOwnership, "cleanupOwnedProcessGroup").mockImplementation(
async () => {
controller.abort(reason);
return {
@@ -25,9 +25,10 @@ import { BrowserObservationError } from "../domain/browserObservationError.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { err, ok, type Result } from "../domain/result.js";
import type {
OwnedProcessGroup,
PersistentOwnedProcessGroup,
ProcessLineageObservation,
} from "../process/ProcessOwnership.js";
import { ProcessOwnershipEvidenceOwner } from "../process/ProcessOwnershipEvidence.js";
import {
observeOwnedProcessLineage,
prepareProcessOwnershipInspection,
@@ -108,7 +109,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
options: ExecutionOptions,
): Promise<Result<ElectronActiveObservationResult, AnalysisError>> {
let application: ElectronApplication | undefined;
let ownership: OwnedProcessGroup | undefined;
let ownership: PersistentOwnedProcessGroup | undefined;
let lineage: ProcessLineageObservation | undefined;
let capture: ElectronActiveObservationResult | undefined;
let paths: ElectronPaths | undefined;
@@ -182,6 +183,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
expectedParentPid: process.pid,
expectedCommand: paths.executable,
sweepTokenOwnedProcesses: true,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
...(captureBaseline === undefined ? {} : { captureBaseline }),
};
lineage = await observeOwnedProcessLineage(ownership);
+8 -3
View File
@@ -30,7 +30,7 @@ import {
type ProcessStartTimeReader,
verifyOwnedInferiorsEnded,
} from "../process/ownedDebuggerSession.js";
import { parseLinuxProcessStartTime } from "../process/ProcessOwnershipObservation.js";
import { parseLinuxProcessStat } from "../process/LinuxProcessStat.js";
const STARTUP_TIMEOUT_MS = 10_000;
const COMMAND_TIMEOUT_MS = 120_000;
@@ -387,7 +387,10 @@ const verifyGdbInferiorOutputSink = async (
"GDB cannot confirm live inferior output descriptors outside Linux /proc.",
);
const statPath = `/proc/${pid}/stat`;
const before = parseLinuxProcessStartTime(await readFile(statPath, "utf8"));
const expectedPid = Number(pid);
const beforeStat = parseLinuxProcessStat(await readFile(statPath, "utf8"));
const before =
beforeStat?.pid === expectedPid ? beforeStat.startTime : undefined;
if (before === undefined)
throw new Error("GDB could not establish the live inferior identity.");
const ttyPath = await realpath(selectedTty);
@@ -408,7 +411,9 @@ const verifyGdbInferiorOutputSink = async (
`GDB inferior descriptor ${descriptor} no longer points to the selected TTY.`,
);
}
const after = parseLinuxProcessStartTime(await readFile(statPath, "utf8"));
const afterStat = parseLinuxProcessStat(await readFile(statPath, "utf8"));
const after =
afterStat?.pid === expectedPid ? afterStat.startTime : undefined;
if (after !== before)
throw new Error(
"The GDB inferior identity changed during descriptor readback.",
@@ -8,6 +8,7 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js";
const command = vi.hoisted(() => {
let helperMode: "read" | "identities" = "read";
let identityAbsent = false;
let helperSessions: Array<{
readonly pid: number;
readonly failed: boolean;
@@ -30,8 +31,9 @@ const command = vi.hoisted(() => {
};
return {
configure(mode: "read" | "identities") {
configure(mode: "read" | "identities", absent = false) {
helperMode = mode;
identityAbsent = absent;
events.length = 0;
expectedHelpers = 1;
startedHelpers = 0;
@@ -100,11 +102,24 @@ const command = vi.hoisted(() => {
results: [
helperMode === "read"
? { pid: session.pid, state: "readable", run_id: "run" }
: {
pid: session.pid,
state: "readable",
identity: "start:456",
},
: identityAbsent
? {
pid: session.pid,
state: "absent",
identity: null,
reason: "not-in-snapshot",
}
: {
pid: session.pid,
state: "readable",
identity: "start:456",
lifecycle: "non-zombie",
relation: {
state: "observed",
parent_pid: 1,
process_group_id: 123,
},
},
],
});
return {
@@ -146,6 +161,37 @@ vi.mock("./ExecFileOutput.js", async (importOriginal) => {
import { createDarwinProcessRunTokenReader } from "./DarwinProcessRunTokenReader.js";
it("decodes a successful native snapshot absence as a distinct identity fact", async () => {
command.configure("identities", true);
const reader = createDarwinProcessRunTokenReader({
compiler: {
kind: "swiftc",
executable: join(tmpdir(), "fixture-swiftc"),
sdkRoot: tmpdir(),
},
environment: {},
});
try {
const operation = reader.identities([
{
pid: 123,
parentPid: 1,
processGroupId: 123,
state: "S",
command: "fixture",
},
]);
await command.waitUntilStarted();
command.release();
await expect(operation).resolves.toEqual(
new Map([[123, { state: "absent", reason: "not-in-snapshot" }]]),
);
} finally {
command.release();
await reader.close();
}
});
it.each(["read", "identities"] as const)(
"waits for an active helper %s before removing its executable root",
async (mode) => {
@@ -213,7 +259,16 @@ it.each(["read", "identities"] as const)(
123,
mode === "read"
? { state: "readable", runId: "run" }
: { state: "readable", identity: "start:456" },
: {
state: "readable",
identity: "start:456",
lifecycle: { state: "observed", lifecycle: "non-zombie" },
relation: {
state: "observed",
parentPid: 1,
processGroupId: 123,
},
},
],
]),
);
+42 -2
View File
@@ -46,10 +46,25 @@ const responseSchema = z.strictObject({
const identityResponseSchema = z.strictObject({
results: z.array(
z.discriminatedUnion("state", [
z.strictObject({
pid: z.number().int().positive(),
state: z.literal("absent"),
identity: z.null().optional(),
reason: z.literal("not-in-snapshot"),
}),
z.strictObject({
pid: z.number().int().positive(),
state: z.literal("readable"),
identity: z.string().min(1),
effective_uid: z.number().int().nonnegative().optional(),
lifecycle: z.enum(["non-zombie", "zombie"]).optional(),
relation: z
.strictObject({
state: z.literal("observed"),
parent_pid: z.number().int().nonnegative(),
process_group_id: z.number().int().nonnegative(),
})
.optional(),
reason: z.null().optional(),
}),
z.strictObject({
@@ -701,8 +716,33 @@ export const createDarwinProcessRunTokenReader = (
observations.set(
result.pid,
result.state === "readable"
? { state: "readable", identity: result.identity }
: { state: "unavailable", reason: result.reason },
? {
state: "readable",
identity: result.identity,
...(result.effective_uid === undefined
? {}
: { effectiveUid: result.effective_uid }),
...(result.lifecycle === undefined
? {}
: {
lifecycle: {
state: "observed" as const,
lifecycle: result.lifecycle,
},
}),
...(result.relation === undefined
? {}
: {
relation: {
state: "observed" as const,
parentPid: result.relation.parent_pid,
processGroupId: result.relation.process_group_id,
},
}),
}
: result.state === "absent"
? { state: "absent", reason: result.reason }
: { state: "unavailable", reason: result.reason },
);
}
if (observations.size !== requested.size)
+58
View File
@@ -0,0 +1,58 @@
import { expect, it } from "vitest";
import { parseLinuxProcessStat } from "./LinuxProcessStat.js";
const statRecord = (
options: {
readonly pid?: string;
readonly command?: string;
readonly state?: string;
readonly parentPid?: string;
readonly processGroupId?: string;
readonly sessionId?: string;
readonly startTime?: string;
} = {},
): string => {
const fields = [
options.state ?? "S",
options.parentPid ?? "17",
options.processGroupId ?? "18",
options.sessionId ?? "19",
];
while (fields.length < 19) fields.push("0");
fields.push(options.startTime ?? "987654");
return `${options.pid ?? "42"} (${options.command ?? "worker"}) ${fields.join(" ")}`;
};
it("parses command names containing parentheses and retains native relation facts", () => {
expect(
parseLinuxProcessStat(statRecord({ command: "worker ) old ) name" })),
).toEqual({
pid: 42,
startTime: "987654",
lifecycle: "non-zombie",
parentPid: 17,
processGroupId: 18,
sessionId: 19,
});
});
it("accepts zero parent, process-group, and session IDs as native facts", () => {
expect(
parseLinuxProcessStat(
statRecord({ parentPid: "0", processGroupId: "0", sessionId: "0" }),
),
).toMatchObject({ parentPid: 0, processGroupId: 0, sessionId: 0 });
});
it("distinguishes zombie lifecycle and rejects malformed or missing identity fields", () => {
expect(parseLinuxProcessStat(statRecord({ state: "Z" }))?.lifecycle).toBe(
"zombie",
);
expect(parseLinuxProcessStat("(worker) S 1 2 3")).toBeUndefined();
expect(parseLinuxProcessStat(statRecord({ pid: "0" }))).toBeUndefined();
expect(parseLinuxProcessStat(statRecord({ state: "??" }))).toBeUndefined();
expect(parseLinuxProcessStat(statRecord({ startTime: "not-a-number" }))).toBe(
undefined,
);
});
+69
View File
@@ -0,0 +1,69 @@
/** Native process facts parsed from one Linux `/proc/<pid>/stat` record. */
export interface LinuxProcessStat {
readonly pid: number;
readonly startTime: string;
readonly lifecycle: "non-zombie" | "zombie";
readonly parentPid: number;
readonly processGroupId: number;
readonly sessionId: number;
}
/**
* Parse the changing command field by its final `)`, then validate the native
* identity and relation fields. Zero is valid for the native group/session
* facts; callers may impose narrower admission rules for their own purpose.
*/
export const parseLinuxProcessStat = (
value: string,
): LinuxProcessStat | undefined => {
const commandStart = value.indexOf("(");
const commandEnd = value.lastIndexOf(")");
if (commandStart <= 0 || commandEnd <= commandStart) return undefined;
const pidText = value.slice(0, commandStart).trim();
if (!/^\d+$/u.test(pidText)) return undefined;
const pid = Number(pidText);
if (!Number.isSafeInteger(pid) || pid <= 0) return undefined;
const fields = value
.slice(commandEnd + 1)
.trim()
.split(/\s+/u);
const state = fields[0];
const parentPidText = fields[1];
const processGroupIdText = fields[2];
const sessionIdText = fields[3];
const startTime = fields[19];
if (
state === undefined ||
!/^[A-Za-z]$/u.test(state) ||
parentPidText === undefined ||
!/^\d+$/u.test(parentPidText) ||
processGroupIdText === undefined ||
!/^\d+$/u.test(processGroupIdText) ||
sessionIdText === undefined ||
!/^\d+$/u.test(sessionIdText) ||
startTime === undefined ||
!/^\d+$/u.test(startTime)
)
return undefined;
const parentPid = Number(parentPidText);
const processGroupId = Number(processGroupIdText);
const sessionId = Number(sessionIdText);
if (
!Number.isSafeInteger(parentPid) ||
!Number.isSafeInteger(processGroupId) ||
!Number.isSafeInteger(sessionId)
)
return undefined;
return {
pid,
startTime,
lifecycle: state === "Z" ? "zombie" : "non-zombie",
parentPid,
processGroupId,
sessionId,
};
};
@@ -0,0 +1,252 @@
import { afterEach, expect, it, vi } from "vitest";
import {
cleanupOwnedProcessGroup,
type ProcessOwnershipHost,
type ProcessTableEntry,
} from "./ProcessOwnership.js";
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
const initialRows: readonly ProcessTableEntry[] = [
{
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "capture-root",
},
{
pid: 101,
parentPid: 100,
processGroupId: 100,
state: "S",
command: "capture-child",
},
];
const makeHost = (onSignal: (groupId: number) => void) => {
let liveRows = initialRows;
const signalGroups: number[] = [];
const host: ProcessOwnershipHost = {
platform: "linux",
listProcesses: async () => liveRows,
environment: async () => ({}),
runTokens: async (entries) =>
new Map(
entries.map(({ pid }) => [
pid,
{
state: "readable" as const,
runId: "sweep-run",
},
]),
),
processIdentities: async (entries) => {
const liveByPid = new Map(liveRows.map((row) => [row.pid, row]));
return new Map(
entries.map((entry) => {
const current = liveByPid.get(entry.pid);
return [
entry.pid,
current === undefined
? {
state: "absent" as const,
reason: "not-found-in-caller-pid-namespace" as const,
}
: {
state: "readable" as const,
identity: `start-${String(entry.pid)}`,
lifecycle: {
state: "observed" as const,
lifecycle: "non-zombie" as const,
},
relation: {
state: "observed" as const,
parentPid: current.parentPid,
processGroupId: current.processGroupId,
},
},
] as const;
}),
);
},
signalGroup: (groupId) => {
signalGroups.push(groupId);
onSignal(groupId);
},
};
return {
host,
signalGroups,
replaceRows: (rows: readonly ProcessTableEntry[]) => {
liveRows = rows;
},
rows: () => liveRows,
};
};
const ownership = () => ({
runId: "sweep-run",
leaderPid: 100,
processGroupId: 100,
sweepTokenOwnedProcesses: true as const,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
});
afterEach(() => vi.useRealTimers());
it("waits for native absence after one signal and does not signal again", async () => {
vi.useFakeTimers();
const fixture = makeHost(() => {
setTimeout(() => fixture.replaceRows([]), 50);
});
const resultPromise = cleanupOwnedProcessGroup(ownership(), fixture.host);
await vi.runAllTimersAsync();
const result = await resultPromise;
expect(result).toEqual({ cleaned: true, signaled: true });
expect(fixture.signalGroups).toEqual([100]);
});
it("preserves a moved same-generation helper as unresolved through the final deadline", async () => {
const child = initialRows[1];
if (child === undefined) throw new Error("fixture child is missing");
let moved = false;
const fixture = makeHost(() => {
moved = true;
fixture.replaceRows([
{
...child,
parentPid: 1,
processGroupId: 777,
},
]);
});
const host: ProcessOwnershipHost = {
...fixture.host,
runTokens: async (entries) =>
new Map(
entries.map(({ pid }) => [
pid,
{
state: "readable" as const,
runId: pid === 101 && moved ? "foreign-token" : "sweep-run",
},
]),
),
};
const resultPromise = cleanupOwnedProcessGroup(ownership(), host);
const result = await resultPromise;
expect(result).toMatchObject({
cleaned: false,
reason: "process ownership token mismatch for PID(s): 101",
});
expect(fixture.rows()).toMatchObject([
{ pid: 101, parentPid: 1, processGroupId: 777 },
]);
expect(fixture.signalGroups).toEqual([100]);
});
it("retries a moved token-owned helper group without signaling a released peer again", async () => {
let rows: readonly ProcessTableEntry[] = [
{
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "capture-root",
},
{
pid: 200,
parentPid: 100,
processGroupId: 200,
state: "S",
command: "helper-group-leader",
},
{
pid: 201,
parentPid: 200,
processGroupId: 200,
state: "S",
command: "moving-helper",
},
{
pid: 202,
parentPid: 200,
processGroupId: 200,
state: "S",
command: "released-peer",
},
];
const signalGroups: number[] = [];
const owner = ownership();
const host: ProcessOwnershipHost = {
platform: "linux",
listProcesses: async () => rows,
environment: async () => ({}),
runTokens: async (entries) =>
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable" as const, runId: owner.runId },
]),
),
processIdentities: async (entries) => {
const liveByPid = new Map(rows.map((row) => [row.pid, row]));
return new Map(
entries.map((entry) => {
const current = liveByPid.get(entry.pid);
return [
entry.pid,
current === undefined
? {
state: "absent" as const,
reason: "not-found-in-caller-pid-namespace" as const,
}
: {
state: "readable" as const,
identity: `start-${String(entry.pid)}`,
lifecycle: {
state: "observed" as const,
lifecycle: "non-zombie" as const,
},
relation: {
state: "observed" as const,
parentPid: current.parentPid,
processGroupId: current.processGroupId,
},
},
] as const;
}),
);
},
signalGroup: (groupId) => {
signalGroups.push(groupId);
if (groupId === 100) {
rows = rows
.filter(({ pid }) => pid !== 100)
.map((row) =>
row.pid === 201
? { ...row, parentPid: 1, processGroupId: 300 }
: row,
);
} else {
rows = rows.filter(({ processGroupId }) => processGroupId !== groupId);
}
},
};
const first = await cleanupOwnedProcessGroup(owner, host);
expect(first).toMatchObject({ cleaned: false });
expect(signalGroups).toEqual([100, 200]);
expect(rows).toContainEqual(
expect.objectContaining({ pid: 201, parentPid: 1, processGroupId: 300 }),
);
const retry = await cleanupOwnedProcessGroup(owner, host);
expect(retry).toEqual({ cleaned: true, signaled: true });
expect(signalGroups).toEqual([100, 200, 300]);
expect(owner.evidenceOwner.candidates).toEqual([]);
});
+12 -6
View File
@@ -1,25 +1,31 @@
import { vi } from "vitest";
import type { ProcessOwnershipHost } from "./ProcessOwnership.js";
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
export const ownership = {
export const createOwnership = () => ({
runId: "run-token",
leaderPid: 100,
processGroupId: 100,
};
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
});
export const host = (
environments: Readonly<Record<number, Readonly<Record<string, string>>>>,
): {
readonly adapter: ProcessOwnershipHost;
readonly signalGroup: ReturnType<typeof vi.fn>;
} => {
const signalGroup = vi.fn();
const livePids = new Set(Object.keys(environments).map(Number));
const signalGroup = vi.fn((processGroupId: number) => {
if (processGroupId === 100) livePids.clear();
});
return {
adapter: {
listProcesses: () =>
Promise.resolve(
Object.keys(environments).map((pid) => ({
pid: Number(pid),
parentPid: Number(pid) === 100 ? 1 : 100,
[...livePids].map((pid) => ({
pid,
parentPid: pid === 100 ? 1 : 100,
processGroupId: 100,
state: "S",
command: "fixture",
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,79 @@
import { expect, it, vi } from "vitest";
import {
cleanupOwnedProcessGroup,
type ProcessIdentityObservation,
type ProcessOwnershipHost,
type ProcessTableEntry,
} from "./ProcessOwnership.js";
it.each(["zombie", "absent", "live", "unavailable"] as const)(
"rechecks native lifetime when a token disappears during cleanup: %s",
async (lifetimeAfterToken) => {
const row: ProcessTableEntry = {
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "fixture",
};
let tokenRead = false;
const identity = (): ProcessIdentityObservation => {
if (tokenRead && lifetimeAfterToken === "absent")
return { state: "absent", reason: "not-found-in-caller-pid-namespace" };
if (tokenRead && lifetimeAfterToken === "unavailable")
return { state: "unavailable", reason: "identity read denied" };
return {
state: "readable",
identity: "same-generation",
lifecycle: {
state: "observed",
lifecycle:
tokenRead && lifetimeAfterToken === "zombie"
? "zombie"
: "non-zombie",
},
relation: { state: "observed", parentPid: 1, processGroupId: 100 },
};
};
const signalGroup = vi.fn();
const host: ProcessOwnershipHost = {
platform: "linux",
listProcesses: async () => [row],
environment: async () => ({}),
runTokens: async (members) => {
tokenRead = true;
return new Map(
members.map(({ pid }) => [
pid,
{ state: "readable" as const, runId: undefined },
]),
);
},
processIdentities: async (members) =>
new Map(members.map(({ pid }) => [pid, identity()])),
signalGroup,
};
const result = await cleanupOwnedProcessGroup(
{ runId: "run", leaderPid: 100, processGroupId: 100 },
host,
);
expect(tokenRead).toBe(true);
if (lifetimeAfterToken === "zombie" || lifetimeAfterToken === "absent") {
expect(result).toEqual({ cleaned: true, signaled: false });
} else {
expect(result).toMatchObject({
cleaned: false,
failures: [
{
pid: 100,
reason:
lifetimeAfterToken === "live"
? "run-token-mismatch"
: "process-identity-unavailable",
},
],
});
}
expect(signalGroup).not.toHaveBeenCalled();
},
);
+415 -213
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanupOwnedProcessGroup,
type ProcessOwnershipHost,
@@ -7,7 +7,13 @@ import {
observeOwnedProcessGroups,
observeOwnedProcessLineage,
} from "./ProcessOwnershipObservation.js";
import { host, ownership } from "./ProcessOwnership.fixture.js";
import { createOwnership, host } from "./ProcessOwnership.fixture.js";
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
let ownership = createOwnership();
beforeEach(() => {
ownership = createOwnership();
});
const processes = [100, 101].map((pid) => ({
pid,
@@ -17,123 +23,211 @@ const processes = [100, 101].map((pid) => ({
command: "fixture",
}));
describe("multi-group ownership observation", () => {
it("observes multiple groups from one process snapshot and token batch", async () => {
const rows = [
...processes,
{
pid: 200,
parentPid: 1,
processGroupId: 200,
state: "S",
command: "other-owned-group",
},
{
pid: 300,
parentPid: 1,
processGroupId: 300,
state: "Z",
command: "[exited]",
},
];
const listProcesses = vi.fn(() => Promise.resolve(rows));
const environment = vi.fn(() => Promise.resolve({}));
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
(members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
{
state: "readable" as const,
runId: pid === 200 ? "other-run" : ownership.runId,
it("uses native same-generation group relations instead of stale table groups", async () => {
const rows = [
{ ...processes[0]!, processGroupId: 200 },
{ ...processes[1]!, pid: 200, parentPid: 1, processGroupId: 100 },
];
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve(rows),
environment: () => Promise.resolve({}),
runTokens: (members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
{ state: "readable" as const, runId: ownership.runId },
]),
),
),
processIdentities: (members) =>
Promise.resolve(
new Map(
members.map((member) => [
member.pid,
{
state: "readable" as const,
identity: `start-${String(member.pid)}`,
relation: {
state: "observed" as const,
parentPid: member.pid === 100 ? 1 : 100,
processGroupId: member.pid === 100 ? 100 : 200,
},
]),
),
},
]),
),
);
),
signalGroup: vi.fn(),
};
await expect(
observeOwnedProcessGroups(ownership.runId, [100, 200, 300], {
listProcesses,
environment,
runTokens,
signalGroup: vi.fn(),
}),
).resolves.toEqual(
new Map([
[100, { state: "alive" }],
[
200,
{ state: "unverifiable", reason: "process ownership did not match" },
],
[300, { state: "empty" }],
]),
);
expect(listProcesses).toHaveBeenCalledTimes(1);
expect(runTokens).toHaveBeenCalledTimes(1);
expect(runTokens).toHaveBeenCalledWith(
rows.filter(({ state }) => state !== "Z"),
);
expect(environment).not.toHaveBeenCalled();
});
await expect(
observeOwnedProcessGroups(ownership.runId, [100, 200], host),
).resolves.toEqual(
new Map([
[100, { state: "alive" }],
[200, { state: "alive" }],
]),
);
});
it("rechecks unavailable members individually before classifying their groups", async () => {
const rows = [
...processes,
{
pid: 200,
parentPid: 1,
processGroupId: 200,
state: "S",
command: "unreadable-group",
},
];
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce(rows)
.mockResolvedValueOnce(rows.filter(({ pid }) => pid !== 101))
.mockResolvedValueOnce(rows);
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
(members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
pid === 101 || pid === 200
? {
state: "unavailable" as const,
reason: `unreadable-${String(pid)}`,
}
: { state: "readable" as const, runId: ownership.runId },
]),
),
it("does not report a native zombie as a live owned group member", async () => {
const row = processes[0]!;
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([row]),
environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
runTokens: (members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
{ state: "readable" as const, runId: ownership.runId },
]),
),
);
),
processIdentities: (members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
{
state: "readable" as const,
identity: `start-${String(pid)}`,
lifecycle: {
state: "observed" as const,
lifecycle: "zombie" as const,
},
relation: {
state: "observed" as const,
parentPid: 1,
processGroupId: 100,
},
},
]),
),
),
signalGroup: vi.fn(),
};
await expect(
observeOwnedProcessGroups(ownership.runId, [100, 200], {
listProcesses,
environment: vi.fn(() => Promise.resolve({})),
runTokens,
signalGroup: vi.fn(),
}),
).resolves.toEqual(
new Map([
[100, { state: "alive" }],
[
200,
{
state: "unverifiable",
reason:
"process ownership could not be revalidated for PID 200: unreadable-200",
},
],
]),
);
expect(listProcesses).toHaveBeenCalledTimes(3);
expect(runTokens).toHaveBeenCalledTimes(1);
});
await expect(
observeOwnedProcessGroups(ownership.runId, [100], host),
).resolves.toEqual(new Map([[100, { state: "empty" }]]));
});
it("observes multiple groups from one process snapshot and token batch", async () => {
const rows = [
...processes,
{
pid: 200,
parentPid: 1,
processGroupId: 200,
state: "S",
command: "other-owned-group",
},
{
pid: 300,
parentPid: 1,
processGroupId: 300,
state: "Z",
command: "[exited]",
},
];
const listProcesses = vi.fn(() => Promise.resolve(rows));
const environment = vi.fn(() => Promise.resolve({}));
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
(members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
{
state: "readable" as const,
runId: pid === 200 ? "other-run" : ownership.runId,
},
]),
),
),
);
await expect(
observeOwnedProcessGroups(ownership.runId, [100, 200, 300], {
listProcesses,
environment,
runTokens,
signalGroup: vi.fn(),
}),
).resolves.toEqual(
new Map([
[100, { state: "alive" }],
[
200,
{ state: "unverifiable", reason: "process ownership did not match" },
],
[300, { state: "empty" }],
]),
);
expect(listProcesses).toHaveBeenCalledTimes(1);
expect(runTokens).toHaveBeenCalledTimes(1);
expect(runTokens).toHaveBeenCalledWith(
rows.filter(({ state }) => state !== "Z"),
);
expect(environment).not.toHaveBeenCalled();
});
it("rechecks unavailable members individually before classifying their groups", async () => {
const rows = [
...processes,
{
pid: 200,
parentPid: 1,
processGroupId: 200,
state: "S",
command: "unreadable-group",
},
];
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce(rows)
.mockResolvedValueOnce(rows.filter(({ pid }) => pid !== 101))
.mockResolvedValueOnce(rows);
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
(members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
pid === 101 || pid === 200
? {
state: "unavailable" as const,
reason: `unreadable-${String(pid)}`,
}
: { state: "readable" as const, runId: ownership.runId },
]),
),
),
);
await expect(
observeOwnedProcessGroups(ownership.runId, [100, 200], {
listProcesses,
environment: vi.fn(() => Promise.resolve({})),
runTokens,
signalGroup: vi.fn(),
}),
).resolves.toEqual(
new Map([
[100, { state: "alive" }],
[
200,
{
state: "unverifiable",
reason:
"process ownership could not be revalidated for PID 200: unreadable-200",
},
],
]),
);
expect(listProcesses).toHaveBeenCalledTimes(3);
expect(runTokens).toHaveBeenCalledTimes(1);
});
describe("multi-group ownership observation cancellation", () => {
@@ -279,115 +373,159 @@ describe("multi-group observation short-circuit", () => {
});
});
describe("batched ownership observation", () => {
it("observes group and lineage with one batch each and falls back only for missing rows", async () => {
const { adapter } = host({
100: { REA_PROCESS_RUN_ID: ownership.runId },
101: { REA_PROCESS_RUN_ID: ownership.runId },
});
const environment = vi.fn(adapter.environment);
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
() =>
Promise.resolve(
new Map([[100, { state: "readable", runId: ownership.runId }]]),
it("builds lineage from same-generation native parent/group facts", async () => {
const rows = [
{ ...processes[0]!, pid: 100, parentPid: 1, processGroupId: 100 },
{ ...processes[1]!, pid: 101, parentPid: 1, processGroupId: 200 },
];
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve(rows),
environment: () => Promise.resolve({}),
runTokens: (members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
{ state: "readable" as const, runId: ownership.runId },
]),
),
),
processIdentities: (members) =>
Promise.resolve(
new Map(
members.map((member) => [
member.pid,
{
state: "readable" as const,
identity: `start-${String(member.pid)}`,
relation: {
state: "observed" as const,
parentPid: member.pid === 100 ? 1 : 100,
processGroupId: member.pid === 100 ? 100 : 200,
},
},
]),
),
),
signalGroup: vi.fn(),
};
await expect(
observeOwnedProcessLineage(ownership, host),
).resolves.toMatchObject({
status: "verified",
lineage: {
launcherParentPid: 1,
processGroupId: 100,
descendants: [{ pid: 101, parentPid: 100, processGroupId: 200 }],
},
});
});
it("observes group and lineage with one batch each and falls back only for missing rows", async () => {
const { adapter } = host({
100: { REA_PROCESS_RUN_ID: ownership.runId },
101: { REA_PROCESS_RUN_ID: ownership.runId },
});
const environment = vi.fn(adapter.environment);
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(() =>
Promise.resolve(
new Map([[100, { state: "readable", runId: ownership.runId }]]),
),
);
const observedHost = { ...adapter, environment, runTokens };
await expect(
observeOwnedProcessGroups(
ownership.runId,
[ownership.processGroupId],
observedHost,
),
).resolves.toEqual(new Map([[ownership.processGroupId, { state: "alive" }]]));
await expect(
observeOwnedProcessLineage(ownership, observedHost),
).resolves.toMatchObject({
status: "verified",
lineage: {
descendants: [{ pid: 101, parentPid: 100, processGroupId: 100 }],
},
});
expect(runTokens.mock.calls).toEqual([[processes], [processes]]);
expect(environment.mock.calls).toEqual([[101], [101]]);
});
it.each(["batch", "environment"] as const)(
"preserves cancellation during %s reads, including successful completion races",
async (boundary) => {
const controller = new AbortController();
const reason = new Error("cancelled ownership read");
const listProcesses = vi.fn(() => Promise.resolve(processes));
const environment = vi.fn<ProcessOwnershipHost["environment"]>(
async (_pid, signal) => {
expect(signal).toBe(controller.signal);
controller.abort(reason);
return { REA_PROCESS_RUN_ID: ownership.runId };
},
);
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
async (_processes, signal) => {
expect(signal).toBe(controller.signal);
if (boundary === "batch") controller.abort(reason);
return new Map();
},
);
const observedHost = { ...adapter, environment, runTokens };
await expect(
observeOwnedProcessGroups(
ownership.runId,
[ownership.processGroupId],
observedHost,
{ listProcesses, environment, runTokens, signalGroup: vi.fn() },
controller.signal,
),
).resolves.toEqual(
new Map([[ownership.processGroupId, { state: "alive" }]]),
).rejects.toBe(reason);
expect(listProcesses).toHaveBeenCalledTimes(1);
expect(environment).toHaveBeenCalledTimes(boundary === "batch" ? 0 : 1);
},
);
it.each([100, 101])(
"rechecks unavailable batch rows for exited PID %i without overriding them through fallback",
async (exitedPid) => {
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce(processes)
.mockResolvedValue(processes.filter(({ pid }) => pid !== exitedPid));
const environment = vi.fn(() =>
Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
);
await expect(
observeOwnedProcessLineage(ownership, observedHost),
).resolves.toMatchObject({
status: "verified",
lineage: {
descendants: [{ pid: 101, parentPid: 100, processGroupId: 100 }],
},
});
expect(runTokens.mock.calls).toEqual([[processes], [processes]]);
expect(environment.mock.calls).toEqual([[101], [101]]);
});
it.each(["batch", "environment"] as const)(
"preserves cancellation during %s reads, including successful completion races",
async (boundary) => {
const controller = new AbortController();
const reason = new Error("cancelled ownership read");
const listProcesses = vi.fn(() => Promise.resolve(processes));
const environment = vi.fn<ProcessOwnershipHost["environment"]>(
async (_pid, signal) => {
expect(signal).toBe(controller.signal);
controller.abort(reason);
return { REA_PROCESS_RUN_ID: ownership.runId };
},
);
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
async (_processes, signal) => {
expect(signal).toBe(controller.signal);
if (boundary === "batch") controller.abort(reason);
return new Map();
},
);
await expect(
observeOwnedProcessGroups(
ownership.runId,
[ownership.processGroupId],
{ listProcesses, environment, runTokens, signalGroup: vi.fn() },
controller.signal,
),
).rejects.toBe(reason);
expect(listProcesses).toHaveBeenCalledTimes(1);
expect(environment).toHaveBeenCalledTimes(boundary === "batch" ? 0 : 1);
},
);
it.each([100, 101])(
"rechecks unavailable batch rows for exited PID %i without overriding them through fallback",
async (exitedPid) => {
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce(processes)
.mockResolvedValue(processes.filter(({ pid }) => pid !== exitedPid));
const environment = vi.fn(() =>
Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
);
const observedHost: ProcessOwnershipHost = {
listProcesses,
environment,
signalGroup: vi.fn(),
runTokens: () =>
Promise.resolve(
new Map(
processes.map(({ pid }) => [
pid,
pid === exitedPid
? { state: "unavailable" as const, reason: "process exited" }
: { state: "readable" as const, runId: ownership.runId },
]),
),
const observedHost: ProcessOwnershipHost = {
listProcesses,
environment,
signalGroup: vi.fn(),
runTokens: () =>
Promise.resolve(
new Map(
processes.map(({ pid }) => [
pid,
pid === exitedPid
? { state: "unavailable" as const, reason: "process exited" }
: { state: "readable" as const, runId: ownership.runId },
]),
),
};
const result = await observeOwnedProcessLineage(ownership, observedHost);
if (exitedPid === 100)
expect(result).toMatchObject({
status: "unavailable",
reason: "owned launcher exited during lineage validation",
});
else
expect(result).toMatchObject({
status: "verified",
lineage: { descendants: [] },
});
expect(environment).not.toHaveBeenCalled();
},
);
});
),
};
const result = await observeOwnedProcessLineage(ownership, observedHost);
if (exitedPid === 100)
expect(result).toMatchObject({
status: "unavailable",
reason: "owned launcher exited during lineage validation",
});
else
expect(result).toMatchObject({
status: "verified",
lineage: { descendants: [] },
});
expect(environment).not.toHaveBeenCalled();
},
);
describe("token sweep batch failures", () => {
it("cleans token-owned groups when a failed batch has readable fallback observations", async () => {
@@ -436,3 +574,67 @@ describe("token sweep batch failures", () => {
expect(signalGroup).not.toHaveBeenCalled();
});
});
it.each(["parent", "group"] as const)(
"keeps a changed native launcher %s relation unavailable instead of publishing the earlier relation",
async (changedRelation) => {
const evidenceOwner = new ProcessOwnershipEvidenceOwner();
const launcher = {
pid: 100,
parentPid: 20,
processGroupId: 100,
state: "S",
command: "fixture",
};
let tokenRead = false;
const currentRelation = () => ({
state: "observed" as const,
parentPid: tokenRead && changedRelation === "parent" ? 1 : 20,
processGroupId: tokenRead && changedRelation === "group" ? 200 : 100,
});
const host: ProcessOwnershipHost = {
listProcesses: async () => [launcher],
environment: async () => ({}),
runTokens: async (members) => {
tokenRead = true;
return new Map(
members.map(({ pid }) => [
pid,
{ state: "readable" as const, runId: "run" },
]),
);
},
processIdentities: async (members) =>
new Map(
members.map(({ pid }) => [
pid,
{
state: "readable" as const,
identity: "same-launcher-generation",
relation: currentRelation(),
},
]),
),
signalGroup: vi.fn(),
};
const result = await observeOwnedProcessLineage(
{
runId: "run",
leaderPid: 100,
processGroupId: 100,
expectedParentPid: 20,
evidenceOwner,
},
host,
);
expect(result).toMatchObject({ status: "unavailable" });
expect(evidenceOwner.candidates).toMatchObject([
{
pid: 100,
applicability: "token-verified",
relation: currentRelation(),
},
]);
expect(host.signalGroup).not.toHaveBeenCalled();
},
);
+36 -27
View File
@@ -1,39 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanupOwnedProcessGroup,
verifyNoTokenOwnedProcesses,
type ProcessOwnershipHost,
} from "./ProcessOwnership.js";
import { observeOwnedProcessLineage } from "./ProcessOwnershipObservation.js";
import { host, ownership } from "./ProcessOwnership.fixture.js";
import { createOwnership, host } from "./ProcessOwnership.fixture.js";
let ownership = createOwnership();
beforeEach(() => {
ownership = createOwnership();
});
describe("owned process-group cleanup discovery", () => {
it("signals token-owned groups that were reparented outside the launcher tree", async () => {
const signalGroup = vi.fn();
const processes = [
{
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "fixture",
},
{
pid: 900,
parentPid: 1,
processGroupId: 900,
state: "S",
command: "detached-helper",
},
{
pid: 901,
parentPid: 900,
processGroupId: 900,
state: "S",
command: "detached-worker",
},
];
const signalGroup = vi.fn((processGroupId: number) => {
for (let index = processes.length - 1; index >= 0; index -= 1)
if (processes[index]?.processGroupId === processGroupId)
processes.splice(index, 1);
});
const adapter: ProcessOwnershipHost = {
listProcesses: () =>
Promise.resolve([
{
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "fixture",
},
{
pid: 900,
parentPid: 1,
processGroupId: 900,
state: "S",
command: "detached-helper",
},
{
pid: 901,
parentPid: 900,
processGroupId: 900,
state: "S",
command: "detached-worker",
},
]),
listProcesses: () => Promise.resolve(processes),
environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
signalGroup,
};
@@ -0,0 +1,148 @@
import { expect, it, vi } from "vitest";
const filesystem = vi.hoisted(() => ({
readFile: vi.fn(),
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs/promises")>();
return { ...original, readFile: filesystem.readFile };
});
import { createSystemProcessOwnershipHost } from "./ProcessOwnershipObservation.js";
import type { ProcessTableEntry } from "./ProcessOwnership.js";
const entry: ProcessTableEntry = {
pid: 42,
parentPid: 1,
processGroupId: 42,
state: "S",
command: "fixture",
};
const filesystemError = (code: string): NodeJS.ErrnoException =>
Object.assign(new Error(code), { code });
it("reports absence only when /proc lookup and signal-zero both report ESRCH", async () => {
const host = createSystemProcessOwnershipHost("linux", {});
const signalProbe = vi.spyOn(process, "kill").mockImplementation(() => {
throw filesystemError("ESRCH");
});
try {
for (const code of ["ENOENT", "ESRCH"]) {
filesystem.readFile.mockRejectedValueOnce(filesystemError(code));
await expect(host.processIdentities?.([entry])).resolves.toEqual(
new Map([
[
42,
{
state: "absent",
reason: "not-found-in-caller-pid-namespace",
},
],
]),
);
}
expect(signalProbe).toHaveBeenCalledTimes(2);
expect(signalProbe).toHaveBeenNthCalledWith(1, 42, 0);
} finally {
signalProbe.mockRestore();
}
});
it("keeps hidden or permission-denied live PIDs unavailable", async () => {
const host = createSystemProcessOwnershipHost("linux", {});
const signalProbe = vi.spyOn(process, "kill");
try {
filesystem.readFile.mockRejectedValueOnce(filesystemError("ENOENT"));
signalProbe.mockReturnValueOnce(true);
await expect(host.processIdentities?.([entry])).resolves.toEqual(
new Map([
[
42,
{
state: "unavailable",
reason: "proc_stat_missing_but_process_signal_probe_succeeded",
},
],
]),
);
filesystem.readFile.mockRejectedValueOnce(filesystemError("ENOENT"));
signalProbe.mockImplementationOnce(() => {
throw filesystemError("EPERM");
});
const denied = await host.processIdentities?.([entry]);
const deniedIdentity = denied?.get(42);
expect(deniedIdentity).toMatchObject({ state: "unavailable" });
expect(
deniedIdentity?.state === "unavailable" ? deniedIdentity.reason : "",
).toContain("EPERM");
} finally {
signalProbe.mockRestore();
}
});
it("keeps permission, parser, and unexpected failures unavailable", async () => {
const host = createSystemProcessOwnershipHost("linux", {});
const signalProbe = vi.spyOn(process, "kill");
try {
for (const code of ["EACCES", "EPERM", "EIO"]) {
filesystem.readFile.mockRejectedValueOnce(filesystemError(code));
const result = await host.processIdentities?.([entry]);
expect(result?.get(42)?.state).toBe("unavailable");
}
filesystem.readFile.mockResolvedValueOnce("42 (fixture) S malformed");
await expect(host.processIdentities?.([entry])).resolves.toEqual(
new Map([[42, { state: "unavailable", reason: "malformed_proc_stat" }]]),
);
expect(signalProbe).not.toHaveBeenCalled();
} finally {
signalProbe.mockRestore();
}
});
it("rejects invalid PIDs before procfs lookup or signal probing", async () => {
const host = createSystemProcessOwnershipHost("linux", {});
const signalProbe = vi.spyOn(process, "kill");
filesystem.readFile.mockClear();
try {
await expect(
host.processIdentities?.([{ ...entry, pid: 0 }]),
).resolves.toEqual(
new Map([[0, { state: "unavailable", reason: "invalid_process_id" }]]),
);
expect(filesystem.readFile).not.toHaveBeenCalled();
expect(signalProbe).not.toHaveBeenCalled();
} finally {
signalProbe.mockRestore();
}
});
it("preserves cancellation before and after the signal-zero probe", async () => {
const host = createSystemProcessOwnershipHost("linux", {});
const controller = new AbortController();
const reason = new Error("cancelled");
controller.abort(reason);
filesystem.readFile.mockRejectedValueOnce(
Object.assign(new Error("aborted"), { name: "AbortError" }),
);
await expect(
host.processIdentities?.([entry], controller.signal),
).rejects.toMatchObject({ name: "AbortError" });
const afterProbe = new AbortController();
const probeAbort = vi.spyOn(process, "kill").mockImplementation(() => {
afterProbe.abort(reason);
throw filesystemError("ESRCH");
});
try {
filesystem.readFile.mockRejectedValueOnce(filesystemError("ENOENT"));
await expect(
host.processIdentities?.([entry], afterProbe.signal),
).rejects.toBe(reason);
} finally {
probeAbort.mockRestore();
}
});
+53 -33
View File
@@ -1,12 +1,37 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanupOwnedProcessGroup,
type ProcessIdentityObservation,
type ProcessOwnershipHost,
type ProcessTableEntry,
} from "./ProcessOwnership.js";
import { parseProcessEnvironment } from "./ProcessOwnershipObservation.js";
import { selectCapturedProcessGroupIds } from "./ProcessOwnershipProcessTree.js";
import { matchesOwnedProcessCommand } from "./ProcessCommandIdentity.js";
import { host, ownership } from "./ProcessOwnership.fixture.js";
import { createOwnership, host } from "./ProcessOwnership.fixture.js";
let ownership = createOwnership();
beforeEach(() => {
ownership = createOwnership();
});
const identitiesWithRelations = (
processes: readonly ProcessTableEntry[],
): ReadonlyMap<number, ProcessIdentityObservation> =>
new Map(
processes.map((entry) => [
entry.pid,
{
state: "readable" as const,
identity: `start-${String(entry.pid)}`,
relation: {
state: "observed" as const,
parentPid: entry.parentPid,
processGroupId: entry.processGroupId,
},
},
]),
);
describe("owned process-group cleanup discovery", () => {
it("excludes sampled groups whose leader was outside the captured tree", () => {
expect(
@@ -88,13 +113,11 @@ describe("opaque process neighbors during cleanup", () => {
command: "/usr/bin/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 signalGroup = vi.fn((processGroupId: number) => {
for (let index = processes.length - 1; index >= 0; index -= 1)
if (processes[index]?.processGroupId === processGroupId)
processes.splice(index, 1);
});
const adapter: ProcessOwnershipHost = {
platform: "darwin",
listProcesses: () => Promise.resolve(processes),
@@ -102,7 +125,23 @@ describe("opaque process neighbors during cleanup", () => {
Promise.resolve({
REA_PROCESS_RUN_ID: pid === 900 ? "unowned" : "run-token",
}),
processIdentities: () => Promise.resolve(identities),
processIdentities: (entries) =>
Promise.resolve(
new Map(
entries.map((entry) => {
const current = processes.find(({ pid }) => pid === entry.pid);
return [
entry.pid,
current === undefined
? {
state: "absent" as const,
reason: "not-in-snapshot" as const,
}
: identitiesWithRelations([current]).get(entry.pid)!,
];
}),
),
),
runTokens: (members) =>
Promise.resolve(
new Map(
@@ -154,12 +193,7 @@ describe("opaque process neighbors during cleanup", () => {
},
].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 identities = identitiesWithRelations(processes);
const adapter: ProcessOwnershipHost = {
platform: "darwin",
listProcesses: () => Promise.resolve(processes),
@@ -282,13 +316,7 @@ describe("related sweep candidates during cleanup", () => {
let childReads = 0;
const adapter: ProcessOwnershipHost = {
listProcesses: async () => processes,
processIdentities: async (members) =>
new Map(
members.map(({ pid }) => [
pid,
{ state: "readable" as const, identity: `start-${String(pid)}` },
]),
),
processIdentities: async (members) => identitiesWithRelations(members),
environment: async () => ({}),
runTokens: async (members) =>
new Map(
@@ -416,12 +444,7 @@ describe("withheld process evidence during cleanup", () => {
},
];
const signalGroup = vi.fn();
const identities = new Map(
processes.map(({ pid }) => [
pid,
{ state: "readable" as const, identity: `start-${String(pid)}` },
]),
);
const identities = identitiesWithRelations(processes);
const adapter: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve(processes),
environment: (pid) =>
@@ -484,10 +507,7 @@ describe("sampled detached process groups during cleanup", () => {
},
];
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 identities = identitiesWithRelations(processes);
const adapter: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve(processes),
environment: (pid) =>
+577 -156
View File
@@ -1,19 +1,26 @@
import { execFile } from "node:child_process";
import { performance } from "node:perf_hooks";
import { promisify } from "node:util";
import {
errorMessage,
readNativeProcessIdentityMap,
readProcessOwnershipFacts,
systemProcessOwnershipHost as systemHost,
} from "./ProcessOwnershipObservation.js";
import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js";
import { descendantsOf, liveProcesses } from "./ProcessOwnershipProcessTree.js";
import { readProcessRunTokens } from "./ProcessRunTokenObservations.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
const execFileAsync = promisify(execFile);
/** Identity proof required before REA may signal an owned process group. */
export interface OwnedProcessGroup {
const PROCESS_CLEANUP_VERIFICATION_GRACE_MS = 1_000;
const PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS = 25;
/** Shared fields for a process group acquired by a resource owner. */
interface OwnedProcessGroupFacts {
readonly runId: string;
readonly leaderPid: number;
readonly processGroupId: number;
@@ -21,8 +28,6 @@ export interface OwnedProcessGroup {
readonly expectedCommand?: string;
/** Expected launcher parent, checked only while the leader exists. */
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;
/**
@@ -32,6 +37,27 @@ export interface OwnedProcessGroup {
readonly sampledProcessGroupIds?: readonly number[];
}
/** Identity proof required before REA may signal an owned process group.
* Token-swept resources must carry one ledger for the whole resource lifetime.
*/
export type OwnedProcessGroup = OwnedProcessGroupFacts &
(
| {
/** Scan all live processes for this run token during cleanup. */
readonly sweepTokenOwnedProcesses: true;
readonly evidenceOwner: ProcessOwnershipEvidenceOwner;
}
| {
readonly sweepTokenOwnedProcesses?: false;
readonly evidenceOwner?: ProcessOwnershipEvidenceOwner;
}
);
/** Process-group authority retained across retries and final verification. */
export type PersistentOwnedProcessGroup = OwnedProcessGroup & {
readonly evidenceOwner: ProcessOwnershipEvidenceOwner;
};
/** Capture-local baseline; null means the PID identity could not be established. */
export type ProcessOwnershipBaseline = readonly {
readonly pid: number;
@@ -51,9 +77,57 @@ export interface ProcessTableEntry {
/** Native process start identity; unavailable identities must never be skipped. */
export type ProcessIdentityObservation =
| { readonly state: "readable"; readonly identity: string }
| {
readonly state: "readable";
readonly identity: string;
/** Effective UID from the same native observation, when that boundary reports it. */
readonly effectiveUid?: number;
/** Kept separate from the start identity; optional for older/custom hosts. */
readonly lifecycle?: ProcessLifecycleObservation;
/** Native relation facts read with this identity, never copied from a process-table row. */
readonly relation?: ProcessNativeRelationObservation;
}
| {
readonly state: "absent";
/**
* Bounded native evidence, not global liveness: Linux corroborates
* procfs absence with kill(pid, 0) in this PID namespace; Darwin reports
* absence from one successful KERN_PROC_ALL snapshot only.
*/
readonly reason: "not-found-in-caller-pid-namespace" | "not-in-snapshot";
}
| { readonly state: "unavailable"; readonly reason: string };
/** Native lifecycle fact observed alongside (but independent of) start identity. */
export type ProcessLifecycleObservation =
| {
readonly state: "observed";
readonly lifecycle: "non-zombie" | "zombie";
}
| { readonly state: "unavailable"; readonly reason: string };
/** Parent and process-group membership from the same native identity read. */
export type ProcessNativeRelationObservation =
| {
readonly state: "observed";
readonly parentPid: number;
readonly processGroupId: number;
}
| { readonly state: "unavailable"; readonly reason: string };
/** One caller-supplied process row validated across identity/token/identity reads. */
export interface ProcessOwnershipFact {
/** Caller-supplied candidate row; its parent/group may predate this read. */
readonly process: ProcessTableEntry;
/** Identity relation only; token ownership still requires matching readable runId. */
readonly state: "same-generation" | "replaced" | "absent" | "unavailable";
readonly identityBefore: ProcessIdentityObservation;
/** Preserve the raw token result; it is not attributable across a replaced generation. */
readonly token: ProcessRunTokenObservation;
/** Current membership is only this read's native relation, when available. */
readonly identityAfter: ProcessIdentityObservation;
}
/** Token-verified process lineage retained for one owned provider run. */
export interface OwnedProcessLineage {
readonly runId: string;
@@ -149,6 +223,7 @@ export interface OwnershipSweepRelation {
readonly leaderPid: number;
readonly processGroupId: number;
readonly sampledProcessGroupIds?: readonly number[];
readonly evidenceOwner: ProcessOwnershipEvidenceOwner;
}
/** Cleanup outcome with per-member diagnostics when ownership is uncertain. */
@@ -227,14 +302,22 @@ export const readProcessRunId = async (
): Promise<string | undefined> =>
(await host.environment(pid, signal)).REA_PROCESS_RUN_ID;
/** Keep unrelated-process uncertainty attached to every outcome of this cleanup attempt. */
const withUnverifiedObservations = (
result: ProcessCleanupFailure,
/** Merge duplicate uncertainty rows from discovery and final verification. */
const mergeUnverifiedObservations = (
result: ProcessCleanupResult,
unverified: readonly OwnershipSweepUnverifiedProcess[],
): ProcessCleanupFailure =>
unverified.length === 0 || (result.unverified?.length ?? 0) > 0
? result
: { ...result, unverified };
): ProcessCleanupResult => {
if (unverified.length === 0) return result;
const merged = [
...new Map(
[...(result.unverified ?? []), ...unverified].map((item) => [
JSON.stringify([item.pid, item.diagnostic]),
item,
]),
).values(),
];
return { ...result, unverified: merged };
};
/** Token-validate every rooted POSIX process group before signaling any. */
export const cleanupOwnedProcessGroup = async (
@@ -261,16 +344,8 @@ export const cleanupOwnedProcessGroup = async (
host,
plan.tokenOwnedIdentities,
);
if ("cleaned" in revalidation) {
if (!revalidation.cleaned)
return withUnverifiedObservations(revalidation, plan.unverified);
return {
...revalidation,
...(revalidation.unverified?.length || plan.unverified.length === 0
? {}
: { unverified: plan.unverified }),
};
}
if ("cleaned" in revalidation)
return mergeUnverifiedObservations(revalidation, plan.unverified);
if (revalidation.empty) continue;
try {
host.signalGroup(processGroupId, "SIGKILL");
@@ -279,7 +354,7 @@ export const cleanupOwnedProcessGroup = async (
const code =
cause instanceof Error && "code" in cause ? cause.code : undefined;
if (code !== "ESRCH")
return withUnverifiedObservations(
return mergeUnverifiedObservations(
{
cleaned: false,
reason: `owned process group signal failed: ${errorMessage(cause)}`,
@@ -290,6 +365,57 @@ export const cleanupOwnedProcessGroup = async (
}
if (plan.unresolved.length > 0)
return cleanupValidationFailure(plan.unresolved, plan.unverified);
if (ownership.sweepTokenOwnedProcesses === true) {
let verification: ProcessCleanupResult;
try {
const relation = {
leaderPid: ownership.leaderPid,
processGroupId: ownership.processGroupId,
evidenceOwner: ownership.evidenceOwner,
...(ownership.sampledProcessGroupIds === undefined
? {}
: { sampledProcessGroupIds: ownership.sampledProcessGroupIds }),
};
const deadline =
performance.now() + PROCESS_CLEANUP_VERIFICATION_GRACE_MS;
verification = await verifyNoTokenOwnedProcesses(
ownership.runId,
host,
ownership.captureBaseline,
relation,
);
while (!verification.cleaned) {
const remaining = deadline - performance.now();
if (remaining <= 0) break;
await new Promise<void>((resolve) =>
setTimeout(
resolve,
Math.min(PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS, remaining),
),
);
verification = await verifyNoTokenOwnedProcesses(
ownership.runId,
host,
ownership.captureBaseline,
relation,
);
}
} catch (cause: unknown) {
return mergeUnverifiedObservations(
{
cleaned: false,
reason: `final process ownership verification failed: ${errorMessage(cause)}`,
},
plan.unverified,
);
}
if (!verification.cleaned)
return mergeUnverifiedObservations(verification, plan.unverified);
return mergeUnverifiedObservations({ cleaned: true, signaled }, [
...plan.unverified,
...(verification.unverified ?? []),
]);
}
return {
cleaned: true,
signaled,
@@ -358,6 +484,75 @@ interface OwnedCleanupPlan {
readonly unverified: readonly OwnershipSweepUnverifiedProcess[];
}
interface ProcessRelationSnapshot {
readonly currentRelations: readonly ProcessTableEntry[];
readonly identities: ReadonlyMap<number, ProcessIdentityObservation>;
readonly failures: readonly ProcessOwnershipValidationFailure[];
}
/**
* Enrich cleanup candidates with same-snapshot native membership. Process-table
* ancestry/group fields select only rows whose missing native facts need to
* fail closed; they never authorize a group or descendant relation.
*/
const readProcessRelationSnapshot = async (
ownership: OwnedProcessGroup,
processes: readonly ProcessTableEntry[],
host: ProcessOwnershipHost,
): Promise<ProcessRelationSnapshot> => {
if (host.processIdentities === undefined)
return {
currentRelations: processes,
identities: new Map(),
failures: [],
};
const identities = await readNativeProcessIdentityMap(processes, host);
const possibleDescendants = new Set(
descendantsOf([ownership.leaderPid], processes).map(({ pid }) => pid),
);
const relevantPids = new Set([
ownership.leaderPid,
...possibleDescendants,
...processes
.filter(
({ processGroupId }) =>
processGroupId === ownership.processGroupId ||
ownership.sampledProcessGroupIds?.includes(processGroupId),
)
.map(({ pid }) => pid),
]);
const currentRelations: ProcessTableEntry[] = [];
const failures: ProcessOwnershipValidationFailure[] = [];
for (const process of processes) {
const identity = identities.get(process.pid);
if (
identity?.state === "readable" &&
identity.lifecycle?.state === "observed" &&
identity.lifecycle.lifecycle === "zombie"
)
continue;
if (
identity?.state === "readable" &&
identity.relation?.state === "observed"
) {
currentRelations.push({
...process,
parentPid: identity.relation.parentPid,
processGroupId: identity.relation.processGroupId,
});
continue;
}
if (relevantPids.has(process.pid) && identity?.state !== "absent")
failures.push({
pid: process.pid,
reason: "process-identity-unavailable",
diagnostic: `native process membership could not be read${identity?.state === "unavailable" ? `: ${identity.reason}` : ""}`,
});
}
return { currentRelations, identities, failures };
};
// A process-table read can race exit and retain the old state with an altered
// command. Only a fresh table proving that PID is no longer live can settle
// that mismatch; surviving group members still require normal token checks.
@@ -371,8 +566,38 @@ const settleLauncherExit = async (
> => {
const launcher = processes.find(({ pid }) => pid === ownership.leaderPid);
if (launcher === undefined) return processes;
const reason = launcherIdentityFailure(launcher, ownership);
let identityCheckedLauncher = launcher;
if (host.processIdentities !== undefined) {
const identities = await readNativeProcessIdentityMap([launcher], host);
const identity = identities.get(launcher.pid);
if (identity?.state === "absent")
return processes.filter(({ pid }) => pid !== launcher.pid);
if (
identity?.state === "readable" &&
identity.lifecycle?.state === "observed" &&
identity.lifecycle.lifecycle === "zombie"
)
return processes.filter(({ pid }) => pid !== launcher.pid);
if (
identity?.state !== "readable" ||
identity.relation?.state !== "observed"
)
return {
cleaned: false,
reason: `owned launcher native identity could not be verified${identity?.state === "unavailable" ? `: ${identity.reason}` : ""}`,
};
identityCheckedLauncher = {
...launcher,
parentPid: identity.relation.parentPid,
processGroupId: identity.relation.processGroupId,
};
}
const reason = launcherIdentityFailure(identityCheckedLauncher, ownership);
if (reason === null) return processes;
// A native identity read already says that the launcher still exists and
// supplies its current parent/group relation. Refreshing the process table
// cannot repair a command or launch-relation mismatch for that generation.
if (host.processIdentities !== undefined) return { cleaned: false, reason };
const refreshed = await readLiveProcessTable(host);
if (
refreshed.available &&
@@ -413,6 +638,11 @@ const createOwnedCleanupPlan = async (
): Promise<OwnedCleanupPlan | ProcessCleanupResult> => {
const processes = await settleLauncherExit(ownership, initialProcesses, host);
if ("cleaned" in processes) return processes;
const relationSnapshot = await readProcessRelationSnapshot(
ownership,
processes,
host,
);
const sampledProcessGroupIds = new Set(
(ownership.sampledProcessGroupIds ?? []).filter(
(processGroupId) =>
@@ -429,6 +659,7 @@ const createOwnedCleanupPlan = async (
{
leaderPid: ownership.leaderPid,
processGroupId: ownership.processGroupId,
evidenceOwner: ownership.evidenceOwner,
...(sampledProcessGroupIds.size === 0
? {}
: { sampledProcessGroupIds: [...sampledProcessGroupIds] }),
@@ -438,23 +669,26 @@ const createOwnedCleanupPlan = async (
const tokenOwnedGroupIds = new Set(
tokenOwned.owned.map(({ processGroupId }) => processGroupId),
);
const launcher = processes.find(({ pid }) => pid === ownership.leaderPid);
const rootMembers = processes.filter(
const launcher = relationSnapshot.currentRelations.find(
({ pid }) => pid === ownership.leaderPid,
);
const rootMembers = relationSnapshot.currentRelations.filter(
({ processGroupId }) => processGroupId === ownership.processGroupId,
);
if (
launcher === undefined &&
rootMembers.length === 0 &&
tokenOwned.owned.length === 0 &&
!processes.some(({ processGroupId }) =>
!relationSnapshot.currentRelations.some(({ processGroupId }) =>
sampledProcessGroupIds.has(processGroupId),
)
)
return tokenOwned.failures.length > 0
return tokenOwned.failures.length > 0 ||
relationSnapshot.failures.length > 0
? {
signalOrder: [],
tokenOwnedIdentities: tokenOwned.identities,
unresolved: tokenOwned.failures,
unresolved: [...tokenOwned.failures, ...relationSnapshot.failures],
unverified: tokenOwned.unverified,
}
: {
@@ -466,7 +700,10 @@ const createOwnedCleanupPlan = async (
};
let descendants: readonly ProcessTableEntry[] = [];
if (launcher !== undefined) {
descendants = descendantsOf([launcher.pid], processes);
descendants = descendantsOf(
[launcher.pid],
relationSnapshot.currentRelations,
);
}
const descendantPids = new Set(descendants.map(({ pid }) => pid));
const processGroupIds = new Set<number>([ownership.processGroupId]);
@@ -477,11 +714,11 @@ const createOwnedCleanupPlan = async (
for (const processGroupId of sampledProcessGroupIds)
processGroupIds.add(processGroupId);
const signalableGroups = new Set<number>();
const unresolved = [...tokenOwned.failures];
const unresolved = [...tokenOwned.failures, ...relationSnapshot.failures];
const unverifiableSampledGroups = new Set<number>();
for (const processGroupId of processGroupIds) {
if (processGroupId === ownership.processGroupId) continue;
const groupLeader = processes.find(
const groupLeader = relationSnapshot.currentRelations.find(
({ pid, processGroupId: observedGroupId }) =>
pid === processGroupId && observedGroupId === processGroupId,
);
@@ -501,8 +738,8 @@ const createOwnedCleanupPlan = async (
)
unverifiableSampledGroups.add(processGroupId);
}
const liveMembers = processes.filter(({ processGroupId }) =>
processGroupIds.has(processGroupId),
const liveMembers = relationSnapshot.currentRelations.filter(
({ processGroupId }) => processGroupIds.has(processGroupId),
);
for (const processGroupId of processGroupIds) {
const members = liveMembers.filter(
@@ -599,7 +836,14 @@ const revalidateOwnedProcessGroup = async (
host,
);
if ("cleaned" in processes) return processes;
members = processes.filter(
const relationSnapshot = await readProcessRelationSnapshot(
ownership,
processes,
host,
);
if (relationSnapshot.failures.length > 0)
return cleanupValidationFailure(relationSnapshot.failures);
members = relationSnapshot.currentRelations.filter(
({ processGroupId: observedGroupId }) =>
observedGroupId === processGroupId,
);
@@ -727,61 +971,67 @@ const processOwnershipFailures = async (
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);
const identityFailures: ProcessOwnershipValidationFailure[] = [];
for (const member of expectedMembers) {
const expected = expectedIdentities.get(member.pid);
const current = observed.get(member.pid);
if (host.processIdentities !== undefined) {
const facts = await readProcessOwnershipFacts(members, host);
const failures: ProcessOwnershipValidationFailure[] = [];
for (const fact of facts) {
if (fact.identityAfter.state === "absent") continue;
if (
expected === null ||
current?.state !== "readable" ||
current.identity !== expected
) {
identityFailures.push({
pid: member.pid,
fact.identityAfter.state === "readable" &&
fact.identityAfter.lifecycle?.state === "observed" &&
fact.identityAfter.lifecycle.lifecycle === "zombie"
)
continue;
const relation =
fact.identityAfter.state === "readable" &&
fact.identityAfter.relation?.state === "observed"
? fact.identityAfter.relation
: undefined;
if (
relation !== undefined &&
relation.processGroupId !== fact.process.processGroupId
)
continue;
if (fact.state !== "same-generation") {
failures.push({
pid: fact.process.pid,
reason: "process-identity-unavailable",
diagnostic:
"process identity changed or became unavailable before signal",
});
continue;
}
const expected = expectedIdentities.get(fact.process.pid);
if (
(expected !== undefined &&
expected !== null &&
fact.identityAfter.state === "readable" &&
fact.identityAfter.identity !== expected) ||
expected === null ||
relation === undefined
) {
failures.push({
pid: fact.process.pid,
reason: "process-identity-unavailable",
diagnostic:
"process identity or group membership could not be revalidated before signal",
});
continue;
}
if (fact.token.state === "unavailable") {
failures.push({
pid: fact.process.pid,
reason: "environment-unreadable",
diagnostic: fact.token.reason,
});
} else if (fact.token.runId !== runId) {
failures.push({ pid: fact.process.pid, reason: "run-token-mismatch" });
}
}
failures.push(...(await recheckOwnershipFailures(identityFailures, host)));
return failures;
}
failures.push(...(await processRunTokenFailures(members, runId, host)));
if (
failures.length === 0 &&
expectedIdentities.size > 0 &&
host.processIdentities !== undefined
) {
const expectedMembers = members.filter(({ pid }) =>
expectedIdentities.has(pid),
);
const observed = await host.processIdentities(expectedMembers);
const identityFailures: ProcessOwnershipValidationFailure[] = [];
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;
identityFailures.push({
pid: member.pid,
reason: "process-identity-unavailable",
diagnostic:
"process identity changed or became unavailable during token validation",
});
}
failures.push(...(await recheckOwnershipFailures(identityFailures, host)));
}
return failures;
return processRunTokenFailures(members, runId, host);
};
const processRunTokenFailures = async (
@@ -892,11 +1142,11 @@ const currentProcessUid = (): number | undefined =>
* unreadable candidate stays fail-closed.
*/
const environmentWithheldFromInspection = (
entry: ProcessTableEntry,
diagnostic: string,
observedUid: number | undefined,
): boolean => {
const uid = currentProcessUid();
if (uid !== undefined && entry.uid !== undefined && entry.uid !== uid)
if (uid !== undefined && observedUid !== undefined && observedUid !== uid)
return true;
return (
diagnostic === PLATFORM_BINARY_ENVIRONMENT_WITHHELD ||
@@ -911,55 +1161,22 @@ const scanTokenOwnedProcesses = async (
captureBaseline?: ProcessOwnershipBaseline,
relation?: OwnershipSweepRelation,
): Promise<TokenOwnedProcessScan> => {
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
);
});
}
}
if (host.processIdentities !== undefined)
return scanWithOwnershipEvidence(
processes,
runId,
host,
captureBaseline,
relation,
);
const candidates = processes;
const failures: ProcessOwnershipValidationFailure[] = [];
if (captureBaseline !== undefined && host.processIdentities !== undefined) {
const stableCandidates: ProcessTableEntry[] = [];
const identityFailures: ProcessOwnershipValidationFailure[] = [];
for (const process of candidates) {
if (candidateIdentities.get(process.pid)?.state === "readable") {
stableCandidates.push(process);
continue;
}
identityFailures.push({
pid: process.pid,
reason: "process-identity-unavailable",
diagnostic: "process identity was unavailable during token scan",
});
}
failures.push(...(await recheckOwnershipFailures(identityFailures, host)));
candidates = stableCandidates;
}
const owned: ProcessTableEntry[] = [];
const ownedIdentities = new Map<number, string | null>();
const recordOwned = (process: ProcessTableEntry): void => {
owned.push(process);
const identity = candidateIdentities.get(process.pid);
ownedIdentities.set(
process.pid,
identity?.state === "readable" ? identity.identity : null,
);
ownedIdentities.set(process.pid, null);
};
const unreadable: UnreadableTokenCandidate[] = [];
for await (const { process, observation } of readProcessRunTokens(
@@ -998,7 +1215,10 @@ const scanTokenOwnedProcesses = async (
relatedGroupIds.has(candidate.process.processGroupId);
if (
!isRelated &&
environmentWithheldFromInspection(candidate.process, candidate.diagnostic)
environmentWithheldFromInspection(
candidate.diagnostic,
candidate.process.uid,
)
) {
withheld.push(candidate);
continue;
@@ -1031,38 +1251,239 @@ const scanTokenOwnedProcesses = async (
diagnostic: candidate.diagnostic,
});
}
if (candidates.length > 0 && host.processIdentities !== undefined) {
const afterRead = await host.processIdentities(candidates);
const stillOwned: ProcessTableEntry[] = [];
const identityFailures: ProcessOwnershipValidationFailure[] = [];
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;
}
identityFailures.push({
return { owned, failures, unverified, identities: ownedIdentities };
};
const scanWithOwnershipEvidence = async (
listedProcesses: readonly ProcessTableEntry[],
runId: string,
host: ProcessOwnershipHost,
captureBaseline: ProcessOwnershipBaseline | undefined,
relation: OwnershipSweepRelation | undefined,
): Promise<TokenOwnedProcessScan> => {
const evidenceOwner =
relation === undefined
? new ProcessOwnershipEvidenceOwner()
: relation.evidenceOwner;
if (evidenceOwner === undefined)
return {
owned: [],
failures: [
{
pid: relation?.leaderPid ?? -1,
reason: "process-identity-unavailable",
diagnostic:
"persistent process ownership evidence owner is required for scoped token scan",
},
],
unverified: [],
identities: new Map(),
};
const observationRows = evidenceOwner.rowsForObservation(listedProcesses);
const listedPids = new Set(listedProcesses.map(({ pid }) => pid));
const baseline = new Map(
(captureBaseline ?? []).map(({ pid, identity }) => [pid, identity]),
);
const retainedPids = new Set(evidenceOwner.candidates.map(({ pid }) => pid));
const firstIdentities = await readNativeProcessIdentityMap(
observationRows,
host,
);
const baselineIdentityFailures: ProcessOwnershipValidationFailure[] = [];
const candidates = observationRows.filter((process) => {
if (retainedPids.has(process.pid)) return true;
if (!baseline.has(process.pid)) return true;
const before = baseline.get(process.pid);
const now = firstIdentities.get(process.pid);
if (before === null || now?.state !== "readable") {
baselineIdentityFailures.push({
pid: process.pid,
reason: "process-identity-unavailable",
diagnostic:
"process identity changed or became unavailable during token validation",
diagnostic: "process identity was unavailable during token validation",
});
// Keep the row in the owning fact pass. The failure is not evidence that
// the process is absent, and a related unknown generation must survive
// a later process-table omission.
return true;
}
failures.push(...(await recheckOwnershipFailures(identityFailures, host)));
return {
owned: stillOwned,
failures,
unverified,
identities: ownedIdentities,
};
return now.identity !== before;
});
// Keep the first relation/token observation: bounded retries can see a
// reparented process and a different withheld-token diagnostic.
const initialFacts = await readProcessOwnershipFacts(
candidates,
host,
undefined,
{
identityBefore: firstIdentities,
},
);
let facts = initialFacts;
for (
let attempt = 0;
attempt < TOKEN_READ_RETRY_ATTEMPTS &&
facts.some(({ token }) => token.state === "unavailable");
attempt += 1
) {
await new Promise<void>((resolve) =>
setTimeout(resolve, TOKEN_READ_RETRY_DELAY_MS),
);
const pendingPids = new Set(
facts
.filter(({ token }) => token.state === "unavailable")
.map(({ process }) => process.pid),
);
const retryRows = candidates.filter(({ pid }) => pendingPids.has(pid));
const retries = await readProcessOwnershipFacts(retryRows, host);
const retryByPid = new Map(retries.map((fact) => [fact.process.pid, fact]));
facts = facts.map((fact) => retryByPid.get(fact.process.pid) ?? fact);
}
return { owned, failures, unverified, identities: ownedIdentities };
const processGroupIds = new Set<number>([
...(relation === undefined ? [] : [relation.processGroupId]),
...(relation?.sampledProcessGroupIds ?? []),
]);
const retained = evidenceOwner.reconcile(facts, {
runId,
listedRows: listedProcesses.length,
listedPids,
leaderPid: relation?.leaderPid ?? -1,
processGroupIds,
scopeFacts: initialFacts,
});
const factsByPid = new Map(facts.map((fact) => [fact.process.pid, fact]));
const retainedByPid = new Map(
retained.map((candidate) => [candidate.pid, candidate]),
);
const failures: ProcessOwnershipValidationFailure[] = [
...baselineIdentityFailures,
];
const unverified: OwnershipSweepUnverifiedProcess[] = [];
const owned: ProcessTableEntry[] = [];
const identities = new Map<number, string | null>();
for (const fact of facts) {
if (fact.state === "replaced")
failures.push({
pid: fact.process.pid,
reason: "process-identity-unavailable",
diagnostic: "process identity changed during token validation",
});
else if (
fact.state === "unavailable" &&
fact.identityAfter.state !== "absent"
)
failures.push({
pid: fact.process.pid,
reason: "process-identity-unavailable",
diagnostic: "process identity was unavailable during token validation",
});
const retainedCandidate = retainedByPid.get(fact.process.pid);
if (
fact.state === "same-generation" &&
fact.token.state === "readable" &&
fact.token.runId !== runId &&
retainedCandidate !== undefined
)
failures.push({
pid: fact.process.pid,
reason: "run-token-mismatch",
});
if (fact.token.state !== "unavailable") continue;
if (fact.identityAfter.state === "absent") continue;
const relationBefore =
fact.identityBefore.state === "readable"
? fact.identityBefore.relation
: undefined;
const relationAfter =
fact.identityAfter.state === "readable"
? fact.identityAfter.relation
: undefined;
const knownUnrelated =
relation !== undefined &&
retainedCandidate === undefined &&
fact.state === "same-generation" &&
relationBefore?.state === "observed" &&
relationAfter?.state === "observed" &&
relationBefore.parentPid === relationAfter.parentPid &&
relationBefore.processGroupId === relationAfter.processGroupId;
const observedUid =
fact.state === "same-generation" &&
fact.identityAfter.state === "readable"
? fact.identityAfter.effectiveUid
: undefined;
if (
knownUnrelated &&
environmentWithheldFromInspection(fact.token.reason, observedUid)
) {
unverified.push({
pid: fact.process.pid,
diagnostic: fact.token.reason,
});
continue;
}
if (
fact.state === "same-generation" ||
(retainedCandidate?.applicability === "related-ownership-unresolved" &&
fact.identityAfter.state === "readable" &&
fact.identityAfter.identity === retainedCandidate.identity)
)
failures.push({
pid: fact.process.pid,
reason: "environment-unreadable",
diagnostic: fact.token.reason,
});
}
for (const candidate of retained) {
const fact = factsByPid.get(candidate.pid);
if (fact?.state !== "same-generation") {
if (
fact?.identityAfter.state !== "absent" &&
!failures.some(({ pid }) => pid === candidate.pid)
)
mergeProcessOwnershipFailure(failures, {
pid: candidate.pid,
reason: "process-identity-unavailable",
diagnostic: "retained candidate identity is not currently verified",
});
continue;
}
const identity = fact.identityAfter;
if (
identity.state !== "readable" ||
candidate.identity === undefined ||
identity.identity !== candidate.identity
)
continue;
if (
candidate.applicability !== "token-verified" ||
fact.token.state !== "readable" ||
fact.token.runId !== runId
)
continue;
if (identity.relation?.state !== "observed") {
failures.push({
pid: candidate.pid,
reason: "process-identity-unavailable",
diagnostic: "native process membership was unavailable",
});
continue;
}
owned.push({
...fact.process,
parentPid: identity.relation.parentPid,
processGroupId: identity.relation.processGroupId,
});
identities.set(candidate.pid, candidate.identity);
}
const mergedFailures: ProcessOwnershipValidationFailure[] = [];
for (const failure of failures)
mergeProcessOwnershipFailure(mergedFailures, failure);
return { owned, failures: mergedFailures, unverified, identities };
};
/**
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanupOwnedProcessGroup,
cleanupWindowsProcessTree,
@@ -10,7 +10,12 @@ import {
observeOwnedProcessGroups,
observeOwnedProcessLineage,
} from "./ProcessOwnershipObservation.js";
import { host, ownership } from "./ProcessOwnership.fixture.js";
import { createOwnership, host } from "./ProcessOwnership.fixture.js";
let ownership = createOwnership();
beforeEach(() => {
ownership = createOwnership();
});
const launcher: ProcessTableEntry = {
pid: 100,
+359
View File
@@ -0,0 +1,359 @@
import type {
ProcessIdentityObservation,
ProcessNativeRelationObservation,
ProcessOwnershipFact,
ProcessRunTokenObservation,
ProcessTableEntry,
} from "./ProcessOwnership.js";
export interface RetainedProcessOwnershipCandidate {
readonly pid: number;
/** Undefined means the generation has never been established, not PID ownership. */
readonly identity: string | undefined;
readonly process: ProcessTableEntry;
readonly applicability:
| "token-verified"
| "related-ownership-unresolved"
| "identity-unresolved";
/** Why this generation remains in the ledger; capture anchors are only scope uncertainty. */
readonly scopeEvidence:
| "run-token"
| "current-token-owned-parent"
| "capture-leader-pid"
| "captured-process-group-id"
| "native-identity-unresolved"
| "previously-related-generation";
readonly token: ProcessRunTokenObservation;
readonly relation:
| Extract<ProcessNativeRelationObservation, { readonly state: "observed" }>
| undefined;
readonly diagnostic?: string;
}
export interface ProcessOwnershipEvidenceCoverage {
readonly snapshotCount: number;
/** Rows supplied by the caller for the latest snapshot, not a completeness claim. */
readonly listedRows: number;
/** Distinct PIDs given before/token/after reads, including retained-only PIDs. */
readonly examinedPids: number;
readonly retainedOnlyPids: number;
readonly retainedOwned: number;
readonly retainedUnresolved: number;
readonly uncertainPids: readonly number[];
readonly processTableCoverage: "unreported-by-host";
}
/** Per-run facts retained across failed cleanup attempts; identity is required for authority. */
export class ProcessOwnershipEvidenceOwner {
readonly #candidates = new Map<number, RetainedProcessOwnershipCandidate>();
#snapshotCount = 0;
#coverage: ProcessOwnershipEvidenceCoverage = {
snapshotCount: 0,
listedRows: 0,
examinedPids: 0,
retainedOnlyPids: 0,
retainedOwned: 0,
retainedUnresolved: 0,
uncertainPids: [],
processTableCoverage: "unreported-by-host",
};
get candidates(): readonly RetainedProcessOwnershipCandidate[] {
return [...this.#candidates.values()];
}
get coverage(): ProcessOwnershipEvidenceCoverage {
return this.#coverage;
}
/**
* Reconcile facts from one explicit snapshot. Unrelated transient rows are
* never retained; unknown related candidates remain evidence, not authority.
*/
reconcile(
facts: readonly ProcessOwnershipFact[],
options: {
readonly runId: string;
readonly listedRows: number;
readonly listedPids: ReadonlySet<number>;
readonly leaderPid: number;
readonly processGroupIds: ReadonlySet<number>;
/** First identity/token/identity result before bounded token-only retries. */
readonly scopeFacts?: readonly ProcessOwnershipFact[];
},
): readonly RetainedProcessOwnershipCandidate[] {
this.#snapshotCount += 1;
const factsByPid = new Map(facts.map((fact) => [fact.process.pid, fact]));
const sameGeneration = new Map<
number,
Extract<
ProcessOwnershipFact["identityAfter"],
{ readonly state: "readable" }
>
>();
const sameGenerationBefore = new Map<
number,
Extract<ProcessIdentityObservation, { readonly state: "readable" }>
>();
const tokenMatchedPids = new Set<number>();
const uncertainPids = new Set<number>();
const retiredPids = new Set<number>();
for (const fact of facts) {
if (fact.state !== "same-generation") continue;
if (fact.identityAfter.state !== "readable") continue;
sameGeneration.set(fact.process.pid, fact.identityAfter);
if (
fact.identityBefore.state === "readable" &&
fact.identityBefore.identity === fact.identityAfter.identity
)
sameGenerationBefore.set(fact.process.pid, fact.identityBefore);
if (fact.token.state === "readable" && fact.token.runId === options.runId)
tokenMatchedPids.add(fact.process.pid);
}
const findScopeEvidence = (
pid: number,
generations: ReadonlyMap<
number,
Extract<ProcessIdentityObservation, { readonly state: "readable" }>
>,
tokenOwners: ReadonlySet<number>,
):
| Exclude<
RetainedProcessOwnershipCandidate["scopeEvidence"],
"run-token" | "previously-related-generation"
>
| undefined => {
const identity = generations.get(pid);
if (identity === undefined) return undefined;
const relation =
identity.relation?.state === "observed" ? identity.relation : undefined;
if (relation === undefined) return undefined;
if (options.processGroupIds.has(relation.processGroupId))
return "captured-process-group-id";
const visited = new Set<number>([pid]);
let parentPid = relation.parentPid;
while (!visited.has(parentPid)) {
const parent = generations.get(parentPid);
if (parent === undefined) {
return parentPid === options.leaderPid
? "capture-leader-pid"
: undefined;
}
const previousParent = this.#candidates.get(parentPid);
if (
tokenOwners.has(parentPid) ||
(previousParent?.applicability === "token-verified" &&
parent.identity === previousParent.identity)
)
return "current-token-owned-parent";
if (parentPid === options.leaderPid) return "capture-leader-pid";
visited.add(parentPid);
if (parent.relation?.state !== "observed") return undefined;
parentPid = parent.relation.parentPid;
}
return undefined;
};
const scopeGenerations = new Map<
number,
Extract<ProcessIdentityObservation, { readonly state: "readable" }>
>();
const scopeBeforeGenerations = new Map<
number,
Extract<ProcessIdentityObservation, { readonly state: "readable" }>
>();
const scopeTokenMatchedPids = new Set<number>();
for (const fact of options.scopeFacts ?? []) {
const current = sameGeneration.get(fact.process.pid);
if (
fact.state !== "same-generation" ||
fact.identityAfter.state !== "readable" ||
current?.identity !== fact.identityAfter.identity
)
continue;
scopeGenerations.set(fact.process.pid, fact.identityAfter);
if (
fact.identityBefore.state === "readable" &&
current?.identity === fact.identityBefore.identity
)
scopeBeforeGenerations.set(fact.process.pid, fact.identityBefore);
if (fact.token.state === "readable" && fact.token.runId === options.runId)
scopeTokenMatchedPids.add(fact.process.pid);
}
const related = (pid: number) => {
const current =
findScopeEvidence(pid, sameGeneration, tokenMatchedPids) ??
findScopeEvidence(pid, sameGenerationBefore, tokenMatchedPids);
if (current !== undefined) return current;
const initial =
findScopeEvidence(pid, scopeGenerations, scopeTokenMatchedPids) ??
findScopeEvidence(pid, scopeBeforeGenerations, scopeTokenMatchedPids);
return initial === undefined
? undefined
: "previously-related-generation";
};
for (const [pid, previous] of this.#candidates) {
const fact = factsByPid.get(pid);
if (fact === undefined) continue;
// Retire only this generation's current ledger entry. Darwin's
// `not-in-snapshot` and Linux's corroborated ENOENT/ESRCH facts are
// bounded observations, not claims about later PID reuse or global
// liveness.
if (
fact.state === "absent" ||
(previous.identity !== undefined &&
fact.identityAfter.state === "readable" &&
fact.identityAfter.identity !== previous.identity) ||
(fact.state === "same-generation" &&
fact.identityAfter.state === "readable" &&
fact.identityAfter.lifecycle?.state === "observed" &&
fact.identityAfter.lifecycle.lifecycle === "zombie")
) {
this.#candidates.delete(pid);
retiredPids.add(pid);
continue;
}
if (fact.state !== "same-generation") {
uncertainPids.add(pid);
continue;
}
const identity = fact.identityAfter;
if (identity.state !== "readable") {
uncertainPids.add(pid);
continue;
}
const matched =
fact.token.state === "readable" && fact.token.runId === options.runId;
if (previous.identity === undefined && !matched) {
uncertainPids.add(pid);
this.#candidates.set(pid, {
...previous,
process: fact.process,
token: fact.token,
...(fact.token.state === "unavailable"
? { diagnostic: fact.token.reason }
: {}),
});
continue;
}
const relation =
identity.relation?.state === "observed"
? {
state: "observed" as const,
parentPid: identity.relation.parentPid,
processGroupId: identity.relation.processGroupId,
}
: undefined;
this.#candidates.set(pid, {
pid,
identity: identity.identity,
process: fact.process,
applicability:
matched || previous.applicability === "token-verified"
? "token-verified"
: "related-ownership-unresolved",
scopeEvidence:
matched || previous.applicability === "token-verified"
? "run-token"
: (related(pid) ?? "previously-related-generation"),
token: fact.token,
relation,
...(fact.token.state === "unavailable"
? { diagnostic: fact.token.reason }
: {}),
});
}
for (const fact of facts) {
const pid = fact.process.pid;
if (
this.#candidates.has(pid) ||
(retiredPids.has(pid) && fact.state !== "same-generation") ||
fact.state === "absent" ||
(fact.state === "same-generation" &&
fact.identityAfter.state === "readable" &&
fact.identityAfter.lifecycle?.state === "observed" &&
fact.identityAfter.lifecycle.lifecycle === "zombie")
)
continue;
const identity = fact.identityAfter;
if (fact.state !== "same-generation" || identity.state !== "readable") {
this.#candidates.set(pid, {
pid,
identity: undefined,
process: fact.process,
applicability: "identity-unresolved",
scopeEvidence: "native-identity-unresolved",
token: fact.token,
relation: undefined,
...(fact.token.state === "unavailable"
? { diagnostic: fact.token.reason }
: {}),
});
uncertainPids.add(pid);
continue;
}
const matched =
fact.token.state === "readable" && fact.token.runId === options.runId;
const unresolvedScope = matched ? undefined : related(pid);
if (!matched && unresolvedScope === undefined) continue;
const relation =
identity.relation?.state === "observed"
? {
state: "observed" as const,
parentPid: identity.relation.parentPid,
processGroupId: identity.relation.processGroupId,
}
: undefined;
this.#candidates.set(pid, {
pid,
identity: identity.identity,
process: fact.process,
applicability: matched
? "token-verified"
: "related-ownership-unresolved",
scopeEvidence: matched
? "run-token"
: (unresolvedScope ?? "previously-related-generation"),
token: fact.token,
relation,
...(fact.token.state === "unavailable"
? { diagnostic: fact.token.reason }
: {}),
});
}
const retainedOnlyPids = [...this.#candidates.keys()].filter(
(pid) => !options.listedPids.has(pid),
);
this.#coverage = {
snapshotCount: this.#snapshotCount,
listedRows: options.listedRows,
examinedPids: facts.length,
retainedOnlyPids: retainedOnlyPids.length,
retainedOwned: [...this.#candidates.values()].filter(
({ applicability }) => applicability === "token-verified",
).length,
retainedUnresolved: [...this.#candidates.values()].filter(
({ applicability }) => applicability !== "token-verified",
).length,
uncertainPids: [...uncertainPids].sort((left, right) => left - right),
processTableCoverage: "unreported-by-host",
};
return this.candidates;
}
/** Merge current discovery rows with retained candidates without PID-only proof. */
rowsForObservation(
processes: readonly ProcessTableEntry[],
): readonly ProcessTableEntry[] {
const byPid = new Map(processes.map((process) => [process.pid, process]));
for (const candidate of this.#candidates.values())
if (!byPid.has(candidate.pid))
byPid.set(candidate.pid, candidate.process);
return [...byPid.values()];
}
}
+600
View File
@@ -0,0 +1,600 @@
import { expect, it, vi } from "vitest";
import type {
ProcessIdentityObservation,
ProcessOwnershipFact,
ProcessOwnershipHost,
ProcessTableEntry,
ProcessRunTokenObservation,
ProcessNativeRelationObservation,
} from "./ProcessOwnership.js";
import { readProcessOwnershipFacts } from "./ProcessOwnershipObservation.js";
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
const row: ProcessTableEntry = {
pid: 42,
parentPid: 1,
processGroupId: 42,
state: "S",
command: "fixture",
};
const identity = (
value: string,
lifecycle: "non-zombie" | "zombie" = "non-zombie",
relation: ProcessNativeRelationObservation = {
state: "observed",
parentPid: row.parentPid,
processGroupId: row.processGroupId,
},
): ProcessIdentityObservation => ({
state: "readable",
identity: value,
lifecycle: { state: "observed", lifecycle },
relation,
});
const hostFor = (
identities: readonly ReadonlyMap<number, ProcessIdentityObservation>[],
token: ProcessRunTokenObservation = { state: "readable", runId: "run" },
): ProcessOwnershipHost => {
let identityRead = 0;
return {
platform: "linux",
listProcesses: vi.fn(async () => {
throw new Error(
"ownership fact owner must not enumerate the process table",
);
}),
environment: async () => ({}),
processIdentities: async () => {
const result = identities[identityRead];
identityRead += 1;
return result ?? new Map();
},
runTokens: async () => new Map([[row.pid, token]]),
signalGroup: () => undefined,
};
};
it("binds owned, missing, and foreign tokens only to the same identity generation", async () => {
for (const token of [
{ state: "readable", runId: "run" },
{ state: "readable", runId: undefined },
{ state: "readable", runId: "foreign" },
] as const) {
const host = hostFor(
[
new Map([[row.pid, identity("start:42")]]),
new Map([[row.pid, identity("start:42")]]),
],
token,
);
const [fact] = await readProcessOwnershipFacts([row], host);
expect(fact).toMatchObject({
state: "same-generation",
token,
identityBefore: { state: "readable", identity: "start:42" },
identityAfter: { state: "readable", identity: "start:42" },
});
}
});
it("retains a matching token as partial evidence when identity changes", async () => {
const host = hostFor([
new Map([[row.pid, identity("start:old")]]),
new Map([[row.pid, identity("start:new")]]),
]);
const [fact] = await readProcessOwnershipFacts([row], host);
expect(fact).toMatchObject({
state: "replaced",
token: { state: "readable", runId: "run" },
});
});
it("keeps omitted identities uncertain and native absence distinct", async () => {
const omitted = hostFor([new Map(), new Map()]);
const [uncertain] = await readProcessOwnershipFacts([row], omitted);
expect(uncertain).toMatchObject({
state: "unavailable",
identityBefore: {
state: "unavailable",
reason: "process identity was not returned during before observation",
},
});
const absent: ProcessIdentityObservation = {
state: "absent",
reason: "not-in-snapshot",
};
const absentHost = hostFor([
new Map([[row.pid, absent]]),
new Map([[row.pid, absent]]),
]);
const [gone] = await readProcessOwnershipFacts([row], absentHost);
expect(gone).toMatchObject({
state: "absent",
token: { state: "readable", runId: "run" },
});
expect(absentHost.listProcesses).not.toHaveBeenCalled();
const appeared = hostFor([
new Map([[row.pid, absent]]),
new Map([[row.pid, identity("start:appeared")]]),
]);
const [replacement] = await readProcessOwnershipFacts([row], appeared);
expect(replacement?.state).toBe("replaced");
});
it("keeps zombie lifecycle separate from identity and token diagnostics intact", async () => {
const zombie = hostFor(
[
new Map([[row.pid, identity("start:zombie", "zombie")]]),
new Map([[row.pid, identity("start:zombie", "zombie")]]),
],
{ state: "unavailable", reason: "environment denied" },
);
const [fact] = await readProcessOwnershipFacts([row], zombie);
expect(fact).toMatchObject({
state: "same-generation",
token: { state: "unavailable", reason: "environment denied" },
});
expect(fact?.identityBefore).toEqual(
expect.objectContaining({
identity: "start:zombie",
lifecycle: { state: "observed", lifecycle: "zombie" },
}),
);
});
it("keeps stale table ancestry separate from current native membership", async () => {
const staleRow: ProcessTableEntry = {
...row,
pid: 101,
parentPid: 100,
processGroupId: 100,
};
const newGenerationRelation = {
state: "observed",
parentPid: 1,
processGroupId: 102,
} as const;
const host = hostFor([
new Map([
[
staleRow.pid,
identity("start:new", "non-zombie", newGenerationRelation),
],
]),
new Map([
[
staleRow.pid,
identity("start:new", "non-zombie", newGenerationRelation),
],
]),
]);
const [fact] = await readProcessOwnershipFacts([staleRow], host);
expect(fact).toMatchObject({
state: "same-generation",
process: { pid: 101, parentPid: 100, processGroupId: 100 },
identityBefore: { relation: newGenerationRelation },
identityAfter: { relation: newGenerationRelation },
});
expect(fact?.identityAfter).toMatchObject({
relation: { parentPid: 1, processGroupId: 102 },
});
});
it("retains both native relations when group membership changes during the read", async () => {
const staleRow: ProcessTableEntry = {
...row,
pid: 101,
parentPid: 100,
processGroupId: 100,
};
const beforeRelation = {
state: "observed",
parentPid: 100,
processGroupId: 100,
} as const;
const afterRelation = {
state: "observed",
parentPid: 1,
processGroupId: 102,
} as const;
const host = hostFor([
new Map([
[staleRow.pid, identity("start:same", "non-zombie", beforeRelation)],
]),
new Map([
[staleRow.pid, identity("start:same", "non-zombie", afterRelation)],
]),
]);
const [fact] = await readProcessOwnershipFacts([staleRow], host);
expect(fact?.state).toBe("same-generation");
expect(fact?.process).toEqual(staleRow);
expect(fact?.identityBefore).toMatchObject({ relation: beforeRelation });
expect(fact?.identityAfter).toMatchObject({ relation: afterRelation });
});
it("retains owned generations across retries after their token becomes unreadable", () => {
const owner = new ProcessOwnershipEvidenceOwner();
const owned: ProcessOwnershipFact = {
process: row,
state: "same-generation",
identityBefore: identity("start:owned"),
token: { state: "readable", runId: "run" },
identityAfter: identity("start:owned"),
};
owner.reconcile([owned], {
runId: "run",
listedRows: 1,
listedPids: new Set([row.pid]),
leaderPid: 1,
processGroupIds: new Set([row.processGroupId]),
});
const later = {
...owned,
token: { state: "unavailable", reason: "environment denied" },
identityBefore: identity("start:owned"),
identityAfter: identity("start:owned", "non-zombie", {
state: "observed",
parentPid: 1,
processGroupId: 55,
}),
} satisfies ProcessOwnershipFact;
const retained = owner.reconcile([later], {
runId: "run",
listedRows: 0,
listedPids: new Set(),
leaderPid: 1,
processGroupIds: new Set([row.processGroupId]),
});
expect(retained).toMatchObject([
{
pid: row.pid,
identity: "start:owned",
applicability: "token-verified",
relation: { parentPid: 1, processGroupId: 55 },
},
]);
expect(owner.coverage).toMatchObject({
listedRows: 0,
retainedOnlyPids: 1,
retainedOwned: 1,
processTableCoverage: "unreported-by-host",
});
});
it("upgrades an identity-unresolved candidate only after a same-generation token match", () => {
const owner = new ProcessOwnershipEvidenceOwner();
const options = {
runId: "run",
listedRows: 1,
listedPids: new Set([row.pid]),
leaderPid: 42,
processGroupIds: new Set([row.processGroupId]),
};
const unavailable: ProcessIdentityObservation = {
state: "unavailable",
reason: "native identity denied",
};
const first: ProcessOwnershipFact = {
process: row,
state: "unavailable",
identityBefore: unavailable,
token: { state: "unavailable", reason: "EPERM" },
identityAfter: unavailable,
};
expect(owner.reconcile([first], options)).toMatchObject([
{
pid: row.pid,
identity: undefined,
applicability: "identity-unresolved",
},
]);
const current: ProcessOwnershipFact = {
process: row,
state: "same-generation",
identityBefore: identity("start:current"),
token: { state: "readable", runId: "run" },
identityAfter: identity("start:current"),
};
expect(owner.reconcile([current], options)).toMatchObject([
{
pid: row.pid,
identity: "start:current",
applicability: "token-verified",
scopeEvidence: "run-token",
},
]);
});
it("lets a successful native absence snapshot retire a token-matching old generation", () => {
const owner = new ProcessOwnershipEvidenceOwner();
const owned: ProcessOwnershipFact = {
process: row,
state: "same-generation",
identityBefore: identity("start:owned"),
token: { state: "readable", runId: "run" },
identityAfter: identity("start:owned"),
};
const options = {
runId: "run",
listedRows: 1,
listedPids: new Set([row.pid]),
leaderPid: 1,
processGroupIds: new Set([row.processGroupId]),
};
owner.reconcile([owned], options);
const snapshotAbsence: ProcessIdentityObservation = {
state: "absent",
reason: "not-in-snapshot",
};
const contradictory = {
...owned,
state: "absent" as const,
identityBefore: snapshotAbsence,
identityAfter: snapshotAbsence,
} satisfies ProcessOwnershipFact;
expect(owner.reconcile([contradictory], options)).toEqual([]);
expect(owner.coverage.retainedOwned).toBe(0);
});
it("retains a newly verified replacement generation after retiring the old one", () => {
const owner = new ProcessOwnershipEvidenceOwner();
const options = {
runId: "run",
listedRows: 1,
listedPids: new Set([row.pid]),
leaderPid: 1,
processGroupIds: new Set([row.processGroupId]),
};
owner.reconcile(
[
{
process: row,
state: "same-generation",
identityBefore: identity("start:old"),
token: { state: "readable", runId: "run" },
identityAfter: identity("start:old"),
},
],
options,
);
const replacement: ProcessOwnershipFact = {
process: row,
state: "same-generation",
identityBefore: identity("start:new"),
token: { state: "readable", runId: "run" },
identityAfter: identity("start:new"),
};
expect(owner.reconcile([replacement], options)).toMatchObject([
{
pid: row.pid,
identity: "start:new",
applicability: "token-verified",
scopeEvidence: "run-token",
},
]);
});
it("retains only related unresolved rows and retires them on replacement", () => {
const owner = new ProcessOwnershipEvidenceOwner();
const candidate: ProcessTableEntry = {
...row,
pid: 77,
parentPid: 42,
processGroupId: 55,
};
const unreadable: ProcessOwnershipFact = {
process: candidate,
state: "same-generation",
identityBefore: identity("start:maybe-owned", "non-zombie", {
state: "observed",
parentPid: 42,
processGroupId: 55,
}),
token: { state: "unavailable", reason: "permission denied" },
identityAfter: identity("start:maybe-owned", "non-zombie", {
state: "observed",
parentPid: 42,
processGroupId: 55,
}),
};
const options = {
runId: "run",
listedRows: 1,
listedPids: new Set([77]),
leaderPid: 42,
processGroupIds: new Set<number>([42]),
};
expect(owner.reconcile([unreadable], options)).toMatchObject([
{
pid: 77,
applicability: "related-ownership-unresolved",
scopeEvidence: "capture-leader-pid",
},
]);
const reparented = {
...unreadable,
process: { ...candidate, parentPid: 1, processGroupId: 100 },
identityBefore: identity("start:maybe-owned", "non-zombie", {
state: "observed",
parentPid: 1,
processGroupId: 100,
}),
identityAfter: identity("start:maybe-owned", "non-zombie", {
state: "observed",
parentPid: 1,
processGroupId: 100,
}),
} satisfies ProcessOwnershipFact;
expect(
owner.reconcile([reparented], {
...options,
listedRows: 0,
listedPids: new Set(),
}),
).toMatchObject([
{
pid: 77,
applicability: "related-ownership-unresolved",
scopeEvidence: "previously-related-generation",
relation: { processGroupId: 100 },
},
]);
const replacement = {
...reparented,
state: "replaced" as const,
identityAfter: identity("start:replacement"),
};
expect(owner.reconcile([replacement], options)).toEqual([]);
});
it("requires a retained owned parent to be present as the same native generation", () => {
const owner = new ProcessOwnershipEvidenceOwner();
const parent: ProcessTableEntry = {
...row,
pid: 70,
parentPid: 1,
processGroupId: 70,
};
const child: ProcessTableEntry = {
...row,
pid: 71,
parentPid: 70,
processGroupId: 71,
};
const parentFact: ProcessOwnershipFact = {
process: parent,
state: "same-generation",
identityBefore: identity("start:parent", "non-zombie", {
state: "observed",
parentPid: 1,
processGroupId: 70,
}),
token: { state: "readable", runId: "run" },
identityAfter: identity("start:parent", "non-zombie", {
state: "observed",
parentPid: 1,
processGroupId: 70,
}),
};
const options = {
runId: "run",
listedRows: 1,
listedPids: new Set([parent.pid]),
leaderPid: 42,
processGroupIds: new Set<number>([42]),
};
owner.reconcile([parentFact], options);
const unreadableChild: ProcessOwnershipFact = {
process: child,
state: "same-generation",
identityBefore: identity("start:child", "non-zombie", {
state: "observed",
parentPid: 70,
processGroupId: 71,
}),
token: { state: "unavailable", reason: "permission denied" },
identityAfter: identity("start:child", "non-zombie", {
state: "observed",
parentPid: parent.pid,
processGroupId: child.processGroupId,
}),
};
expect(
owner.reconcile([unreadableChild], {
...options,
listedRows: 1,
listedPids: new Set([child.pid]),
}),
).toMatchObject([{ pid: parent.pid, applicability: "token-verified" }]);
const currentParent = {
...parentFact,
token: { state: "unavailable", reason: "permission denied" },
} satisfies ProcessOwnershipFact;
expect(
owner.reconcile([currentParent, unreadableChild], {
...options,
listedRows: 2,
listedPids: new Set([parent.pid, child.pid]),
}),
).toMatchObject([
{ pid: parent.pid, applicability: "token-verified" },
{
pid: child.pid,
applicability: "related-ownership-unresolved",
scopeEvidence: "current-token-owned-parent",
},
]);
});
it("does not retain unrelated transient rows and reports table coverage as unknown", () => {
const owner = new ProcessOwnershipEvidenceOwner();
const unrelated: ProcessOwnershipFact = {
process: { ...row, pid: 88, parentPid: 1, processGroupId: 88 },
state: "same-generation",
identityBefore: identity("start:unrelated", "non-zombie", {
state: "observed",
parentPid: 1,
processGroupId: 88,
}),
token: { state: "unavailable", reason: "temporary permission error" },
identityAfter: identity("start:unrelated", "non-zombie", {
state: "observed",
parentPid: 1,
processGroupId: 88,
}),
};
expect(
owner.reconcile([unrelated], {
runId: "run",
listedRows: 1,
listedPids: new Set([88]),
leaderPid: 42,
processGroupIds: new Set([42]),
}),
).toEqual([]);
expect(owner.coverage).toMatchObject({
listedRows: 1,
examinedPids: 1,
retainedUnresolved: 0,
processTableCoverage: "unreported-by-host",
});
});
it("preserves the caller's cancellation reason after the final identity read", async () => {
const controller = new AbortController();
const reason = new Error("caller stopped final identity read");
let identityRead = 0;
const host: ProcessOwnershipHost = {
...hostFor([
new Map([[row.pid, identity("start:42")]]),
new Map([[row.pid, identity("start:42")]]),
]),
processIdentities: async () => {
identityRead += 1;
if (identityRead === 2) controller.abort(reason);
return new Map([[row.pid, identity("start:42")]]);
},
};
await expect(
readProcessOwnershipFacts([row], host, controller.signal),
).rejects.toBe(reason);
});
+488 -17
View File
@@ -1,4 +1,5 @@
import { readFile } from "node:fs/promises";
import { parseLinuxProcessStat } from "./LinuxProcessStat.js";
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js";
@@ -15,9 +16,12 @@ import type {
ProcessLineageObservation,
ProcessOwnershipHost,
ProcessIdentityObservation,
ProcessOwnershipFact,
ProcessOwnershipBaseline,
ProcessRunTokenObservation,
ProcessTableEntry,
} from "./ProcessOwnership.js";
import type { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
/** Observe multiple groups from one process snapshot and one token batch. */
export const observeOwnedProcessGroups = async (
@@ -25,6 +29,7 @@ export const observeOwnedProcessGroups = async (
processGroupIds: readonly number[],
host: ProcessOwnershipHost = systemProcessOwnershipHost,
signal?: AbortSignal,
evidenceOwner?: ProcessOwnershipEvidenceOwner,
): Promise<ReadonlyMap<number, ProcessGroupObservation>> => {
signal?.throwIfAborted();
const groupIds = [...new Set(processGroupIds)];
@@ -41,6 +46,116 @@ export const observeOwnedProcessGroups = async (
return new Map(groupIds.map((groupId) => [groupId, failure]));
}
signal?.throwIfAborted();
if (host.processIdentities !== undefined) {
const observationRows =
evidenceOwner?.rowsForObservation(processes) ?? processes;
const retainedPids = new Set(
evidenceOwner?.candidates.map(({ pid }) => pid) ?? [],
);
const firstIdentities = await readNativeProcessIdentityMap(
observationRows,
host,
signal,
);
const uncertainGroups = new Set<number>();
const candidateProcesses = observationRows.filter((process) => {
if (retainedPids.has(process.pid)) return true;
const identity = firstIdentities.get(process.pid);
if (identity?.state === "readable") {
if (
identity.lifecycle?.state === "observed" &&
identity.lifecycle.lifecycle === "zombie"
)
return false;
if (
identity.relation?.state === "observed" &&
groupIds.includes(identity.relation.processGroupId)
)
return true;
if (
groupIds.includes(process.processGroupId) &&
identity.relation?.state !== "observed"
)
uncertainGroups.add(process.processGroupId);
return groupIds.includes(process.processGroupId);
}
if (groupIds.includes(process.processGroupId))
uncertainGroups.add(process.processGroupId);
return groupIds.includes(process.processGroupId);
});
const facts = await readProcessOwnershipFacts(
candidateProcesses,
host,
signal,
{ identityBefore: firstIdentities },
);
evidenceOwner?.reconcile(facts, {
runId,
listedRows: processes.length,
listedPids: new Set(processes.map(({ pid }) => pid)),
leaderPid: -1,
processGroupIds: new Set(groupIds),
scopeFacts: facts,
});
const observations = new Map<number, ProcessGroupObservation>(
groupIds.map((groupId) => [
groupId,
uncertainGroups.has(groupId)
? {
state: "unverifiable",
reason: "native process-group membership could not be verified",
}
: { state: "empty" },
]),
);
for (const fact of facts) {
if (fact.identityAfter.state === "absent") continue;
const nativeGroupId =
fact.identityAfter.state === "readable" &&
fact.identityAfter.relation?.state === "observed"
? fact.identityAfter.relation.processGroupId
: undefined;
if (
fact.identityAfter.state === "readable" &&
fact.identityAfter.lifecycle?.state === "observed" &&
fact.identityAfter.lifecycle.lifecycle === "zombie"
)
continue;
const candidateGroups = groupIds.filter(
(groupId) =>
nativeGroupId === groupId ||
(nativeGroupId === undefined &&
fact.process.processGroupId === groupId),
);
for (const groupId of candidateGroups) {
if (observations.get(groupId)?.state === "unverifiable") continue;
if (
fact.state !== "same-generation" ||
fact.identityAfter.state !== "readable" ||
nativeGroupId !== groupId
) {
observations.set(groupId, {
state: "unverifiable",
reason: `native process-group membership could not be revalidated for PID ${String(fact.process.pid)}`,
});
continue;
}
if (fact.token.state !== "readable" || fact.token.runId !== runId) {
observations.set(groupId, {
state: "unverifiable",
reason:
fact.token.state === "unavailable"
? `process ownership could not be revalidated for PID ${String(fact.process.pid)}: ${fact.token.reason}`
: "process ownership did not match",
});
continue;
}
observations.set(groupId, { state: "alive" });
}
}
signal?.throwIfAborted();
return observations;
}
const membersByGroup = new Map(
groupIds.map((groupId) => [groupId, [] as ProcessTableEntry[]]),
);
@@ -113,6 +228,8 @@ export const observeOwnedProcessLineage = async (
`process table could not be inspected: ${errorMessage(cause)}`,
);
}
if (host.processIdentities !== undefined)
return observeNativeOwnedProcessLineage(ownership, processes, host);
const launcher = processes.find(({ pid }) => pid === ownership.leaderPid);
if (launcher === undefined)
return unavailableLineage(ownership, "owned launcher is not live");
@@ -166,6 +283,177 @@ export const observeOwnedProcessLineage = async (
};
};
const observeNativeOwnedProcessLineage = async (
ownership: OwnedProcessGroup,
processes: readonly ProcessTableEntry[],
host: ProcessOwnershipHost,
): Promise<ProcessLineageObservation> => {
const initialIdentities = await readNativeProcessIdentityMap(processes, host);
const launcher = processes.find(({ pid }) => pid === ownership.leaderPid);
if (launcher === undefined)
return unavailableLineage(ownership, "owned launcher is not live");
const launcherIdentity = initialIdentities.get(launcher.pid);
if (launcherIdentity?.state === "absent")
return unavailableLineage(ownership, "owned launcher is not live");
if (launcherIdentity?.state !== "readable")
return unavailableLineage(
ownership,
`owned launcher identity could not be read: ${launcherIdentity?.reason ?? "identity observation was omitted"}`,
);
if (
launcherIdentity.lifecycle?.state === "observed" &&
launcherIdentity.lifecycle.lifecycle === "zombie"
)
return unavailableLineage(ownership, "owned launcher is not live");
const nativeLauncher = processWithNativeRelation(launcher, launcherIdentity);
if (nativeLauncher === undefined)
return unavailableLineage(
ownership,
"owned launcher native parent/group relation was unavailable",
);
const identityFailure = launcherIdentityFailure(nativeLauncher, ownership);
if (identityFailure !== null)
return unavailableLineage(ownership, identityFailure);
const nativeProcesses = processes.flatMap((process) => {
const identity = initialIdentities.get(process.pid);
if (
identity?.state === "readable" &&
identity.lifecycle?.state === "observed" &&
identity.lifecycle.lifecycle === "zombie"
)
return [];
const nativeProcess =
identity?.state === "readable"
? processWithNativeRelation(process, identity)
: undefined;
return nativeProcess === undefined ? [] : [nativeProcess];
});
const descendants = descendantsOf([ownership.leaderPid], nativeProcesses);
const candidates = [nativeLauncher, ...descendants];
const facts = await readProcessOwnershipFacts(candidates, host, undefined, {
identityBefore: initialIdentities,
});
// Preserve each generation's ownership evidence before projecting a lineage.
// A later candidate failure or a smaller future lineage cannot erase a
// token-verified helper that has since detached from this launcher.
ownership.evidenceOwner?.reconcile(facts, {
runId: ownership.runId,
listedRows: processes.length,
listedPids: new Set(processes.map(({ pid }) => pid)),
leaderPid: ownership.leaderPid,
processGroupIds: new Set([
ownership.processGroupId,
...(ownership.sampledProcessGroupIds ?? []),
]),
});
const factsByPid = new Map(facts.map((fact) => [fact.process.pid, fact]));
const verifiedDescendants: ProcessTableEntry[] = [];
let currentLauncher = nativeLauncher;
for (const member of candidates) {
const fact = factsByPid.get(member.pid);
if (fact === undefined)
return unavailableLineage(
ownership,
`native process facts were unavailable for PID ${String(member.pid)}`,
);
if (fact.state === "absent") {
if (member.pid === ownership.leaderPid)
return unavailableLineage(
ownership,
"owned launcher exited during lineage validation",
);
continue;
}
if (fact.state !== "same-generation")
return unavailableLineage(
ownership,
`process identity changed or became unavailable during lineage validation for PID ${String(member.pid)}`,
);
if (
fact.identityAfter.state === "readable" &&
fact.identityAfter.lifecycle?.state === "observed" &&
fact.identityAfter.lifecycle.lifecycle === "zombie"
) {
if (member.pid === ownership.leaderPid)
return unavailableLineage(
ownership,
"owned launcher exited during lineage validation",
);
continue;
}
if (fact.token.state !== "readable")
return unavailableLineage(
ownership,
`process ownership could not be revalidated for PID ${String(member.pid)}: ${fact.token.reason}`,
);
if (fact.token.runId !== ownership.runId)
return unavailableLineage(
ownership,
"process lineage contains an unowned or PID-reused process",
);
const nativeMember = processWithNativeRelation(
fact.process,
fact.identityAfter,
);
if (nativeMember === undefined)
return unavailableLineage(
ownership,
`native process relation was unavailable for PID ${String(member.pid)}`,
);
if (member.pid === ownership.leaderPid) {
const failure = launcherIdentityFailure(nativeMember, ownership);
if (failure !== null) return unavailableLineage(ownership, failure);
currentLauncher = nativeMember;
continue;
}
verifiedDescendants.push(nativeMember);
}
const currentDescendantPids = new Set(
descendantsOf(
[ownership.leaderPid],
[currentLauncher, ...verifiedDescendants],
).map(({ pid }) => pid),
);
const moved = verifiedDescendants.find(
({ pid }) => !currentDescendantPids.has(pid),
);
if (moved !== undefined)
return unavailableLineage(
ownership,
`process lineage relation changed during validation for PID ${String(moved.pid)}`,
);
return {
status: "verified",
observedAt: new Date().toISOString(),
lineage: {
runId: ownership.runId,
launcherPid: ownership.leaderPid,
launcherParentPid: currentLauncher.parentPid,
processGroupId: currentLauncher.processGroupId,
descendants: verifiedDescendants
.sort((left, right) => left.pid - right.pid)
.map(({ pid, parentPid, processGroupId }) => ({
pid,
parentPid,
processGroupId,
})),
},
};
};
const processWithNativeRelation = (
process: ProcessTableEntry,
identity: ProcessIdentityObservation,
): ProcessTableEntry | undefined =>
identity.state === "readable" && identity.relation?.state === "observed"
? {
...process,
parentPid: identity.relation.parentPid,
processGroupId: identity.relation.processGroupId,
}
: undefined;
const unavailableLineage = (
ownership: OwnedProcessGroup,
reason: string,
@@ -192,20 +480,149 @@ 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 identityFact = (
observations: ReadonlyMap<number, ProcessIdentityObservation> | undefined,
pid: number,
phase: "before" | "after",
failure?: string,
): ProcessIdentityObservation =>
observations?.get(pid) ?? {
state: "unavailable",
reason:
failure ??
`process identity was not returned during ${phase} observation`,
};
const readIdentityFacts = async (
processes: readonly ProcessTableEntry[],
host: ProcessOwnershipHost,
phase: "before" | "after",
signal?: AbortSignal,
): Promise<ReadonlyMap<number, ProcessIdentityObservation>> => {
signal?.throwIfAborted();
if (host.processIdentities === undefined)
return new Map<number, ProcessIdentityObservation>(
processes.map(({ pid }) => [
pid,
{
state: "unavailable",
reason: "process identity inspection is unsupported",
} as const,
]),
);
try {
const observations =
signal === undefined
? await host.processIdentities(processes)
: await host.processIdentities(processes, signal);
signal?.throwIfAborted();
return new Map<number, ProcessIdentityObservation>(
processes.map(({ pid }) => [pid, identityFact(observations, pid, phase)]),
);
} catch (cause: unknown) {
signal?.throwIfAborted();
const reason = errorMessage(cause);
return new Map<number, ProcessIdentityObservation>(
processes.map(({ pid }) => [
pid,
identityFact(undefined, pid, phase, reason),
]),
);
}
};
/** Read native identities/relations for caller-supplied rows without listing processes. */
export const readNativeProcessIdentityMap = async (
processes: readonly ProcessTableEntry[],
host: ProcessOwnershipHost,
signal?: AbortSignal,
): Promise<ReadonlyMap<number, ProcessIdentityObservation>> =>
readIdentityFacts(processes, host, "before", signal);
/**
* Read identity, token, then identity for caller-provided rows. This owner
* never enumerates the process table; a token is tied to a generation only
* when both native identity observations match.
*/
export const readProcessOwnershipFacts = async (
processes: readonly ProcessTableEntry[],
host: ProcessOwnershipHost,
signal?: AbortSignal,
options: {
readonly identityBefore?: ReadonlyMap<number, ProcessIdentityObservation>;
} = {},
): Promise<readonly ProcessOwnershipFact[]> => {
signal?.throwIfAborted();
if (processes.length === 0) return [];
const identityBefore =
options.identityBefore === undefined
? await readIdentityFacts(processes, host, "before", signal)
: new Map(
processes.map(({ pid }) => [
pid,
identityFact(options.identityBefore, pid, "before"),
]),
);
signal?.throwIfAborted();
const tokens = new Map<number, ProcessRunTokenObservation>();
try {
for await (const { process, observation } of readProcessRunTokens(
host,
processes,
signal,
)) {
signal?.throwIfAborted();
tokens.set(process.pid, observation);
}
} catch (cause: unknown) {
signal?.throwIfAborted();
const reason = errorMessage(cause);
for (const { pid } of processes)
if (!tokens.has(pid)) tokens.set(pid, { state: "unavailable", reason });
}
signal?.throwIfAborted();
const identityAfter = await readIdentityFacts(
processes,
host,
"after",
signal,
);
signal?.throwIfAborted();
const facts = processes.map((process): ProcessOwnershipFact => {
const before = identityBefore.get(process.pid) ?? {
state: "unavailable" as const,
reason: "process identity was not returned during before observation",
};
const after = identityAfter.get(process.pid) ?? {
state: "unavailable" as const,
reason: "process identity was not returned during after observation",
};
const token = tokens.get(process.pid) ?? {
state: "unavailable" as const,
reason: "process token was not returned",
};
const state =
after.state === "absent"
? "absent"
: before.state === "readable" && after.state === "readable"
? before.identity === after.identity
? "same-generation"
: "replaced"
: before.state === "absent" && after.state === "readable"
? "replaced"
: "unavailable";
return {
process,
state,
identityBefore: before,
token,
identityAfter: after,
};
});
signal?.throwIfAborted();
return facts;
};
const isExpectedAbort = (
@@ -288,26 +705,80 @@ export const createSystemProcessOwnershipHost = (
return new Map<number, ProcessIdentityObservation>(
await Promise.all(
processes.map(async ({ pid }) => {
if (!Number.isSafeInteger(pid) || pid <= 0)
return [
pid,
{ state: "unavailable", reason: "invalid_process_id" } as const,
] as const;
try {
const stat = await readFile(`/proc/${String(pid)}/stat`, {
encoding: "utf8",
...(signal === undefined ? {} : { signal }),
});
const identity = parseLinuxProcessStartTime(stat);
const parsed = parseLinuxProcessStat(stat);
return [
pid,
identity === undefined
parsed === undefined || parsed.pid !== pid
? ({
state: "unavailable",
reason: "malformed_proc_stat",
} as const)
: ({
state: "readable",
identity: `linux-starttime:${identity}`,
identity: `linux-starttime:${parsed.startTime}`,
lifecycle: {
state: "observed",
lifecycle: parsed.lifecycle,
},
relation: {
state: "observed",
parentPid: parsed.parentPid,
processGroupId: parsed.processGroupId,
},
} as const),
] as const;
} catch (cause: unknown) {
if (isExpectedAbort(cause, signal)) throw cause;
if (
cause instanceof Error &&
"code" in cause &&
(cause.code === "ENOENT" || cause.code === "ESRCH")
) {
signal?.throwIfAborted();
let presenceProbeFailure: unknown;
let presenceProbeSucceeded = false;
try {
presenceProbeSucceeded = process.kill(pid, 0);
} catch (probeCause: unknown) {
presenceProbeFailure = probeCause;
}
signal?.throwIfAborted();
if (
presenceProbeFailure instanceof Error &&
"code" in presenceProbeFailure &&
presenceProbeFailure.code === "ESRCH"
)
return [
pid,
{
state: "absent",
reason: "not-found-in-caller-pid-namespace",
} as const,
] as const;
return [
pid,
{
state: "unavailable",
reason:
presenceProbeFailure === undefined &&
presenceProbeSucceeded
? "proc_stat_missing_but_process_signal_probe_succeeded"
: presenceProbeFailure === undefined
? "proc_stat_missing_and_process_signal_probe_returned_false"
: `proc_stat_missing_and_process_signal_probe_failed: ${errorMessage(presenceProbeFailure)}`,
} as const,
] as const;
}
return [
pid,
{ state: "unavailable", reason: errorMessage(cause) } as const,
+2
View File
@@ -19,6 +19,7 @@ import {
prepareProcessOwnershipInspection,
systemProcessOwnershipHost,
} from "./ProcessOwnershipObservation.js";
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
const DEFAULT_TERMINATION_GRACE_MS = 250;
const DEFAULT_KILL_GRACE_MS = 1_000;
@@ -295,6 +296,7 @@ export const spawnOwnedProviderProcess = async (
leaderPid: pid,
processGroupId: pid,
expectedParentPid: process.pid,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
...(sweepTokenOwnedProcesses
? {
sweepTokenOwnedProcesses: true,
+11 -32
View File
@@ -1,4 +1,5 @@
import { readFile, readdir } from "node:fs/promises";
import { parseLinuxProcessStat } from "../LinuxProcessStat.js";
import {
mergeSamplingDiagnostics,
readProcSamplingSource,
@@ -27,43 +28,21 @@ const parseProcStat = (
pid: number,
stat: string,
): SampledProcessRow | undefined => {
const commandStart = stat.indexOf("(");
const commandEnd = stat.lastIndexOf(")");
const parsed = parseLinuxProcessStat(stat);
if (
commandStart < 0 ||
commandEnd <= commandStart ||
!/^\d+$/u.test(stat.slice(0, commandStart).trim()) ||
Number(stat.slice(0, commandStart).trim()) !== pid
)
return undefined;
const fields = stat
.slice(commandEnd + 2)
.trim()
.split(/\s+/u);
const parentPid = Number(fields[1]);
const processGroupId = Number(fields[2]);
const sessionId = Number(fields[3]);
const startTime = fields[19];
if (
!Number.isSafeInteger(pid) ||
pid <= 0 ||
!Number.isSafeInteger(parentPid) ||
parentPid < 0 ||
!Number.isSafeInteger(processGroupId) ||
processGroupId <= 0 ||
!Number.isSafeInteger(sessionId) ||
sessionId <= 0 ||
startTime === undefined ||
!/^\d+$/u.test(startTime)
parsed === undefined ||
parsed.pid !== pid ||
parsed.processGroupId <= 0 ||
parsed.sessionId <= 0
)
return undefined;
return {
pid,
parent_pid: parentPid,
process_group_id: processGroupId,
session_id: sessionId,
pid: parsed.pid,
parent_pid: parsed.parentPid,
process_group_id: parsed.processGroupId,
session_id: parsed.sessionId,
command: "",
startTime,
startTime: parsed.startTime,
};
};
+40 -54
View File
@@ -32,16 +32,16 @@ import {
import { classifyFilesystemEffects } from "./ProcessFilesystemEffects.js";
import {
cleanupOwnedProcessGroup,
verifyNoTokenOwnedProcesses,
type OwnedProcessGroup,
type ProcessCleanupResult,
type ProcessOwnershipBaseline,
type ProcessOwnershipHost,
} from "../ProcessOwnership.js";
import {
observeOwnedProcessGroups,
prepareProcessOwnershipInspection,
systemProcessOwnershipHost,
} from "../ProcessOwnershipObservation.js";
import { ProcessOwnershipEvidenceOwner } from "../ProcessOwnershipEvidence.js";
import {
describeProcessCaptureExecutionFailure,
normalizeCaptureFailure,
@@ -697,15 +697,27 @@ export const createRunManifest = async (
};
};
export const observeSettlement = async (
runId: string,
processGroupIds: readonly number[],
settleMs: number,
recordEvent: RecordProcessCaptureEvent = () => undefined,
platform: NodeJS.Platform = process.platform,
signal?: AbortSignal,
onLiveProgress?: () => void,
): Promise<ObservedProcessSettlement> => {
export const observeSettlement = async ({
runId,
processGroupIds,
ownershipEvidence,
settleMs,
recordEvent = () => undefined,
platform = process.platform,
signal,
ownershipHost,
onLiveProgress,
}: {
readonly runId: string;
readonly processGroupIds: readonly number[];
readonly ownershipEvidence: ProcessOwnershipEvidenceOwner;
readonly settleMs: number;
readonly recordEvent?: RecordProcessCaptureEvent;
readonly platform?: NodeJS.Platform;
readonly signal?: AbortSignal | undefined;
readonly ownershipHost?: ProcessOwnershipHost;
readonly onLiveProgress?: (() => void) | undefined;
}): Promise<ObservedProcessSettlement> => {
assertNotCancelled(signal);
onLiveProgress?.();
if (platform === "win32") {
@@ -722,8 +734,9 @@ export const observeSettlement = async (
const observations = await observeOwnedProcessGroups(
runId,
processGroupIds,
undefined,
ownershipHost,
signal,
ownershipEvidence,
);
assertNotCancelled(signal);
if (
@@ -866,10 +879,10 @@ export const awaitTerminalExit = async ({
Promise.race([
signalTarget(finalizationSignal),
delay(
PROCESS_CLEANUP_VERIFICATION_GRACE_MS,
PROCESS_FINALIZATION_DELIVERY_GRACE_MS,
{
delivery: "unverified" as const,
reason: `delivery did not settle within ${String(PROCESS_CLEANUP_VERIFICATION_GRACE_MS)} ms`,
reason: `delivery did not settle within ${String(PROCESS_FINALIZATION_DELIVERY_GRACE_MS)} ms`,
},
{
ref: false,
@@ -988,7 +1001,7 @@ export const awaitTerminalExit = async ({
const grace = new AbortController();
void Promise.race([
Promise.allSettled(pendingDeliveries),
delay(PROCESS_CLEANUP_VERIFICATION_GRACE_MS, undefined, {
delay(PROCESS_FINALIZATION_DELIVERY_GRACE_MS, undefined, {
ref: false,
signal: grace.signal,
}).catch(() => undefined),
@@ -1030,42 +1043,22 @@ 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 =
monotonicProcessCaptureClock() + PROCESS_CLEANUP_VERIFICATION_GRACE_MS;
let result = await verify();
while (!result.cleaned) {
const remaining = deadline - monotonicProcessCaptureClock();
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;
};
const PROCESS_FINALIZATION_DELIVERY_GRACE_MS = 1_000;
const releaseCapturedProcessGroup = async (
ownership: OwnedProcessGroup,
ownership: Extract<
OwnedProcessGroup,
{ readonly sweepTokenOwnedProcesses: true }
>,
host: ProcessCaptureCleanupHost,
): Promise<ProcessCaptureCleanupReport["owned_process_group"]> => {
if (host.platform === "win32")
@@ -1085,21 +1078,8 @@ const releaseCapturedProcessGroup = async (
try {
const cleaned = await host.cleanupProcessGroup(ownership);
unverified.push(...(cleaned.unverified ?? []));
if (!cleaned.cleaned) {
if (!cleaned.cleaned)
outcome = { state: "unverified", reason: cleaned.reason };
} else {
const verified = await verifyTokenOwnedProcessesUntilSettled(() =>
host.verifyTokenOwnedProcesses(
ownership.runId,
undefined,
ownership.captureBaseline,
ownership,
),
);
unverified.push(...(verified.unverified ?? []));
if (!verified.cleaned)
outcome = { state: "unverified", reason: verified.reason };
}
} catch (cause: unknown) {
outcome = {
state: "failed",
@@ -1132,6 +1112,8 @@ export interface ProcessCaptureResourceCleanupOptions {
readonly temporaryRoot: string;
readonly captureBaseline?: ProcessOwnershipBaseline;
readonly sampledProcessGroupIds?: readonly number[];
/** Shared with settlement observations for this exact captured run. */
readonly ownershipEvidence?: ProcessOwnershipEvidenceOwner;
readonly host?: ProcessCaptureCleanupHost;
}
@@ -1146,6 +1128,7 @@ export interface ProcessCaptureCleanupOutcome {
class ProcessCaptureResourceOwner {
readonly cleanupLease: AnalysisCleanupLease;
readonly #host: ProcessCaptureCleanupHost;
readonly #ownershipEvidence: ProcessOwnershipEvidenceOwner;
#terminalRenderer:
| ProcessCaptureCleanupReport["terminal_renderer"]
| undefined;
@@ -1156,6 +1139,8 @@ class ProcessCaptureResourceOwner {
constructor(private readonly options: ProcessCaptureResourceCleanupOptions) {
this.#host = options.host ?? processCaptureCleanupHost;
this.#ownershipEvidence =
options.ownershipEvidence ?? new ProcessOwnershipEvidenceOwner();
if (options.renderer === undefined)
this.#terminalRenderer = { state: "not_required", reason: null };
if (options.terminal === undefined)
@@ -1249,6 +1234,7 @@ class ProcessCaptureResourceOwner {
leaderPid: this.options.terminal.pid,
processGroupId: this.options.terminal.pid,
sweepTokenOwnedProcesses: true,
evidenceOwner: this.#ownershipEvidence,
...(this.options.sampledProcessGroupIds === undefined
? {}
: { sampledProcessGroupIds: this.options.sampledProcessGroupIds }),
@@ -0,0 +1,153 @@
import { describe, expect, it, vi } from "vitest";
import type {
ProcessIdentityObservation,
ProcessOwnershipHost,
ProcessTableEntry,
ProcessRunTokenObservation,
} from "../ProcessOwnership.js";
import { observeOwnedProcessGroups } from "../ProcessOwnershipObservation.js";
import { ProcessOwnershipEvidenceOwner } from "../ProcessOwnershipEvidence.js";
import {
observeSettlement,
ProcessCaptureResourceScope,
} from "./ProcessCaptureLifecycle.js";
import { cleanupOwnedProcessGroup } from "../ProcessOwnership.js";
const runId = "capture-run";
const root: ProcessTableEntry = {
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "capture-root",
};
const helper: ProcessTableEntry = {
pid: 101,
parentPid: 100,
processGroupId: 100,
state: "S",
command: "capture-helper",
};
describe("capture ownership evidence lifetime", () => {
it("retains a same-generation helper that moves groups before its token is withheld", async () => {
const owner = new ProcessOwnershipEvidenceOwner();
let helperMoved = false;
let rootAlive = true;
let helperIdentity = "start-101";
const processRows = (): readonly ProcessTableEntry[] => [
...(rootAlive ? [root] : []),
helperMoved ? { ...helper, parentPid: 1, processGroupId: 200 } : helper,
];
const processIdentities = (
members: readonly ProcessTableEntry[],
): Promise<ReadonlyMap<number, ProcessIdentityObservation>> =>
Promise.resolve(
new Map(
members.map((member) => [
member.pid,
!rootAlive && member.pid === 100
? {
state: "absent" as const,
reason: "not-found-in-caller-pid-namespace" as const,
}
: {
state: "readable" as const,
identity: member.pid === 101 ? helperIdentity : "start-100",
relation: {
state: "observed" as const,
parentPid: member.pid === 100 ? 1 : helperMoved ? 1 : 100,
processGroupId:
member.pid === 100 ? 100 : helperMoved ? 200 : 100,
},
},
]),
),
);
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve(processRows()),
environment: () => Promise.resolve({}),
processIdentities,
runTokens: (members) =>
Promise.resolve(
new Map<number, ProcessRunTokenObservation>(
members.map((member) => [
member.pid,
member.pid === 101 && helperMoved
? {
state: "unavailable" as const,
reason: "platform_binary_environment_withheld",
}
: { state: "readable" as const, runId },
]),
),
),
signalGroup: () => undefined,
};
await expect(
observeSettlement({
runId: runId,
processGroupIds: [100],
ownershipEvidence: owner,
settleMs: 0,
platform: "linux",
ownershipHost: host,
}),
).resolves.toMatchObject({ state: "alive_at_deadline" });
expect(owner.candidates.map(({ pid }) => pid)).toEqual([100, 101]);
helperMoved = true;
await observeOwnedProcessGroups(runId, [100], host, undefined, owner);
expect(owner.candidates).toContainEqual(
expect.objectContaining({
pid: 101,
identity: "start-101",
applicability: "token-verified",
relation: {
state: "observed",
parentPid: 1,
processGroupId: 200,
},
token: {
state: "unavailable",
reason: "platform_binary_environment_withheld",
},
}),
);
rootAlive = false;
const resources = new ProcessCaptureResourceScope();
const removeTemporaryRoot = vi.fn(async () => undefined);
const cleanup = await resources.release({
timers: new Set(),
terminal: { pid: 100 },
renderer: undefined,
runId,
temporaryRoot: "authored-root",
ownershipEvidence: owner,
host: {
platform: "linux",
cleanupProcessGroup: (ownership) =>
cleanupOwnedProcessGroup(ownership, host),
removeTemporaryRoot,
},
});
expect(cleanup.report.owned_process_group).toMatchObject({
state: "unverified",
});
expect(cleanup.report.temporary_root).toMatchObject({
state: "unverified",
});
expect(removeTemporaryRoot).not.toHaveBeenCalled();
helperIdentity = "replacement-101";
await observeOwnedProcessGroups(runId, [100], host, undefined, owner);
expect(owner.candidates).toEqual([]);
await resources.close();
expect(removeTemporaryRoot).toHaveBeenCalledExactlyOnceWith(
"authored-root",
);
});
});
+59 -180
View File
@@ -316,10 +316,6 @@ it("keeps empty cleanup exception messages actionable in the report", async () =
cleaned: false,
reason: "unused process cleanup",
}),
verifyTokenOwnedProcesses: async () => ({
cleaned: false,
reason: "unused process cleanup",
}),
removeTemporaryRoot: async () => {
throw new Error("");
},
@@ -339,132 +335,69 @@ it("keeps empty cleanup exception messages actionable in the report", async () =
});
});
it.each(["termination", "verification"] as const)(
"retains observations and retries the temporary root when process %s throws",
async (stage) => {
const cleanupFailure = new Error(`${stage} inspection failed`);
const removeTemporaryRoot = vi.fn(async () => undefined);
let failing = true;
const scope = new ProcessCaptureResourceScope();
const host: ProcessCaptureCleanupHost = {
platform: "linux",
cleanupProcessGroup: async () => {
if (failing && stage === "termination") throw cleanupFailure;
return {
cleaned: true,
signaled: true,
unverified: failing
? [{ pid: 900, diagnostic: "environment unavailable" }]
: [],
};
},
verifyTokenOwnedProcesses: async () => {
if (failing) throw cleanupFailure;
return { cleaned: true, signaled: false };
},
removeTemporaryRoot,
};
const cleanup = await scope.release({
timers: new Set(),
terminal: { pid: 321 },
renderer: undefined,
runId: "fixture-run",
temporaryRoot: "/fixture/root",
host,
});
it("retains observations and retries the temporary root when process cleanup throws", async () => {
const cleanupFailure = new Error("termination inspection failed");
const removeTemporaryRoot = vi.fn(async () => undefined);
let failing = true;
const scope = new ProcessCaptureResourceScope();
const host: ProcessCaptureCleanupHost = {
platform: "linux",
cleanupProcessGroup: async () => {
if (failing) throw cleanupFailure;
return { cleaned: true, signaled: true };
},
removeTemporaryRoot,
};
const cleanup = await scope.release({
timers: new Set(),
terminal: { pid: 321 },
renderer: undefined,
runId: "fixture-run",
temporaryRoot: "/fixture/root",
host,
});
expect(removeTemporaryRoot).not.toHaveBeenCalled();
expect(cleanup.report.temporary_root).toEqual({
state: "unverified",
reason: `temporary root retained because owned process cleanup is failed: ${cleanupFailure.message}`,
});
expect(cleanup.report.owned_process_group).toMatchObject({
state: "failed",
reason: cleanupFailure.message,
});
if (stage === "verification")
expect(cleanup.report.owned_process_group.unverified_processes).toEqual([
{ pid: 900, reason: "environment unavailable" },
]);
const executionFailure = new Error("capture snapshot failed");
const capture = parseProcessCapture({
...accountFullyObservedProcessCapture({
...emptyProcessCapture(),
frames: [{ sequence: 0, at_ms: 0, data: "collected before failure" }],
}),
event_journal: [],
});
try {
resolveProcessResult(capture, executionFailure, cleanup);
throw new Error("expected cleanup failure");
} catch (cause: unknown) {
if (!(cause instanceof ProcessCaptureError)) throw cause;
expect(cause.cause).toBe(executionFailure);
expect(
analysisErrorProjectionSchema.parse(projectAnalysisError(cause)),
).toMatchObject({
code: "cleanup_incomplete",
details: {
cleanup_report: cleanup.report,
execution_failure: "capture snapshot failed",
partial_observation: {
capture: { frames: [{ data: "collected before failure" }] },
},
},
});
}
failing = false;
await scope.retryCleanup();
expect(removeTemporaryRoot).toHaveBeenCalledExactlyOnceWith(
"/fixture/root",
);
await scope.close();
expect(removeTemporaryRoot).toHaveBeenCalledTimes(1);
},
);
it("waits for token-owned processes to exit after one cleanup signal", async () => {
vi.useFakeTimers();
expect(removeTemporaryRoot).not.toHaveBeenCalled();
expect(cleanup.report.temporary_root).toEqual({
state: "unverified",
reason: `temporary root retained because owned process cleanup is failed: ${cleanupFailure.message}`,
});
expect(cleanup.report.owned_process_group).toMatchObject({
state: "failed",
reason: cleanupFailure.message,
});
const executionFailure = new Error("capture snapshot failed");
const capture = parseProcessCapture({
...accountFullyObservedProcessCapture({
...emptyProcessCapture(),
frames: [{ sequence: 0, at_ms: 0, data: "collected before failure" }],
}),
event_journal: [],
});
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 };
resolveProcessResult(capture, executionFailure, cleanup);
throw new Error("expected cleanup failure");
} catch (cause: unknown) {
if (!(cause instanceof ProcessCaptureError)) throw cause;
expect(cause.cause).toBe(executionFailure);
expect(
analysisErrorProjectionSchema.parse(projectAnalysisError(cause)),
).toMatchObject({
code: "cleanup_incomplete",
details: {
cleanup_report: cleanup.report,
execution_failure: "capture snapshot failed",
partial_observation: {
capture: { frames: [{ data: "collected before failure" }] },
},
},
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 outcome = await result;
expect(outcome.report.owned_process_group).toEqual({
state: "cleaned",
reason: null,
});
expect(cleanupCalls).toBe(1);
expect(verificationCalls).toBeGreaterThan(1);
} finally {
vi.useRealTimers();
}
failing = false;
await scope.retryCleanup();
expect(removeTemporaryRoot).toHaveBeenCalledExactlyOnceWith("/fixture/root");
await scope.close();
expect(removeTemporaryRoot).toHaveBeenCalledTimes(1);
});
it("retains unrelated withheld processes through cleanup, capture, and JSON", async () => {
@@ -475,13 +408,9 @@ it("retains unrelated withheld processes through cleanup, capture, and JSON", as
signaled: true,
unverified: [
{ pid: 900, diagnostic: "platform_binary_environment_withheld" },
{ pid: 901, diagnostic: "foreign uid: EINVAL" },
],
}),
verifyTokenOwnedProcesses: async () => ({
cleaned: true,
signaled: false,
unverified: [{ pid: 901, diagnostic: "foreign uid: EINVAL" }],
}),
removeTemporaryRoot: async () => undefined,
};
const outcome = await releaseProcessResources({
@@ -525,52 +454,6 @@ it("retains unrelated withheld processes through cleanup, capture, and JSON", as
}
});
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 outcome = await result;
expect(outcome.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 = {
@@ -582,10 +465,6 @@ it("passes sampled detached groups into cleanup even when their token is unknown
reason: "sampled group has no verifiable run token",
};
},
verifyTokenOwnedProcesses: async () => ({
cleaned: true,
signaled: false,
}),
removeTemporaryRoot: async () => undefined,
};
+27 -9
View File
@@ -73,6 +73,7 @@ import {
type ProcessCaptureProgressTracker,
} from "./ProcessCaptureProgress.js";
import type { ProcessOwnershipBaseline } from "../ProcessOwnership.js";
import { ProcessOwnershipEvidenceOwner } from "../ProcessOwnershipEvidence.js";
import {
observeProcessStartIdentity,
signalProcessWithStartIdentity,
@@ -88,6 +89,8 @@ interface StartedCaptureRuntime {
readonly lastOutput: () => number;
readonly terminalRetention: () => TerminalCaptureRetention;
readonly stopSampler: ReturnType<typeof startProcessSampler>;
/** One ownership ledger shared by observations and final cleanup for this run. */
readonly ownershipEvidence: ProcessOwnershipEvidenceOwner;
/** Signals the captured root only while its launch-time start identity still matches. */
readonly signalRoot: (signal: "SIGTERM" | "SIGKILL") => Promise<{
readonly delivery: "signaled" | "gone" | "identity-changed" | "unverified";
@@ -168,6 +171,7 @@ const cleanupFailedStartup = async (options: {
readonly renderer: TerminalRenderer | undefined;
readonly runId: string;
readonly captureBaseline: ProcessOwnershipBaseline;
readonly ownershipEvidence: ProcessOwnershipEvidenceOwner;
readonly scenario: ProcessScenario;
readonly cleanupHost?: ProcessCaptureCleanupHost;
readonly observations?: ProcessCaptureObservationBuffer;
@@ -201,6 +205,7 @@ const cleanupFailedStartup = async (options: {
renderer: options.renderer,
runId: options.runId,
captureBaseline: options.captureBaseline,
ownershipEvidence: options.ownershipEvidence,
...(sampledProcessGroupIds === undefined ? {} : { sampledProcessGroupIds }),
temporaryRoot: options.temporaryRoot,
...(options.cleanupHost === undefined ? {} : { host: options.cleanupHost }),
@@ -250,6 +255,7 @@ interface StartCaptureRuntimeOptions {
readonly temporaryRoot: string;
readonly runId: string;
readonly ownershipBaseline: ProcessOwnershipBaseline;
readonly ownershipEvidence: ProcessOwnershipEvidenceOwner;
readonly cleanupHost?: ProcessCaptureCleanupHost;
readonly resourceScope?: ProcessCaptureResourceScope;
readonly observationBuffer: ProcessCaptureObservationBuffer;
@@ -268,6 +274,7 @@ const startCaptureRuntime = async (
options: StartCaptureRuntimeOptions,
): Promise<StartedCaptureRuntime> => {
const { scenario } = options;
const { ownershipEvidence } = options;
let renderer: TerminalRenderer | undefined;
let terminal: IPty | undefined;
try {
@@ -335,6 +342,7 @@ const startCaptureRuntime = async (
return {
renderer,
terminal,
ownershipEvidence,
started,
startedAt,
executableIdentity,
@@ -356,6 +364,7 @@ const startCaptureRuntime = async (
renderer,
runId: options.runId,
captureBaseline: options.ownershipBaseline,
ownershipEvidence,
scenario,
...(options.cleanupHost === undefined
? {}
@@ -374,6 +383,7 @@ const finishProcessRun = async (options: {
readonly timers: Set<ProcessTimer>;
readonly runId: string;
readonly captureBaseline: ProcessOwnershipBaseline;
readonly ownershipEvidence: ProcessOwnershipEvidenceOwner;
readonly cleanupHost?: ProcessCaptureCleanupHost;
readonly temporaryRoot: string;
readonly scenario: ProcessScenario;
@@ -403,6 +413,7 @@ const finishProcessRun = async (options: {
renderer: options.runtime?.renderer,
runId: options.runId,
captureBaseline: options.captureBaseline,
ownershipEvidence: options.ownershipEvidence,
...(sampledProcessGroupIds === undefined ? {} : { sampledProcessGroupIds }),
temporaryRoot: options.temporaryRoot,
...(options.cleanupHost === undefined ? {} : { host: options.cleanupHost }),
@@ -498,15 +509,19 @@ const completeCapture = async (options: {
throw processCaptureCancelled(options.signal?.reason);
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,
options.signal,
options.onLiveProgress,
);
settlement = await observeSettlement({
runId: options.runId,
processGroupIds: selectCapturedProcessGroupIds(
runtime.terminal.pid,
options.samples,
),
ownershipEvidence: runtime.ownershipEvidence,
settleMs: scenario.settle_ms,
recordEvent: options.recordEvent,
platform: options.hostPlatform,
signal: options.signal,
onLiveProgress: options.onLiveProgress,
});
} catch (cause: unknown) {
options.observationBuffer.settlement = {
state: "unavailable",
@@ -704,6 +719,7 @@ const runProcessScenario = async (
throw cause;
}
const { temporaryRoot, runId, ownershipBaseline, before } = prepared;
const ownershipEvidence = new ProcessOwnershipEvidenceOwner();
const journal = createProcessCaptureJournal();
const { entries: eventJournal, recordEvent } = journal;
recordEvent("filesystem_checkpoints", 0);
@@ -730,6 +746,7 @@ const runProcessScenario = async (
temporaryRoot,
runId,
ownershipBaseline,
ownershipEvidence,
...(cleanupHost === undefined ? {} : { cleanupHost }),
...(resourceScope === undefined ? {} : { resourceScope }),
observationBuffer: observations,
@@ -844,6 +861,7 @@ const runProcessScenario = async (
timers,
runId,
captureBaseline: ownershipBaseline,
ownershipEvidence,
...(cleanupHost === undefined ? {} : { cleanupHost }),
temporaryRoot,
scenario,
@@ -29,6 +29,8 @@ const fixture = vi.hoisted(() => ({
readonly read: number;
readonly pid?: number;
readonly parent?: number;
readonly processGroupId?: number;
readonly sessionId?: number;
readonly start?: number;
}
>(),
@@ -165,6 +167,7 @@ vi.mock("../ExecFileOutput.js", () => ({
}));
import { startProcessSampler } from "./ProcessSampling.js";
import { readLinuxProcessStat } from "./LinuxProcessSampling.js";
import type { ProcessSample } from "../../domain/process/processCapture.js";
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
@@ -195,8 +198,8 @@ const statLine = (pid: number, read: number): string => {
String(
mutation?.read === read ? (mutation.parent ?? row.parent) : row.parent,
),
"100",
"100",
String(mutation?.read === read ? (mutation.processGroupId ?? 100) : 100),
String(mutation?.read === read ? (mutation.sessionId ?? 100) : 100),
];
while (fields.length < 19) fields.push("0");
fields.push(
@@ -269,6 +272,28 @@ const waitFor = async (condition: () => boolean): Promise<void> => {
}
};
it("rejects a mismatched stat PID and zero process-group/session IDs at sampling admission", async () => {
setUpFixture();
fixture.statMutations.set(100, { read: 1, pid: 200 });
const wrongPid = await readLinuxProcessStat(
100,
new AbortController().signal,
);
expect(wrongPid.value).toBeUndefined();
setUpFixture();
fixture.statMutations.set(100, {
read: 1,
processGroupId: 0,
sessionId: 0,
});
const zeroGroups = await readLinuxProcessStat(
100,
new AbortController().signal,
);
expect(zeroGroups.value).toBeUndefined();
});
const flushImmediateProcPass = async (): Promise<void> => {
await new Promise((resolve) => setTimeout(resolve, 0));
await Promise.resolve();
+4 -4
View File
@@ -1,6 +1,6 @@
import { readFile } from "node:fs/promises";
import { parseLinuxProcessStartTime } from "./ProcessOwnershipObservation.js";
import { parseLinuxProcessStat } from "./LinuxProcessStat.js";
/** Result of terminating one debugger process owned by this application. */
export type OwnedDebuggerStopResult =
@@ -37,10 +37,10 @@ const readLinuxProcessStartTime: ProcessStartTimeReader = async (pid) => {
return null;
throw cause;
}
const startTime = parseLinuxProcessStartTime(stat);
if (startTime === undefined)
const parsed = parseLinuxProcessStat(stat);
if (parsed === undefined || parsed.pid !== Number(pid))
throw new Error(`Inferior PID ${pid} has an unreadable process identity.`);
return startTime;
return parsed.startTime;
};
/** Confirm only that every previously identified inferior process has ended. */
@@ -30,10 +30,6 @@ const cleanupOptions = (
const host: ProcessCaptureCleanupHost = {
platform: process.platform,
cleanupProcessGroup: async () => ({ cleaned: true, signaled: false }),
verifyTokenOwnedProcesses: async () => ({
cleaned: true,
signaled: false,
}),
removeTemporaryRoot,
};
return {
@@ -1,3 +1,4 @@
import { ProcessOwnershipEvidenceOwner } from "../../../src/process/ProcessOwnershipEvidence.js";
import { expect, it } from "vitest";
import { emptyUnverifiedProcessCapture } from "../../../src/domain/process/processCapture.fixture.js";
@@ -57,13 +58,14 @@ it("stamps process Evidence from the capture manifest host", () => {
it("uses the selected host platform for process settlement behavior", async () => {
const events: string[] = [];
const result = await observeSettlement(
"run",
[],
0,
(event) => events.push(event),
"win32",
);
const result = await observeSettlement({
runId: "run",
processGroupIds: [],
settleMs: 0,
recordEvent: (event) => events.push(event),
platform: "win32",
ownershipEvidence: new ProcessOwnershipEvidenceOwner(),
});
expect(result).toEqual({ state: "unverifiable", elapsed_ms: 0 });
expect(events).toEqual(["lifecycle"]);
@@ -1230,6 +1230,12 @@ it.skipIf(!onDarwin)(
expect(JSON.parse(stdout)).toEqual({
preservedOtherErrno: true,
retriedAfterFreshSize: true,
successfulSnapshotMissingPidIsAbsent: true,
failedSnapshotMissingPidIsUnavailable: true,
invalidPidIsUnavailable: true,
zombieLifecycleIsSeparateFromIdentity: true,
effectiveUidComesFromNativeIdentity: true,
nativeRelationIsSeparateFromIdentity: true,
});
} finally {
await rm(root, { recursive: true, force: true });
@@ -371,10 +371,6 @@ itWithCaptureCapability(
cleaned: false,
reason: "fixture cleanup could not be verified",
}),
verifyTokenOwnedProcesses: async () => ({
cleaned: false,
reason: "fixture cleanup could not be verified",
}),
removeTemporaryRoot: async (path) =>
rm(path, { recursive: true, force: true }),
};
@@ -21,10 +21,6 @@ const cleanupOptions = (
const host: ProcessCaptureCleanupHost = {
platform: process.platform,
cleanupProcessGroup: async () => ({ cleaned: true, signaled: false }),
verifyTokenOwnedProcesses: async () => ({
cleaned: true,
signaled: false,
}),
removeTemporaryRoot,
};
return {
@@ -1,3 +1,4 @@
import { ProcessOwnershipEvidenceOwner } from "../../../src/process/ProcessOwnershipEvidence.js";
import { expect, it } from "vitest";
import { parseProcessScenario } from "../../../src/domain/process/processScenario.js";
@@ -28,13 +29,14 @@ it("uses one selected host identity for manifest and settlement", async () => {
observeLaunchedExecutable(scenario.executable, selectedExecutable),
selectedHost,
);
const settlement = await observeSettlement(
"run",
[],
0,
() => undefined,
selectedHost.platform,
);
const settlement = await observeSettlement({
runId: "run",
processGroupIds: [],
settleMs: 0,
recordEvent: () => undefined,
platform: selectedHost.platform,
ownershipEvidence: new ProcessOwnershipEvidenceOwner(),
});
expect(manifest).toMatchObject(selectedHost);
expect(settlement).toEqual({ state: "unverifiable", elapsed_ms: 0 });
@@ -145,7 +145,12 @@ itWithCaptureCapability(
});
const equalSize = await capture("b");
expect(equalSize.truncated).toBe(false);
// This short-lived target can exit before process sampling finishes.
// Assert the terminal coverage that this normalization control exercises.
expect(equalSize.truncation_details.raw_terminal).toMatchObject({
observed_bytes: Buffer.byteLength(`${"a".repeat(100)}\r\n`),
retained_bytes: Buffer.byteLength(`${"a".repeat(100)}\r\n`),
});
expect(equalSize.frames.map(({ data }) => data).join("")).toBe(
`${"b".repeat(100)}\r\n`,
);
@@ -1,3 +1,4 @@
import { ProcessOwnershipEvidenceOwner } from "../../../src/process/ProcessOwnershipEvidence.js";
import { describe, expect, it } from "vitest";
import {
@@ -65,13 +66,14 @@ describe.skipIf(process.platform === "win32")(
(await observeGroup(ABSENT_GROUP_ID)).get(ABSENT_GROUP_ID)?.state,
).toBe("empty");
const settlement = await observeSettlement(
"settlement-single-empty",
[ABSENT_GROUP_ID],
0,
() => undefined,
"linux",
);
const settlement = await observeSettlement({
runId: "settlement-single-empty",
processGroupIds: [ABSENT_GROUP_ID],
settleMs: 0,
recordEvent: () => undefined,
platform: "linux",
ownershipEvidence: new ProcessOwnershipEvidenceOwner(),
});
expect(settlement.state).toBe("unverifiable");
});
@@ -107,13 +109,17 @@ describe.skipIf(process.platform === "win32")(
: undefined,
).toBe("alive");
const settlement = await observeSettlement(
runId,
[launched.ownership.processGroupId],
0,
() => undefined,
process.platform,
);
const ownershipEvidence = launched.ownership.evidenceOwner;
if (ownershipEvidence === undefined)
throw new Error("owned spawn omitted ownership evidence");
const settlement = await observeSettlement({
runId: runId,
processGroupIds: [launched.ownership.processGroupId],
settleMs: 0,
recordEvent: () => undefined,
platform: process.platform,
ownershipEvidence: ownershipEvidence,
});
expect(settlement.state).toBe("alive_at_deadline");
} finally {
await stopOwnedFixture(launched.process, launched.ownership);
@@ -121,13 +127,14 @@ describe.skipIf(process.platform === "win32")(
});
it("still reports two consecutive empty observations as quiesced", async () => {
const settlement = await observeSettlement(
"settlement-two-empty",
[ABSENT_GROUP_ID],
1_000,
() => undefined,
"linux",
);
const settlement = await observeSettlement({
runId: "settlement-two-empty",
processGroupIds: [ABSENT_GROUP_ID],
settleMs: 1_000,
recordEvent: () => undefined,
platform: "linux",
ownershipEvidence: new ProcessOwnershipEvidenceOwner(),
});
expect(settlement.state).toBe("quiesced");
});
},
@@ -22,7 +22,7 @@ import type {
SpawnedOwnedProviderProcess,
} from "../../../src/process/ProviderProcess.js";
import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js";
import { parseLinuxProcessStartTime } from "../../../src/process/ProcessOwnershipObservation.js";
import { parseLinuxProcessStat } from "../../../src/process/LinuxProcessStat.js";
import { expectGdbSessionStarted } from "./gdbTestHelpers.js";
let directory: string | undefined;
@@ -1941,7 +1941,9 @@ it.each([
expect(observedPid).toBe(pid);
if (process.platform === "linux") {
const stat = await readFile(`/proc/${observedPid}/stat`, "utf8");
capturedStartTime = parseLinuxProcessStartTime(stat);
const parsed = parseLinuxProcessStat(stat);
capturedStartTime =
parsed?.pid === Number(observedPid) ? parsed.startTime : undefined;
} else capturedStartTime = `test-inferior-${observedPid}`;
if (capturedStartTime === undefined)
throw new Error("Controlled inferior identity was unreadable");
@@ -1951,7 +1953,8 @@ it.each([
if (observedPid !== pid || !inferiorAlive) return null;
if (process.platform === "linux") {
const stat = await readFile(`/proc/${observedPid}/stat`, "utf8");
return parseLinuxProcessStartTime(stat) ?? null;
const parsed = parseLinuxProcessStat(stat);
return parsed?.pid === Number(observedPid) ? parsed.startTime : null;
}
return capturedStartTime ?? null;
},
+27 -1
View File
@@ -30,9 +30,35 @@ struct ProcessRunTokenSnapshotProbe {
return -1
}
let absentIdentities = readProcessIdentities(
pids: [42],
snapshot: ProcessSnapshot(metadata: [:], failure: nil)
)
let failedIdentities = readProcessIdentities(
pids: [42],
snapshot: ProcessSnapshot(metadata: [:], failure: "process_table_failed_13")
)
let invalidIdentity = readProcessIdentities(
pids: [0],
snapshot: ProcessSnapshot(metadata: [:], failure: nil)
)
let zombieIdentity = readProcessIdentities(
pids: [42],
snapshot: ProcessSnapshot(
metadata: [42: ProcessMetadata(identity: "start:42", effectiveUid: 501, lifecycle: "zombie", parentPid: 7, processGroupId: 9, pointerSize: 8)],
failure: nil
)
)
let output: [String: Any] = [
"retriedAfterFreshSize": retried.failure == nil && sizeQueries == 2 && readAttempts == 2,
"preservedOtherErrno": otherError.failure == "process_table_failed_\(EACCES)"
"preservedOtherErrno": otherError.failure == "process_table_failed_\(EACCES)",
"successfulSnapshotMissingPidIsAbsent": absentIdentities.first?.state == "absent" && absentIdentities.first?.reason == "not-in-snapshot",
"failedSnapshotMissingPidIsUnavailable": failedIdentities.first?.state == "unavailable" && failedIdentities.first?.reason == "process_table_failed_13",
"invalidPidIsUnavailable": invalidIdentity.first?.state == "unavailable" && invalidIdentity.first?.reason == "invalid_process_id",
"zombieLifecycleIsSeparateFromIdentity": zombieIdentity.first?.state == "readable" && zombieIdentity.first?.identity == "start:42" && zombieIdentity.first?.lifecycle == "zombie",
"effectiveUidComesFromNativeIdentity": zombieIdentity.first?.effective_uid == 501,
"nativeRelationIsSeparateFromIdentity": zombieIdentity.first?.relation?.parent_pid == 7 && zombieIdentity.first?.relation?.process_group_id == 9
]
let data = try JSONSerialization.data(withJSONObject: output, options: [.sortedKeys])
FileHandle.standardOutput.write(data)
@@ -120,9 +120,6 @@ it.each(["none", "before", "open", "final-stat", "close"] as const)(
cleanupProcessGroup: async () => {
throw new Error("No process should have been acquired");
},
verifyTokenOwnedProcesses: async () => {
throw new Error("No process should have been acquired");
},
removeTemporaryRoot: async (path) => {
await rm(path, { recursive: true });
removed.push(path);