diff --git a/docs/process-capture.md b/docs/process-capture.md index 1583585949..7277dbb700 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -472,7 +472,9 @@ Every capture requires `truncation_details`, with separate accounting for: Denied or failed procfs reads mark sampling partial while preserving readable process rows and thread children; a process with unreadable identity is not attributed. Expected process-exit - races are separate from failed observations. Shutdown counts only when it + races are separate from failed observations. A native token reader's confirmed + process absence during initialization is an expected exit; token-read denial + and unavailable snapshots still count as failed observations. Shutdown counts only when it interrupts a read or leaves traversal unfinished; a completed pass does not become partial merely because the sampler stops. Completed rows remain available and issued reads settle before final coverage is returned. diff --git a/src/process/ProcessOwnership.tokenAvailability.test.ts b/src/process/ProcessOwnership.tokenAvailability.test.ts new file mode 100644 index 0000000000..d71e57f634 --- /dev/null +++ b/src/process/ProcessOwnership.tokenAvailability.test.ts @@ -0,0 +1,41 @@ +import { expect, it, vi } from "vitest"; + +const source = vi.hoisted(() => ({ + present: true, + reason: "process_not_in_snapshot", +})); +vi.mock("./ExecFileOutput.js", () => ({ + execFileOutput: async () => ({ + stdout: source.present ? "100 1 100 501 S fixture" : "", + }), +})); +vi.mock("./DarwinProcessRunTokenReader.js", () => ({ + createDarwinProcessRunTokenReader: () => ({ + read: async () => + new Map([[100, { state: "unavailable", reason: source.reason }]]), + }), +})); + +import { createSystemProcessOwnershipHost } from "./ProcessOwnershipObservation.js"; +import { ProcessRunTokenReadError } from "./ProcessRunTokenObservations.js"; + +it.each([ + { present: false, reason: "unused", availability: "absent" }, + { present: true, reason: "process_not_in_snapshot", availability: "absent" }, + { present: true, reason: "sysctl_failed_13", availability: "unavailable" }, +])( + "retains native run-token availability at the host adapter: %j", + async (state) => { + Object.assign(source, state); + const host = createSystemProcessOwnershipHost("darwin", {}); + const failure = await host + .environment(100) + .catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(ProcessRunTokenReadError); + expect(failure).toMatchObject({ + pid: 100, + availability: state.availability, + reason: state.present ? state.reason : "process_not_in_snapshot", + }); + }, +); diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index 55df0b1071..11aac8dee2 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -23,7 +23,10 @@ import { createDarwinProcessRunTokenReader, type DarwinProcessReaderCompiler, } from "./DarwinProcessRunTokenReader.js"; -import { readProcessRunTokens } from "./ProcessRunTokenObservations.js"; +import { + ProcessRunTokenReadError, + readProcessRunTokens, +} from "./ProcessRunTokenObservations.js"; import type { OwnedProcessGroup, ProcessGroupObservation, @@ -1265,12 +1268,20 @@ export const createSystemProcessOwnershipHost = ( (entry) => entry.pid === pid, ); if (process === undefined) - throw new Error(`process ${String(pid)} is not live`); + throw new ProcessRunTokenReadError( + pid, + "absent", + "process_not_in_snapshot", + ); const observation = (await darwinTokens?.read([process], signal))?.get( pid, ); if (observation === undefined || observation.state === "unavailable") - throw new Error( + throw new ProcessRunTokenReadError( + pid, + observation?.reason === "process_not_in_snapshot" + ? "absent" + : "unavailable", observation?.reason ?? "process run token could not be read", ); const environment: Record = Object.create(null); diff --git a/src/process/ProcessRunTokenObservations.ts b/src/process/ProcessRunTokenObservations.ts index 076b6c936d..893a113413 100644 --- a/src/process/ProcessRunTokenObservations.ts +++ b/src/process/ProcessRunTokenObservations.ts @@ -4,6 +4,19 @@ import type { ProcessTableEntry, } from "./ProcessOwnership.js"; +/** Structured native token availability, distinct from failed reads and caller cancellation. */ +export class ProcessRunTokenReadError extends Error { + readonly _tag = "ProcessRunTokenReadError"; + constructor( + readonly pid: number, + readonly availability: "absent" | "unavailable", + readonly reason: string, + ) { + super(`Run token for PID ${String(pid)}: ${reason}`); + this.name = "ProcessRunTokenReadError"; + } +} + const errorMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); diff --git a/src/process/capture/ProcessSampling.procReadCoverage.test.ts b/src/process/capture/ProcessSampling.procReadCoverage.test.ts index 078f7fd265..5c04b95ed7 100644 --- a/src/process/capture/ProcessSampling.procReadCoverage.test.ts +++ b/src/process/capture/ProcessSampling.procReadCoverage.test.ts @@ -36,6 +36,7 @@ const fixture = vi.hoisted(() => ({ >(), runId: "fixture-run-token", runIdFailure: undefined as string | undefined, + runIdAvailability: undefined as "absent" | "unavailable" | undefined, runIdReads: 0, })); @@ -147,6 +148,15 @@ vi.mock("node:fs/promises", async (importOriginal) => { vi.mock("../ProcessOwnership.js", () => ({ readProcessRunId: async () => { fixture.runIdReads += 1; + if ( + fixture.runIdFailure !== undefined && + fixture.runIdAvailability !== undefined + ) + throw new ProcessRunTokenReadError( + 100, + fixture.runIdAvailability, + fixture.runIdFailure, + ); if (fixture.runIdFailure !== undefined) throw Object.assign(new Error(fixture.runIdFailure), { code: fixture.runIdFailure, @@ -166,6 +176,7 @@ vi.mock("../ExecFileOutput.js", () => ({ }, })); +import { ProcessRunTokenReadError } from "../ProcessRunTokenObservations.js"; import { startProcessSampler } from "./ProcessSampling.js"; import { readLinuxProcessStat } from "./LinuxProcessSampling.js"; import type { ProcessSample } from "../../domain/process/processCapture.js"; @@ -242,6 +253,7 @@ const setUpFixture = (): void => { fixture.statReads.clear(); fixture.statMutations.clear(); fixture.runIdFailure = undefined; + fixture.runIdAvailability = undefined; fixture.runIdReads = 0; for (const pid of [100, 200, 300, 400]) fixture.tasks.set(pid, [String(pid)]); fixture.children.set("100:100", "200 400"); @@ -626,3 +638,41 @@ it("marks a completed clean pass as neither partial nor interrupted", async () = expect(result.coverage.sampling_failures).toBe(0); expect(result.coverage.first_sampling_failure).toBeNull(); }); + +it.each(["absent", "unavailable"] as const)( + "classifies native token availability during Darwin sampler initialization: %s", + async (availability) => { + setUpFixture(); + Object.defineProperty(process, "platform", { + configurable: true, + value: "darwin", + }); + fixture.runIdAvailability = availability; + fixture.runIdFailure = + availability === "absent" + ? "process_not_in_snapshot" + : "permission_denied"; + try { + const { samples, stop } = start(); + await waitFor(() => fixture.runIdReads > 0); + await flushImmediateProcPass(); + const result = await stop(); + expect(samples).toEqual([]); + expect(result.partial).toBe(availability !== "absent"); + if (availability === "absent") { + expect(result.coverage.sampling_failures).toBe(0); + expect(result.coverage.first_sampling_failure).toBeNull(); + } else { + expect(result.coverage.sampling_failures).toBeGreaterThan(0); + expect(result.coverage.first_sampling_failure).toContain( + "permission_denied", + ); + } + } finally { + Object.defineProperty(process, "platform", { + configurable: true, + value: "linux", + }); + } + }, +); diff --git a/src/process/capture/ProcessSamplingObservation.ts b/src/process/capture/ProcessSamplingObservation.ts index 3f6a0f2207..7dfe7a1d1c 100644 --- a/src/process/capture/ProcessSamplingObservation.ts +++ b/src/process/capture/ProcessSamplingObservation.ts @@ -1,3 +1,4 @@ +import { ProcessRunTokenReadError } from "../ProcessRunTokenObservations.js"; import { err, ok, type Result } from "../../domain/result.js"; /** Retain a count, first failure and interruption fact with their observations. */ @@ -13,6 +14,7 @@ export interface SamplingObservation { } export type SamplingReadFailure = + | { readonly kind: "process-exited" } | { readonly kind: "cancelled"; readonly reason: unknown } | { readonly kind: "unavailable"; @@ -69,6 +71,11 @@ export const readSamplingSource = async ( } catch (cause: unknown) { if (isSamplingAbort(cause, signal)) return err({ kind: "cancelled", reason: signal.reason }); + if ( + cause instanceof ProcessRunTokenReadError && + cause.availability === "absent" + ) + return err({ kind: "process-exited" }); const systemCode = cause instanceof Error && "code" in cause && @@ -96,7 +103,9 @@ export const samplingReadObservation = ( undefined, result.error.kind === "cancelled" ? samplingInterrupted() - : samplingFailure(result.error.diagnostic), + : result.error.kind === "process-exited" + ? mergeSamplingDiagnostics() + : samplingFailure(result.error.diagnostic), ); /** Missing procfs entries are expected exit races; denied reads stay unknown. */