Propagate CLI interrupts to capture and interrupt post-exit settlement without cancelling owned-resource cleanup or discarding prior observations. Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
669 lines
22 KiB
TypeScript
669 lines
22 KiB
TypeScript
import type { IPty } from "@lydell/node-pty";
|
|
import type {
|
|
InteractionEvent,
|
|
ProcessCapture,
|
|
UnverifiedProcessCapture,
|
|
ProcessCaptureEventJournalEntry,
|
|
ProcessSample,
|
|
ProcessScenario,
|
|
RecordProcessCaptureEvent,
|
|
TerminalFrame,
|
|
} from "../../domain/process/processCapture.js";
|
|
import { parseProcessCapture } from "../../domain/process/processCapture.js";
|
|
import { err, ok, type Result } from "../../domain/result.js";
|
|
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
|
|
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
|
|
import {
|
|
describeProcessCaptureExecutionFailure,
|
|
ProcessCaptureError,
|
|
processCaptureCancelled,
|
|
} from "./ProcessCaptureError.js";
|
|
export { ProcessCaptureError } from "./ProcessCaptureError.js";
|
|
import { startProcessSampler } from "./ProcessSampling.js";
|
|
import { snapshotRoots } from "./FilesystemSnapshot.js";
|
|
import { TerminalRenderer } from "./TerminalRenderer.js";
|
|
import type { ProcessTimer } from "./ProcessTimer.js";
|
|
import { normalizeProcessText } from "./ProcessNormalization.js";
|
|
import { selectCapturedProcessGroupIds } from "../ProcessOwnershipProcessTree.js";
|
|
import {
|
|
awaitTerminalExit,
|
|
buildCaptureResult,
|
|
cleanupReportFailure,
|
|
captureTerminalFrames,
|
|
createProcessCaptureObservationBuffer,
|
|
createRunManifest,
|
|
observeLaunchedExecutable,
|
|
observeSelectedExecutable,
|
|
observeSettlement,
|
|
prepareProcessCapture,
|
|
releaseProcessResources,
|
|
resolveProcessResult,
|
|
settleProcessCaptureJournal,
|
|
type ProcessCaptureCleanupHost,
|
|
type ProcessCaptureObservationBuffer,
|
|
type PendingProcessCapture,
|
|
} from "./ProcessCaptureLifecycle.js";
|
|
import {
|
|
assertNotCancelled,
|
|
resolveProcessScenarioRuntimePaths,
|
|
} from "./ProcessScenarioRuntimeValidation.js";
|
|
import {
|
|
createProcessCaptureJournal,
|
|
scheduleScenarioInteractions,
|
|
} from "./ProcessCaptureJournal.js";
|
|
import { makeProcessCaptureEnvironment } from "./ProcessCaptureEnvironment.js";
|
|
import { classifyFilesystemEffects } from "./ProcessFilesystemEffects.js";
|
|
import { processCaptureOwnershipUnavailableReason } from "./ProcessCaptureCapability.js";
|
|
import type { ProcessOwnershipBaseline } from "../ProcessOwnership.js";
|
|
import { DarwinProcessOwnershipInspectionError } from "../DarwinProcessRunTokenReader.js";
|
|
export { probeProcessCaptureCapability } from "./ProcessCaptureCapability.js";
|
|
|
|
interface StartedCaptureRuntime {
|
|
readonly renderer: TerminalRenderer;
|
|
readonly terminal: IPty;
|
|
readonly started: number;
|
|
readonly startedAt: Date;
|
|
readonly executableIdentity: ReturnType<typeof observeLaunchedExecutable>;
|
|
readonly lastOutput: () => number;
|
|
readonly framesTruncated: () => boolean;
|
|
readonly stopSampler: () => Promise<{ readonly partial: boolean }>;
|
|
}
|
|
|
|
const cleanupFailedStartup = async (options: {
|
|
readonly cause: unknown;
|
|
readonly timers: Set<ProcessTimer>;
|
|
readonly terminal: IPty | undefined;
|
|
readonly renderer: TerminalRenderer | undefined;
|
|
readonly runId: string;
|
|
readonly captureBaseline: ProcessOwnershipBaseline;
|
|
readonly scenario: ProcessScenario;
|
|
readonly cleanupHost?: ProcessCaptureCleanupHost;
|
|
readonly observations?: ProcessCaptureObservationBuffer;
|
|
readonly temporaryRoot: string;
|
|
}): Promise<never> => {
|
|
const sampledProcessGroupIds =
|
|
options.terminal !== undefined &&
|
|
options.observations?.process_samples.state === "available"
|
|
? selectCapturedProcessGroupIds(
|
|
options.terminal.pid,
|
|
options.observations.process_samples.value,
|
|
).filter((groupId) => groupId !== options.terminal?.pid)
|
|
: undefined;
|
|
options.terminal?.kill("SIGKILL");
|
|
if (options.observations !== undefined && options.renderer !== undefined) {
|
|
try {
|
|
options.observations.rendered_frames = {
|
|
state: "available",
|
|
value: await options.renderer.frames(),
|
|
};
|
|
} catch (cause: unknown) {
|
|
options.observations.rendered_frames = {
|
|
state: "unavailable",
|
|
reason: `Rendered terminal frame collection failed: ${cause instanceof Error ? cause.message : "unknown failure"}`,
|
|
};
|
|
}
|
|
}
|
|
const cleanupFailure = await releaseProcessResources({
|
|
timers: options.timers,
|
|
terminal: options.terminal,
|
|
renderer: options.renderer,
|
|
runId: options.runId,
|
|
captureBaseline: options.captureBaseline,
|
|
...(sampledProcessGroupIds === undefined ? {} : { sampledProcessGroupIds }),
|
|
temporaryRoot: options.temporaryRoot,
|
|
...(options.cleanupHost === undefined ? {} : { host: options.cleanupHost }),
|
|
});
|
|
if (cleanupReportFailure(cleanupFailure) !== undefined) {
|
|
resolveProcessResult(
|
|
undefined,
|
|
options.cause,
|
|
cleanupFailure,
|
|
options.observations,
|
|
{
|
|
scenario: options.scenario,
|
|
...(options.terminal === undefined
|
|
? {}
|
|
: { rootPid: options.terminal.pid }),
|
|
},
|
|
);
|
|
}
|
|
throw options.cause;
|
|
};
|
|
|
|
const createTerminalRenderer = (
|
|
scenario: ProcessScenario,
|
|
temporaryRoot: string,
|
|
terminalPid: () => number,
|
|
recordEvent: RecordProcessCaptureEvent,
|
|
): TerminalRenderer =>
|
|
new TerminalRenderer({
|
|
columns: scenario.terminal.columns,
|
|
rows: scenario.terminal.rows,
|
|
scrollback: scenario.terminal.scrollback,
|
|
maxBytes: scenario.limits.output_bytes,
|
|
normalize: (value) =>
|
|
normalizeProcessText(value, scenario, temporaryRoot, terminalPid()),
|
|
recordEvent,
|
|
});
|
|
|
|
interface StartCaptureRuntimeOptions {
|
|
readonly scenario: ProcessScenario;
|
|
readonly hostEnvironment: Readonly<Record<string, string | undefined>>;
|
|
readonly temporaryRoot: string;
|
|
readonly runId: string;
|
|
readonly ownershipBaseline: ProcessOwnershipBaseline;
|
|
readonly cleanupHost?: ProcessCaptureCleanupHost;
|
|
readonly observationBuffer: ProcessCaptureObservationBuffer;
|
|
readonly onSpawn: (pid: number) => void;
|
|
readonly frames: TerminalFrame[];
|
|
readonly samples: ProcessSample[];
|
|
readonly interactions: InteractionEvent[];
|
|
readonly timers: Set<ProcessTimer>;
|
|
readonly dispatchedEventIndexes: Set<number>;
|
|
readonly recordEvent: RecordProcessCaptureEvent;
|
|
readonly signal?: AbortSignal;
|
|
}
|
|
|
|
const startCaptureRuntime = async (
|
|
options: StartCaptureRuntimeOptions,
|
|
): Promise<StartedCaptureRuntime> => {
|
|
const { scenario } = options;
|
|
let renderer: TerminalRenderer | undefined;
|
|
let terminal: IPty | undefined;
|
|
try {
|
|
const { spawn } = await import("@lydell/node-pty");
|
|
const selectedExecutable = await observeSelectedExecutable(
|
|
scenario.executable,
|
|
options.signal,
|
|
);
|
|
const startedAt = new Date();
|
|
const started = Date.now();
|
|
let lastOutput = started;
|
|
renderer = createTerminalRenderer(
|
|
scenario,
|
|
options.temporaryRoot,
|
|
() => terminal?.pid ?? -1,
|
|
options.recordEvent,
|
|
);
|
|
terminal = spawn(scenario.executable, [...scenario.arguments], {
|
|
cwd: scenario.working_directory,
|
|
env: makeProcessCaptureEnvironment(options),
|
|
cols: scenario.terminal.columns,
|
|
rows: scenario.terminal.rows,
|
|
name: "xterm-256color",
|
|
});
|
|
options.onSpawn(terminal.pid);
|
|
options.observationBuffer.target_pid = {
|
|
state: "available",
|
|
value: scenario.normalization.pids ? 1 : terminal.pid,
|
|
};
|
|
const executableIdentity = observeLaunchedExecutable(
|
|
scenario.executable,
|
|
selectedExecutable,
|
|
);
|
|
const framesTruncated = captureTerminalFrames({
|
|
...options,
|
|
terminal,
|
|
started,
|
|
onOutput: () => (lastOutput = Date.now()),
|
|
renderer,
|
|
});
|
|
scheduleScenarioInteractions({
|
|
...options,
|
|
getTerminal: () => terminal,
|
|
renderer,
|
|
started,
|
|
});
|
|
const stopSampler = startProcessSampler({
|
|
rootPid: terminal.pid,
|
|
runId: options.runId,
|
|
started,
|
|
limit: scenario.limits.processes,
|
|
samples: options.samples,
|
|
recordEvent: options.recordEvent,
|
|
});
|
|
return {
|
|
renderer,
|
|
terminal,
|
|
started,
|
|
startedAt,
|
|
executableIdentity,
|
|
lastOutput: () => lastOutput,
|
|
framesTruncated,
|
|
stopSampler,
|
|
};
|
|
} catch (cause: unknown) {
|
|
return cleanupFailedStartup({
|
|
cause,
|
|
timers: options.timers,
|
|
terminal,
|
|
renderer,
|
|
runId: options.runId,
|
|
captureBaseline: options.ownershipBaseline,
|
|
scenario,
|
|
...(options.cleanupHost === undefined
|
|
? {}
|
|
: { cleanupHost: options.cleanupHost }),
|
|
observations: options.observationBuffer,
|
|
temporaryRoot: options.temporaryRoot,
|
|
});
|
|
}
|
|
};
|
|
|
|
const finishProcessRun = async (options: {
|
|
readonly runtime: StartedCaptureRuntime | undefined;
|
|
readonly timers: Set<ProcessTimer>;
|
|
readonly runId: string;
|
|
readonly captureBaseline: ProcessOwnershipBaseline;
|
|
readonly cleanupHost?: ProcessCaptureCleanupHost;
|
|
readonly temporaryRoot: string;
|
|
readonly scenario: ProcessScenario;
|
|
readonly samples: readonly ProcessSample[];
|
|
readonly stopSampler: () => Promise<{ readonly partial: boolean }>;
|
|
readonly capture: PendingProcessCapture | undefined;
|
|
readonly executionFailure: unknown;
|
|
readonly observations?: ProcessCaptureObservationBuffer;
|
|
readonly rootPid?: number;
|
|
}): Promise<ProcessCapture> => {
|
|
await options.stopSampler();
|
|
const sampledProcessGroupIds =
|
|
options.runtime === undefined
|
|
? undefined
|
|
: selectCapturedProcessGroupIds(
|
|
options.runtime.terminal.pid,
|
|
options.samples,
|
|
).filter((groupId) => groupId !== options.runtime?.terminal.pid);
|
|
const cleanup = await releaseProcessResources({
|
|
timers: options.timers,
|
|
terminal: options.runtime?.terminal,
|
|
renderer: options.runtime?.renderer,
|
|
runId: options.runId,
|
|
captureBaseline: options.captureBaseline,
|
|
...(sampledProcessGroupIds === undefined ? {} : { sampledProcessGroupIds }),
|
|
temporaryRoot: options.temporaryRoot,
|
|
...(options.cleanupHost === undefined ? {} : { host: options.cleanupHost }),
|
|
});
|
|
const cleanupFailure = cleanupReportFailure(cleanup);
|
|
let { capture, executionFailure } = options;
|
|
let verifiedCapture: ProcessCapture | undefined;
|
|
if (capture !== undefined && cleanupFailure === undefined) {
|
|
const settlement: UnverifiedProcessCapture["settlement"] =
|
|
capture.settlement.state === "quiesced"
|
|
? { ...capture.settlement, cleanup_outcome: "not_required" }
|
|
: { ...capture.settlement, cleanup_outcome: "cleaned" };
|
|
const completedCapture: UnverifiedProcessCapture = {
|
|
...capture,
|
|
settlement,
|
|
cleanup: {
|
|
owned_process_group: "verified",
|
|
temporary_root: "removed",
|
|
},
|
|
};
|
|
try {
|
|
verifiedCapture = parseProcessCapture(completedCapture);
|
|
} catch (cause: unknown) {
|
|
executionFailure = new ProcessCaptureError(
|
|
`process capture validation failed${cause instanceof Error ? `: ${cause.message}` : ""}`,
|
|
{ cause },
|
|
);
|
|
}
|
|
}
|
|
const rootPid = options.runtime?.terminal.pid ?? options.rootPid;
|
|
const partialContext =
|
|
rootPid === undefined
|
|
? { scenario: options.scenario }
|
|
: { scenario: options.scenario, rootPid };
|
|
return resolveProcessResult(
|
|
cleanupFailure === undefined ? verifiedCapture : capture,
|
|
executionFailure,
|
|
cleanup,
|
|
options.observations,
|
|
partialContext,
|
|
);
|
|
};
|
|
|
|
/** Preserve typed process-inspection preflight failures at the capture boundary. */
|
|
export const normalizeCaptureFailure = (
|
|
cause: unknown,
|
|
signal: AbortSignal | undefined,
|
|
): unknown => {
|
|
if (cause instanceof ProcessCaptureError) return cause;
|
|
if (cause instanceof DarwinProcessOwnershipInspectionError)
|
|
return new ProcessCaptureError(cause.message, { cause });
|
|
if (
|
|
signal?.aborted === true &&
|
|
(cause === signal.reason ||
|
|
(cause instanceof Error && cause.name === "AbortError"))
|
|
)
|
|
return processCaptureCancelled();
|
|
return cause;
|
|
};
|
|
|
|
const completeCapture = async (options: {
|
|
readonly scenario: ProcessScenario;
|
|
readonly hostPlatform: NodeJS.Platform;
|
|
readonly runtime: StartedCaptureRuntime;
|
|
readonly runId: string;
|
|
readonly temporaryRoot: string;
|
|
readonly before: Awaited<ReturnType<typeof snapshotRoots>>;
|
|
readonly frames: readonly TerminalFrame[];
|
|
readonly samples: readonly ProcessSample[];
|
|
readonly interactions: readonly InteractionEvent[];
|
|
readonly exit: Awaited<ReturnType<typeof awaitTerminalExit>>;
|
|
readonly signal?: AbortSignal;
|
|
readonly captureSnapshot: typeof snapshotRoots;
|
|
readonly initiallyTruncated: boolean;
|
|
readonly eventJournal: readonly ProcessCaptureEventJournalEntry[];
|
|
readonly observationBuffer: ProcessCaptureObservationBuffer;
|
|
readonly recordEvent: RecordProcessCaptureEvent;
|
|
}): Promise<PendingProcessCapture> => {
|
|
const unavailableReason = (stage: string, cause: unknown): string =>
|
|
`${stage} failed: ${cause instanceof Error ? cause.message : "unknown failure"}`;
|
|
const { runtime, scenario } = options;
|
|
const { reason } = options.exit;
|
|
if (reason === "cancelled") throw processCaptureCancelled();
|
|
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,
|
|
);
|
|
} catch (cause: unknown) {
|
|
options.observationBuffer.settlement = {
|
|
state: "unavailable",
|
|
reason: unavailableReason("Process settlement observation", cause),
|
|
};
|
|
throw cause;
|
|
}
|
|
options.observationBuffer.settlement = {
|
|
state: "available",
|
|
value: settlement,
|
|
};
|
|
const samplingPartial = (await runtime.stopSampler()).partial;
|
|
await settleProcessCaptureJournal(options.eventJournal);
|
|
assertNotCancelled(options.signal);
|
|
let after: Awaited<ReturnType<typeof snapshotRoots>>;
|
|
try {
|
|
after = await options.captureSnapshot(scenario, options.signal);
|
|
} catch (cause: unknown) {
|
|
options.observationBuffer.filesystem_snapshots = {
|
|
...options.observationBuffer.filesystem_snapshots,
|
|
after: {
|
|
state: "unavailable",
|
|
reason: unavailableReason("Final filesystem snapshot", cause),
|
|
},
|
|
};
|
|
throw cause;
|
|
}
|
|
options.observationBuffer.filesystem_snapshots = {
|
|
...options.observationBuffer.filesystem_snapshots,
|
|
after: {
|
|
state: "available",
|
|
value: { files: after.files, truncated: after.truncated },
|
|
},
|
|
};
|
|
options.recordEvent("filesystem_checkpoints", 1);
|
|
let renderedFrames: Awaited<ReturnType<TerminalRenderer["frames"]>>;
|
|
try {
|
|
renderedFrames = await runtime.renderer.frames();
|
|
} catch (cause: unknown) {
|
|
options.observationBuffer.rendered_frames = {
|
|
state: "unavailable",
|
|
reason: unavailableReason("Rendered terminal frame collection", cause),
|
|
};
|
|
throw cause;
|
|
}
|
|
options.observationBuffer.rendered_frames = {
|
|
state: "available",
|
|
value: renderedFrames,
|
|
};
|
|
const checkpoints: UnverifiedProcessCapture["filesystem_checkpoints"] = [
|
|
{
|
|
name: "before",
|
|
at_ms: 0,
|
|
files: options.before.files,
|
|
effects: [],
|
|
truncated: options.before.truncated,
|
|
},
|
|
{
|
|
name: "after_settlement",
|
|
at_ms: Math.max(0, Date.now() - runtime.started),
|
|
files: after.files,
|
|
effects: classifyFilesystemEffects(options.before.files, after.files),
|
|
truncated: after.truncated,
|
|
},
|
|
];
|
|
let manifest: Awaited<ReturnType<typeof createRunManifest>>;
|
|
try {
|
|
manifest = await createRunManifest(
|
|
scenario,
|
|
runtime.startedAt,
|
|
new Date(),
|
|
runtime.executableIdentity,
|
|
{ platform: options.hostPlatform, architecture: process.arch },
|
|
);
|
|
} catch (cause: unknown) {
|
|
options.observationBuffer.manifest = {
|
|
state: "unavailable",
|
|
reason: unavailableReason("Capture manifest creation", cause),
|
|
};
|
|
throw cause;
|
|
}
|
|
options.observationBuffer.manifest = { state: "available", value: manifest };
|
|
const truncated =
|
|
options.initiallyTruncated ||
|
|
after.truncated ||
|
|
runtime.framesTruncated() ||
|
|
checkpoints.some(({ truncated: partial }) => partial) ||
|
|
samplingPartial ||
|
|
runtime.renderer.truncated();
|
|
return buildCaptureResult({
|
|
frames: options.frames,
|
|
exit: { ...options.exit, reason },
|
|
samples: options.samples,
|
|
before: options.before,
|
|
after,
|
|
truncated,
|
|
scenario,
|
|
rootPid: runtime.terminal.pid,
|
|
samplingPartial,
|
|
renderedFrames,
|
|
interactions: options.interactions,
|
|
checkpoints,
|
|
settlement,
|
|
manifest,
|
|
eventJournal: options.eventJournal,
|
|
});
|
|
};
|
|
|
|
/** Execute one caller-selected scenario and return bounded observations. */
|
|
const runProcessScenario = async (
|
|
scenario: ProcessScenario,
|
|
signal?: AbortSignal,
|
|
hostEnvironment: Readonly<Record<string, string | undefined>> = process.env,
|
|
hostPlatform: NodeJS.Platform = process.platform,
|
|
captureSnapshot: typeof snapshotRoots = snapshotRoots,
|
|
cleanupHost?: ProcessCaptureCleanupHost,
|
|
): Promise<ProcessCapture> => {
|
|
const { temporaryRoot, runId, ownershipBaseline, before } =
|
|
await prepareProcessCapture(scenario, signal, captureSnapshot);
|
|
const frames: TerminalFrame[] = [];
|
|
const samples: ProcessSample[] = [];
|
|
const interactions: InteractionEvent[] = [];
|
|
const journal = createProcessCaptureJournal();
|
|
const { entries: eventJournal, recordEvent } = journal;
|
|
recordEvent("filesystem_checkpoints", 0);
|
|
const observations = createProcessCaptureObservationBuffer({
|
|
frames,
|
|
interactions,
|
|
samples,
|
|
eventJournal,
|
|
before,
|
|
});
|
|
let runtime: StartedCaptureRuntime | undefined;
|
|
let actualRootPid: number | undefined;
|
|
const timers = new Set<ProcessTimer>();
|
|
let capture: PendingProcessCapture | undefined;
|
|
let executionFailure: unknown;
|
|
let stopSampler = async () => ({ partial: false });
|
|
const dispatchedEventIndexes = new Set<number>();
|
|
|
|
try {
|
|
runtime = await startCaptureRuntime({
|
|
scenario,
|
|
hostEnvironment,
|
|
temporaryRoot,
|
|
runId,
|
|
ownershipBaseline,
|
|
...(cleanupHost === undefined ? {} : { cleanupHost }),
|
|
observationBuffer: observations,
|
|
onSpawn: (pid) => {
|
|
actualRootPid = pid;
|
|
},
|
|
frames,
|
|
samples,
|
|
interactions,
|
|
timers,
|
|
dispatchedEventIndexes,
|
|
recordEvent,
|
|
...(signal === undefined ? {} : { signal }),
|
|
});
|
|
stopSampler = runtime.stopSampler;
|
|
const exit = await awaitTerminalExit({
|
|
terminal: runtime.terminal,
|
|
scenario,
|
|
started: runtime.started,
|
|
lastOutput: runtime.lastOutput,
|
|
signal,
|
|
timers,
|
|
interactions,
|
|
dispatchedEventIndexes,
|
|
recordEvent,
|
|
});
|
|
observations.exit = {
|
|
state: "available",
|
|
value: {
|
|
code:
|
|
exit.reason === "exited" && exit.exitCode >= 0 ? exit.exitCode : null,
|
|
signal: exit.signal ?? null,
|
|
reason: exit.reason,
|
|
},
|
|
};
|
|
capture = await completeCapture({
|
|
scenario,
|
|
hostPlatform,
|
|
runtime,
|
|
runId,
|
|
temporaryRoot,
|
|
before,
|
|
frames,
|
|
samples,
|
|
interactions,
|
|
exit,
|
|
initiallyTruncated: before.truncated,
|
|
eventJournal,
|
|
observationBuffer: observations,
|
|
recordEvent,
|
|
captureSnapshot,
|
|
...(signal === undefined ? {} : { signal }),
|
|
});
|
|
} catch (cause: unknown) {
|
|
runtime?.terminal.kill("SIGKILL");
|
|
executionFailure = normalizeCaptureFailure(cause, signal);
|
|
if (
|
|
runtime !== undefined &&
|
|
observations.rendered_frames.state === "unavailable" &&
|
|
observations.rendered_frames.reason.startsWith(
|
|
"Rendered terminal frames were not collected",
|
|
)
|
|
) {
|
|
try {
|
|
observations.rendered_frames = {
|
|
state: "available",
|
|
value: await runtime.renderer.frames(),
|
|
};
|
|
} catch (renderCause: unknown) {
|
|
const renderFailure =
|
|
renderCause instanceof Error
|
|
? renderCause.message
|
|
: "unknown failure";
|
|
observations.rendered_frames = {
|
|
state: "unavailable",
|
|
reason: `Rendered terminal frame collection failed: ${renderFailure}`,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
return finishProcessRun({
|
|
runtime,
|
|
timers,
|
|
runId,
|
|
captureBaseline: ownershipBaseline,
|
|
...(cleanupHost === undefined ? {} : { cleanupHost }),
|
|
temporaryRoot,
|
|
scenario,
|
|
samples,
|
|
stopSampler,
|
|
capture,
|
|
executionFailure,
|
|
observations,
|
|
...(actualRootPid === undefined ? {} : { rootPid: actualRootPid }),
|
|
});
|
|
};
|
|
|
|
/** Execute one scenario through a typed expected-failure channel. */
|
|
export const captureProcessScenario = async (
|
|
scenario: ProcessScenario,
|
|
signal?: AbortSignal,
|
|
platform: NodeJS.Platform = process.platform,
|
|
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
captureSnapshot: typeof snapshotRoots = snapshotRoots,
|
|
cleanupHost?: ProcessCaptureCleanupHost,
|
|
): Promise<Result<ProcessCapture, ProcessCaptureError | AnalysisError>> => {
|
|
const ownershipReason = processCaptureOwnershipUnavailableReason(platform);
|
|
if (ownershipReason !== undefined)
|
|
return err(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"rea-process",
|
|
"capture_process_scenario",
|
|
ownershipReason,
|
|
{ userMessage: ownershipReason },
|
|
),
|
|
);
|
|
try {
|
|
assertNotCancelled(signal);
|
|
const resolvedScenario = await resolveProcessScenarioRuntimePaths(
|
|
scenario,
|
|
environment,
|
|
);
|
|
return ok(
|
|
await runProcessScenario(
|
|
resolvedScenario,
|
|
signal,
|
|
environment,
|
|
platform,
|
|
captureSnapshot,
|
|
cleanupHost,
|
|
),
|
|
);
|
|
} catch (cause: unknown) {
|
|
const failure = normalizeCaptureFailure(cause, signal);
|
|
const executionFailureReason =
|
|
describeProcessCaptureExecutionFailure(cause);
|
|
return err(
|
|
failure instanceof ProcessCaptureError
|
|
? failure
|
|
: new ProcessCaptureError("process capture failed", {
|
|
cause,
|
|
...(executionFailureReason === undefined
|
|
? {}
|
|
: { executionFailure: executionFailureReason }),
|
|
}),
|
|
);
|
|
}
|
|
};
|