mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 05:16:16 +00:00
chore: refresh HTTP follow-up with latest main
This commit is contained in:
commit
5b14d4cea0
60 files changed
+5700
-1206
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -457,7 +457,9 @@ disposable HOME and CODEX_HOME under `~/.cache/rea-agent-evaluations/`. Set
|
||||
`REA_AGENT_EVAL_ROOT` to choose another parent directory outside the OS temporary
|
||||
directory: current Codex refuses helper aliases under `/tmp`, preventing shell
|
||||
commands and skill reads even if MCP works. Each owned run directory is removed
|
||||
after verification unless fixture retention is requested. The lane
|
||||
after verification unless fixture retention is requested. Cleanup attempts the
|
||||
remaining run tree even if auth removal fails, and reports unresolved cleanup
|
||||
together with the original verification failure. The lane
|
||||
installs the packaged skill and MCP registration through public `rea setup`,
|
||||
rather than copying a skill into a project that also inherits the caller's
|
||||
skills and configuration. Only authentication is copied, when present, and it
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,8 +18,11 @@ can run.
|
||||
|
||||
For DWARF, REA does not follow local debug links by default. Set `follow_debug_links` to `true` to pass `-WK`; external debug-file identities are not bound to Evidence when following links is enabled. The CLI flag is `--follow-debug-links`; `--no-follow-debug-links` or `--follow-debug-links=false` keeps the default. A separate `true` or `false` word after the flag is not its value. REA clears `DEBUGINFOD_URLS` in the objdump child environment to disable debuginfod network access on GNU binutils versions that do not support `-WE`. The complete raw output is authoritative; REA does not normalize it into a version-independent disassembly schema.
|
||||
|
||||
For a known native symbol, use `operation: "disassemble", symbol: "my_function"` in MCP or `--symbol my_function` in CLI. REA passes `--disassemble=my_function` to objdump before collecting output, rather than producing the whole disassembly and filtering it afterward. Omit `symbol` to keep full code-section disassembly. Other operations reject this selector. Use a nonempty, NUL-free name with well-formed Unicode; unsupported values are rejected before reading the artifact or starting objdump. Symbol names are passed unchanged; matching, versioned symbols, aliases and the end of the disassembly follow the installed GNU objdump's semantics. Evidence records the selector and actual arguments. An empty match does not establish artifact-wide absence, and the selected observation does not contain the omitted disassembly; request the complete operation when needed.
|
||||
|
||||
```sh
|
||||
rea inspect-with-objdump ./program disassemble
|
||||
rea inspect-with-objdump ./program disassemble --symbol my_function
|
||||
rea inspect-with-objdump ./program dwarf
|
||||
rea inspect-with-objdump ./program dwarf --follow-debug-links
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
summarizeFactualCorrectness,
|
||||
} from "../dist/evaluation/AgentEvaluationReport.js";
|
||||
import { PRODUCT_IDENTITY } from "../dist/identity.js";
|
||||
import { closeVerifierResources } from "./lib/verifier-cleanup.mjs";
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
@@ -68,6 +69,7 @@ const evaluationEnvironment = {
|
||||
PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? ""}`,
|
||||
};
|
||||
|
||||
let primaryError;
|
||||
try {
|
||||
const codexVersion = await readCommandVersion(codex);
|
||||
await Promise.all([
|
||||
@@ -391,11 +393,20 @@ try {
|
||||
throw new Error(
|
||||
`Agent routing/workflow/answer checks failed: ${failed.map(({ id }) => id).join(", ")}`,
|
||||
);
|
||||
} catch (cause) {
|
||||
primaryError = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
// Even a deliberately retained fixture directory must never retain auth.
|
||||
await rm(authPath, { force: true });
|
||||
if (process.env.REA_AGENT_EVAL_KEEP_FIXTURES !== "true")
|
||||
await rm(evaluationRoot, { recursive: true, force: true });
|
||||
await closeVerifierResources(
|
||||
[
|
||||
() => rm(authPath, { force: true }),
|
||||
...(process.env.REA_AGENT_EVAL_KEEP_FIXTURES === "true"
|
||||
? []
|
||||
: [() => rm(evaluationRoot, { recursive: true, force: true })]),
|
||||
],
|
||||
primaryError,
|
||||
);
|
||||
}
|
||||
|
||||
async function createTargets(root, includeManaged) {
|
||||
|
||||
@@ -190,11 +190,16 @@ export class ReverseEngineeringService {
|
||||
);
|
||||
const input = parsed.data;
|
||||
const command = this.#commands.objdump(input);
|
||||
let parameters: Readonly<Record<string, JsonValue>>;
|
||||
if (input.operation === "disassemble") {
|
||||
const { symbol, ...base } = input;
|
||||
parameters = symbol === undefined ? base : { ...base, symbol };
|
||||
} else parameters = input;
|
||||
return this.#observe(
|
||||
"inspect_with_objdump",
|
||||
command,
|
||||
input.operation === "architectures" ? undefined : input.path,
|
||||
input,
|
||||
parameters,
|
||||
options.signal,
|
||||
);
|
||||
}
|
||||
@@ -581,6 +586,12 @@ export class ReverseEngineeringService {
|
||||
]),
|
||||
"A non-zero process exit is preserved as an observed command result.",
|
||||
...(operation === "inspect_with_objdump" &&
|
||||
typeof parameters.symbol === "string"
|
||||
? [
|
||||
"Disassembly is scoped to the caller-selected symbol using the provider's matching and symbol-boundary semantics; an empty match does not establish artifact-wide absence.",
|
||||
]
|
||||
: []),
|
||||
...(operation === "inspect_with_objdump" &&
|
||||
parameters.follow_debug_links === true
|
||||
? [
|
||||
"DWARF output may include local debug-link files whose identities are not bound to Evidence.",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([]);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { evidenceSchema, type Evidence } from "../domain/evidence.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import { err } from "../domain/result.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { OBJDUMP_ARTIFACT_OPERATIONS } from "../domain/reverseEngineering.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
@@ -112,18 +113,16 @@ export const registerReverseEngineeringCommands = (
|
||||
.min(1)
|
||||
.describe("Path to the local artifact to inspect."),
|
||||
operation: z
|
||||
.enum([
|
||||
"file_headers",
|
||||
"section_headers",
|
||||
"symbols",
|
||||
"relocations",
|
||||
"dwarf",
|
||||
"disassemble",
|
||||
"disassemble_all",
|
||||
])
|
||||
.enum(OBJDUMP_ARTIFACT_OPERATIONS)
|
||||
.describe("GNU objdump inspection operation to run."),
|
||||
}),
|
||||
options: z.object({
|
||||
symbol: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Native symbol to select with the disassemble operation; GNU objdump controls matching and boundaries.",
|
||||
),
|
||||
followDebugLinks: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
@@ -139,6 +138,9 @@ export const registerReverseEngineeringCommands = (
|
||||
{
|
||||
...args,
|
||||
follow_debug_links: options.followDebugLinks,
|
||||
...(options.symbol === undefined
|
||||
? {}
|
||||
: { symbol: options.symbol }),
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
|
||||
@@ -29,7 +29,7 @@ export const REVERSE_ENGINEERING_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("inspect_with_objdump"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Run caller-selected GNU objdump inspection against a local artifact, or request operation=architectures without a path to list BFD targets advertised by the installed binary. Preserves decoded stdout and stderr inline with SHA-256 Evidence for artifact inspections, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. The raw output is authoritative and REA does not claim a stable normalized format. DWARF does not follow local debug links by default. Set follow_debug_links to true to follow them; identities of external debug files are not bound to Evidence. REA clears DEBUGINFOD_URLS for objdump so DWARF processing does not make debuginfod network requests. Architecture output is subjectless and does not establish support for any artifact.",
|
||||
"Run caller-selected GNU objdump inspection against a local artifact, or request operation=architectures without a path to list BFD targets advertised by the installed binary. For operation=disassemble, optional symbol selects native symbol-scoped disassembly before output collection; omission preserves all code sections. Matching and symbol boundaries follow GNU objdump. Preserves decoded stdout and stderr inline with SHA-256 Evidence for artifact inspections, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. The raw output is authoritative and REA does not claim a stable normalized format. DWARF does not follow local debug links by default. Set follow_debug_links to true to follow them; identities of external debug files are not bound to Evidence. REA clears DEBUGINFOD_URLS for objdump so DWARF processing does not make debuginfod network requests. Architecture output is subjectless and does not establish support for any artifact.",
|
||||
inputSchema: objdumpInputSchema,
|
||||
outputSchema: evidenceResultOf(commandObservationSchema),
|
||||
examples: [
|
||||
|
||||
@@ -67,6 +67,45 @@ it.each(["draft-2020-12", "draft-07"] as const)(
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["draft-2020-12", "draft-07"] as const)(
|
||||
"keeps objdump Unicode validation and portable SDK patterns in %s",
|
||||
(target) => {
|
||||
const canonical = toolInputSchemaWithMetadata(
|
||||
toolContract("inspect_with_objdump"),
|
||||
);
|
||||
const schema = canonical["~standard"].jsonSchema.input({ target });
|
||||
const input = { path: "sample.elf", operation: "disassemble" };
|
||||
for (const unicodeRegExp of [false, true]) {
|
||||
const options = { strict: false, validateFormats: false, unicodeRegExp };
|
||||
const ajv =
|
||||
target === "draft-2020-12" ? new Ajv2020(options) : new Ajv(options);
|
||||
expect(ajv.validateSchema(schema)).toBe(true);
|
||||
const validate = ajv.compile(schema);
|
||||
for (const symbol of [
|
||||
"known_function",
|
||||
"selector_\u{1F680}",
|
||||
"selector_\uFFFD",
|
||||
]) {
|
||||
expect(
|
||||
validate({ ...input, symbol }),
|
||||
JSON.stringify(validate.errors),
|
||||
).toBe(true);
|
||||
expect(canonical.safeParse({ ...input, symbol }).success).toBe(true);
|
||||
}
|
||||
expect(validate({ ...input, symbol: "a\u0000b" })).toBe(false);
|
||||
}
|
||||
// Scalar Unicode engines cannot express unpaired UTF-16 surrogates. The
|
||||
// registered canonical validator owns that runtime constraint.
|
||||
for (const symbol of [
|
||||
"\ud800",
|
||||
"\udc00",
|
||||
"prefix\ud800suffix",
|
||||
"\udc00\ud800",
|
||||
])
|
||||
expect(canonical.safeParse({ ...input, symbol }).success).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
describe("advertised object dependencies", () => {
|
||||
it.each([
|
||||
["draft-07", "dependencies"],
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const OBJDUMP_ARTIFACT_OPERATIONS = [
|
||||
"file_headers",
|
||||
"section_headers",
|
||||
"symbols",
|
||||
"relocations",
|
||||
"dwarf",
|
||||
"disassemble",
|
||||
"disassemble_all",
|
||||
] as const;
|
||||
|
||||
const objdumpArtifactInputSchema = z
|
||||
.object({
|
||||
path: z.string().min(1),
|
||||
operation: z.enum([
|
||||
"file_headers",
|
||||
"section_headers",
|
||||
"symbols",
|
||||
"relocations",
|
||||
"dwarf",
|
||||
"disassemble",
|
||||
"disassemble_all",
|
||||
]),
|
||||
operation: z.enum(OBJDUMP_ARTIFACT_OPERATIONS).exclude(["disassemble"]),
|
||||
follow_debug_links: z.boolean().default(false),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const objdumpDisassemblyInputSchema = objdumpArtifactInputSchema.extend({
|
||||
operation: z.literal("disassemble"),
|
||||
symbol: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[^\x00]*$/u)
|
||||
// Keep UTF-16 checks out of patterns consumed by scalar Unicode engines.
|
||||
.refine(
|
||||
(symbol) => !/[\uD800-\uDFFF]/u.test(symbol),
|
||||
"Symbol must be well-formed Unicode.",
|
||||
)
|
||||
.describe(
|
||||
"Native symbol to disassemble, expressed as well-formed Unicode. Matching and symbol boundaries follow GNU objdump; omitted means all code sections.",
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** Caller-selected artifact facet or pathless provider capability query. */
|
||||
export const objdumpInputSchema = z.discriminatedUnion("operation", [
|
||||
objdumpArtifactInputSchema,
|
||||
objdumpDisassemblyInputSchema,
|
||||
z.strictObject({ operation: z.literal("architectures") }),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -16,7 +16,10 @@ export const objdumpCommand = (input: ObjdumpInput): readonly string[] => {
|
||||
symbols: ["-t"],
|
||||
relocations: ["-r"],
|
||||
dwarf: ["-W", input.follow_debug_links ? "-WK" : "-WN"],
|
||||
disassemble: ["-d"],
|
||||
disassemble:
|
||||
input.operation === "disassemble" && input.symbol !== undefined
|
||||
? [`--disassemble=${input.symbol}`]
|
||||
: ["-d"],
|
||||
disassemble_all: ["-D"],
|
||||
} satisfies Record<typeof input.operation, readonly string[]>;
|
||||
return [
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -50,13 +50,14 @@ const observation = (completionStatus: "complete" | "unknown") =>
|
||||
const runObjdump = async (
|
||||
service: Partial<ReverseEngineeringService>,
|
||||
path = "/tmp/sample",
|
||||
selection: { operation?: string; symbol?: string } = {},
|
||||
) => {
|
||||
const commands = new Map<
|
||||
string,
|
||||
{
|
||||
run(input: {
|
||||
args: { path: string; operation: string };
|
||||
options: { followDebugLinks: boolean };
|
||||
options: { followDebugLinks: boolean; symbol?: string };
|
||||
}): Promise<unknown>;
|
||||
}
|
||||
>();
|
||||
@@ -66,7 +67,7 @@ const runObjdump = async (
|
||||
definition: {
|
||||
run(input: {
|
||||
args: { path: string; operation: string };
|
||||
options: { followDebugLinks: boolean };
|
||||
options: { followDebugLinks: boolean; symbol?: string };
|
||||
}): Promise<unknown>;
|
||||
},
|
||||
) => {
|
||||
@@ -85,11 +86,31 @@ const runObjdump = async (
|
||||
if (command === undefined)
|
||||
throw new Error("objdump CLI command was not registered");
|
||||
return command.run({
|
||||
args: { path, operation: "file_headers" },
|
||||
options: { followDebugLinks: false },
|
||||
args: { path, operation: selection.operation ?? "file_headers" },
|
||||
options: {
|
||||
followDebugLinks: false,
|
||||
...(selection.symbol === undefined ? {} : { symbol: selection.symbol }),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it("forwards the selected native symbol to the shared workflow", async () => {
|
||||
const inspectWithObjdump = vi.fn(async (_input: unknown) =>
|
||||
ok(observation("complete")),
|
||||
);
|
||||
await runObjdump(
|
||||
{ inspectWithObjdump, close: async () => undefined },
|
||||
"/tmp/sample",
|
||||
{ operation: "disassemble", symbol: "_Z3foov@@VERSION_1" },
|
||||
);
|
||||
expect(inspectWithObjdump.mock.calls[0]?.[0]).toEqual({
|
||||
path: "/tmp/sample",
|
||||
operation: "disassemble",
|
||||
follow_debug_links: false,
|
||||
symbol: "_Z3foov@@VERSION_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists objdump architectures without inventing an artifact path", async () => {
|
||||
const evidence = observation("complete");
|
||||
const inspectWithObjdump = vi.fn(async (_input: unknown) => ok(evidence));
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { chmod, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { closeVerifierResources } from "../../../scripts/lib/verifier-cleanup.mjs";
|
||||
import {
|
||||
execFileOutput,
|
||||
execFileOutputFailure,
|
||||
} from "../../../src/process/ExecFileOutput.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const receiptSchema = z.object({ root: z.string(), codex: z.string() });
|
||||
|
||||
// The executable fixture needs a POSIX shebang. Only the permission-denial
|
||||
// cases additionally need an ordinary user. No auth copying or model requests run.
|
||||
for (const { denied, keep } of [
|
||||
{ denied: false, keep: false },
|
||||
{ denied: true, keep: false },
|
||||
{ denied: false, keep: true },
|
||||
{ denied: true, keep: true },
|
||||
]) {
|
||||
it.skipIf(
|
||||
process.platform === "win32" || (denied && process.getuid?.() === 0),
|
||||
)(
|
||||
`cleans independent evaluation files and retains failures (denied: ${String(denied)}, keep: ${String(keep)})`,
|
||||
async () => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-agent-eval-cleanup-",
|
||||
);
|
||||
const receipt = join(directory, "receipt.json");
|
||||
const command = join(directory, "codex-fixture.mjs");
|
||||
const parent = join(directory, "evaluations");
|
||||
await writeFile(
|
||||
command,
|
||||
`#!${process.execPath}
|
||||
import { chmodSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
if (process.argv[2] !== "--version") throw new Error("Unexpected model invocation");
|
||||
const parent = ${JSON.stringify(parent)};
|
||||
const entries = readdirSync(parent);
|
||||
if (entries.length !== 1) throw new Error("Expected exactly one owned run");
|
||||
const root = join(parent, entries[0]);
|
||||
const codex = join(root, "codex");
|
||||
for (const child of ["codex", "account", "targets"]) mkdirSync(join(root, child));
|
||||
writeFileSync(join(codex, "auth.json"), "inert synthetic fixture");
|
||||
writeFileSync(${JSON.stringify(receipt)}, JSON.stringify({ root, codex }));
|
||||
if (${String(denied)}) chmodSync(codex, 0o500);
|
||||
process.stderr.write("synthetic version failure");
|
||||
process.exitCode = 1;
|
||||
`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
let run: { root: string; codex: string } | undefined;
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
let stderr: string;
|
||||
try {
|
||||
await execFileOutput(
|
||||
process.execPath,
|
||||
[
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"../../../scripts/verify-agent-experience.mjs",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
REA_CODEX_CLI: command,
|
||||
REA_AGENT_EVAL_ROOT: parent,
|
||||
REA_AGENT_EVAL_TIMEOUT_MS: "10000",
|
||||
REA_AGENT_EVAL_KEEP_FIXTURES: String(keep),
|
||||
},
|
||||
timeout: 15_000,
|
||||
stopSignal: "SIGTERM",
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
);
|
||||
throw new Error(
|
||||
"The synthetic version failure must reject verification",
|
||||
);
|
||||
} catch (cause) {
|
||||
const failure = execFileOutputFailure(cause);
|
||||
if (failure === undefined) throw cause;
|
||||
expect(failure.code).toBe(1);
|
||||
stderr = failure.stderr;
|
||||
}
|
||||
// Cleanup must preserve the original version diagnostic too.
|
||||
expect(stderr).toContain(
|
||||
"--version failed with 1: synthetic version failure",
|
||||
);
|
||||
run = receiptSchema.parse(JSON.parse(await readFile(receipt, "utf8")));
|
||||
if (denied) {
|
||||
expect.soft(stderr).toContain("Verifier cleanup failed");
|
||||
expect(stderr).toContain("EACCES");
|
||||
expect(stderr).toContain(join(run.codex, "auth.json"));
|
||||
expect(await readFile(join(run.codex, "auth.json"), "utf8")).toBe(
|
||||
"inert synthetic fixture",
|
||||
);
|
||||
} else {
|
||||
expect(stderr).not.toContain("Verifier cleanup failed");
|
||||
await expect(
|
||||
stat(join(run.codex, "auth.json")),
|
||||
).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
}
|
||||
for (const child of ["account", "targets"]) {
|
||||
const entry = stat(join(run.root, child));
|
||||
if (keep) expect((await entry).isDirectory()).toBe(true);
|
||||
else await expect(entry).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}
|
||||
if (!denied && !keep)
|
||||
await expect(stat(run.root)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
} catch (cause) {
|
||||
primaryError = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
// Restore this test's deliberately denied subtree before fixture teardown.
|
||||
if (denied)
|
||||
await closeVerifierResources(
|
||||
[
|
||||
async () => {
|
||||
try {
|
||||
run ??= receiptSchema.parse(
|
||||
JSON.parse(await readFile(receipt, "utf8")),
|
||||
);
|
||||
} catch (cause) {
|
||||
// Startup can fail before the executable creates its receipt.
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
cause.code === "ENOENT"
|
||||
)
|
||||
return;
|
||||
throw cause;
|
||||
}
|
||||
await chmod(run.codex, 0o700);
|
||||
},
|
||||
],
|
||||
primaryError,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -289,7 +289,7 @@ itWithCaptureCapability(
|
||||
);
|
||||
|
||||
itWithCaptureCapability(
|
||||
"reports no acquired resources when temporary-root creation fails",
|
||||
"reports startup cleanup coverage when temporary storage is unavailable",
|
||||
async () => {
|
||||
const scenario = await writeScenario(
|
||||
"rea-process-progress-prepare-failed-",
|
||||
@@ -312,14 +312,25 @@ itWithCaptureCapability(
|
||||
});
|
||||
|
||||
expect(capture.exitCode).toBe(1);
|
||||
const error = analysisCliErrorEnvelopeSchema.parse(
|
||||
JSON.parse(capture.stdout),
|
||||
);
|
||||
expect(error).toMatchObject({
|
||||
code: "process_capture_failed",
|
||||
details: { execution_failure: expect.stringContaining(missingTempRoot) },
|
||||
});
|
||||
// Darwin prepares its native inspector before acquiring capture resources.
|
||||
// Other hosts reach the capture root allocation, whose failure is accounted.
|
||||
const cleanupState =
|
||||
process.platform === "darwin" ? "unverified" : "not_required";
|
||||
expect(capture.lines.at(-1)?.update).toMatchObject({
|
||||
phase: "cleanup",
|
||||
terminal: true,
|
||||
details: {
|
||||
disposition: "failed",
|
||||
owned_process_group: "not_required",
|
||||
terminal_renderer: "not_required",
|
||||
temporary_root: "not_required",
|
||||
owned_process_group: cleanupState,
|
||||
terminal_renderer: cleanupState,
|
||||
temporary_root: cleanupState,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
import { createArtifact } from "../support/reverseEngineeringCompositionFixtures.js";
|
||||
import { TestReverseEngineeringService } from "../support/reverseEngineeringService.js";
|
||||
|
||||
it("keeps omitted and undefined selection out of default command Evidence", async () => {
|
||||
const { directory, path } = await createArtifact();
|
||||
const run = vi.fn(async (_command: string, _args: readonly string[]) => ({
|
||||
stdout: "complete disassembly\n",
|
||||
stderr: "",
|
||||
}));
|
||||
const service = new TestReverseEngineeringService({ run });
|
||||
try {
|
||||
for (const selector of [{}, { symbol: undefined }]) {
|
||||
const result = await service.inspectWithObjdump({
|
||||
path,
|
||||
operation: "disassemble",
|
||||
...selector,
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.parameters).not.toHaveProperty("symbol");
|
||||
expect(result.value.parameters.command_arguments).toEqual(["-d", path]);
|
||||
}
|
||||
} finally {
|
||||
await service.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"curl_version_info",
|
||||
"_Z3foov@@VERSION_1",
|
||||
"--leading-option with spaces\nand-newline",
|
||||
"native_\u{1F680}_symbol",
|
||||
"native_\uFFFD_symbol",
|
||||
])(
|
||||
"preserves native symbol %j as one argument and scoped Evidence",
|
||||
async (symbol) => {
|
||||
const { directory, path } = await createArtifact();
|
||||
const run = vi.fn(async (_command: string, _args: readonly string[]) => ({
|
||||
stdout: "selected disassembly\n",
|
||||
stderr: "",
|
||||
}));
|
||||
const service = new TestReverseEngineeringService({ run });
|
||||
try {
|
||||
const result = await service.inspectWithObjdump({
|
||||
path,
|
||||
operation: "disassemble",
|
||||
symbol,
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(run.mock.calls[0]?.[1]).toEqual([`--disassemble=${symbol}`, path]);
|
||||
expect(result.value.parameters).toMatchObject({
|
||||
operation: "disassemble",
|
||||
symbol,
|
||||
command_arguments: [`--disassemble=${symbol}`, path],
|
||||
});
|
||||
expect(result.value.normalized_result).toMatchObject({
|
||||
stdout: "selected disassembly\n",
|
||||
output_truncated: false,
|
||||
artifact_changed: false,
|
||||
completion_status: "complete",
|
||||
});
|
||||
expect(result.value.limitations).toContain(
|
||||
"Disassembly is scoped to the caller-selected symbol using the provider's matching and symbol-boundary semantics; an empty match does not establish artifact-wide absence.",
|
||||
);
|
||||
} finally {
|
||||
await service.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"architectures",
|
||||
"file_headers",
|
||||
"section_headers",
|
||||
"symbols",
|
||||
"relocations",
|
||||
"dwarf",
|
||||
"disassemble_all",
|
||||
])(
|
||||
"rejects symbol selection for %s before reading or launching",
|
||||
async (operation) => {
|
||||
const digest = vi.fn();
|
||||
const run = vi.fn();
|
||||
const service = new TestReverseEngineeringService({ digest, run });
|
||||
const result = await service.inspectWithObjdump({
|
||||
...(operation === "architectures" ? {} : { path: "/unread-artifact" }),
|
||||
operation,
|
||||
symbol: "my_function",
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisInputError" },
|
||||
});
|
||||
expect(digest).not.toHaveBeenCalled();
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
await service.close();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"",
|
||||
"a\u0000b",
|
||||
"\ud800",
|
||||
"\udc00",
|
||||
"prefix\ud800suffix",
|
||||
"prefix\udc00\ud800suffix",
|
||||
null,
|
||||
1,
|
||||
])("rejects invalid symbol %j before artifact admission", async (symbol) => {
|
||||
const digest = vi.fn();
|
||||
const run = vi.fn();
|
||||
const service = new TestReverseEngineeringService({ digest, run });
|
||||
const result = await service.inspectWithObjdump({
|
||||
path: "/unread-artifact",
|
||||
operation: "disassemble",
|
||||
symbol,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisInputError" },
|
||||
});
|
||||
expect(digest).not.toHaveBeenCalled();
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
await service.close();
|
||||
});
|
||||
+27
-1
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user