mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 09:21:16 +00:00
fix(process): localize capture truncation diagnostics (#1284)
Report per-source capture coverage and existing file-hash omissions while preserving comparable observations in unaffected dimensions. Keep legacy truncated captures conservative and retain known partial trace events. Closes #1250 Partially addresses #1251; independent hashing remains out of scope.
This commit is contained in:
1 parent
7e96f25f8b
commit
33bf585185
14 files changed
+739
-50
No files matched your search
@@ -7,12 +7,14 @@ import type {
|
||||
ProcessScenario,
|
||||
} from "../../domain/process/processCapture.js";
|
||||
import type { Stats } from "node:fs";
|
||||
import type { FilesystemCoverage } from "../../domain/process/processCaptureCoverage.js";
|
||||
|
||||
export interface SnapshotResult {
|
||||
readonly files: readonly FileState[];
|
||||
readonly truncated: boolean;
|
||||
/** Root aliases whose path enumeration was exhausted, regardless of hash coverage. */
|
||||
readonly completeRoots: readonly string[];
|
||||
readonly coverage?: FilesystemCoverage;
|
||||
}
|
||||
|
||||
const hasSameIdentity = (
|
||||
@@ -97,6 +99,10 @@ export const snapshotRoots = async (
|
||||
const completeRoots: string[] = [];
|
||||
let remainingBytes = scenario.limits.file_bytes;
|
||||
let truncated = false;
|
||||
const enumerationReasons = new Set<
|
||||
FilesystemCoverage["enumeration_reasons"][number]
|
||||
>();
|
||||
const hashOmissions: FilesystemCoverage["hash_omissions"] = [];
|
||||
const visit = async (
|
||||
root: string,
|
||||
rootAlias: string,
|
||||
@@ -109,6 +115,10 @@ export const snapshotRoots = async (
|
||||
depth > scenario.limits.filesystem_depth
|
||||
) {
|
||||
truncated = true;
|
||||
if (entries.length >= scenario.limits.files)
|
||||
enumerationReasons.add("files_limit");
|
||||
if (depth > scenario.limits.filesystem_depth)
|
||||
enumerationReasons.add("depth_limit");
|
||||
return false;
|
||||
}
|
||||
const stats = await lstatIfPresent(path);
|
||||
@@ -119,6 +129,7 @@ export const snapshotRoots = async (
|
||||
const afterRead = await lstat(path);
|
||||
if (!hasSameIdentity(stats, afterRead, "symlink")) {
|
||||
truncated = true;
|
||||
enumerationReasons.add("identity_changed");
|
||||
return false;
|
||||
}
|
||||
entries.push({
|
||||
@@ -137,7 +148,18 @@ export const snapshotRoots = async (
|
||||
? await hashFile(path, stats, remainingBytes, signal)
|
||||
: null;
|
||||
remainingBytes -= sha256 === null ? 0 : stats.size;
|
||||
if (sha256 === null) truncated = true;
|
||||
if (sha256 === null) {
|
||||
truncated = true;
|
||||
hashOmissions.push({
|
||||
path: `${rootAlias}:${relativePath}`,
|
||||
size_bytes: stats.size,
|
||||
remaining_budget_bytes: remainingBytes,
|
||||
reason:
|
||||
remainingBytes < stats.size
|
||||
? "file_bytes_budget"
|
||||
: "file_changed_or_short_read",
|
||||
});
|
||||
}
|
||||
entries.push({
|
||||
path: `${rootAlias}:${relativePath}`,
|
||||
type: "file",
|
||||
@@ -162,6 +184,7 @@ export const snapshotRoots = async (
|
||||
const afterRead = await lstat(path);
|
||||
if (!hasSameIdentity(stats, afterRead, "directory")) {
|
||||
truncated = true;
|
||||
enumerationReasons.add("identity_changed");
|
||||
return false;
|
||||
}
|
||||
let complete = true;
|
||||
@@ -179,5 +202,19 @@ export const snapshotRoots = async (
|
||||
}
|
||||
if (await visit(root, alias, root, 0)) completeRoots.push(alias);
|
||||
}
|
||||
return { files: entries, truncated, completeRoots };
|
||||
return {
|
||||
files: entries,
|
||||
truncated,
|
||||
completeRoots,
|
||||
coverage: {
|
||||
files_limit: scenario.limits.files,
|
||||
depth_limit: scenario.limits.filesystem_depth,
|
||||
enumeration_truncated:
|
||||
completeRoots.length !== scenario.filesystem_observation_paths.length,
|
||||
enumeration_reasons: [...enumerationReasons],
|
||||
hash_budget_bytes: scenario.limits.file_bytes,
|
||||
hashed_bytes: scenario.limits.file_bytes - remainingBytes,
|
||||
hash_omissions: hashOmissions,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -59,6 +59,11 @@ import {
|
||||
} from "./ProcessNormalization.js";
|
||||
import { PROCESS_PROVIDER } from "../../domain/process/processEvidenceProvider.js";
|
||||
import { TerminalRenderer } from "./TerminalRenderer.js";
|
||||
import {
|
||||
hasCaptureTruncation,
|
||||
type TerminalRetention,
|
||||
type ProcessCaptureTruncationDetails,
|
||||
} from "../../domain/process/processCaptureCoverage.js";
|
||||
import { scheduleProcessInterval, type ProcessTimer } from "./ProcessTimer.js";
|
||||
|
||||
interface TerminalExitOptions {
|
||||
@@ -84,6 +89,7 @@ interface CaptureResultOptions {
|
||||
readonly before: SnapshotResult;
|
||||
readonly after: SnapshotResult;
|
||||
readonly truncated: boolean;
|
||||
readonly truncationDetails?: ProcessCaptureTruncationDetails;
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly rootPid: number;
|
||||
readonly samplingPartial: boolean;
|
||||
@@ -279,7 +285,13 @@ export const buildCaptureResult = (
|
||||
files_before: options.before.files,
|
||||
files_after: options.after.files,
|
||||
filesystem_effects: filesystemEffects,
|
||||
truncated: options.truncated,
|
||||
truncated:
|
||||
options.truncationDetails === undefined
|
||||
? options.truncated
|
||||
: hasCaptureTruncation(options.truncationDetails),
|
||||
...(options.truncationDetails === undefined
|
||||
? {}
|
||||
: { truncation_details: options.truncationDetails }),
|
||||
limitations: [
|
||||
"The executable digest is a prelaunch file sample; matching path metadata immediately after spawn does not prove an atomic operating-system image binding.",
|
||||
"Process trees are sampled and may omit short-lived descendants.",
|
||||
@@ -290,6 +302,11 @@ export const buildCaptureResult = (
|
||||
"Inherited host environment variables are not recorded and may affect results.",
|
||||
...(!hasFilesystemObservations ? [filesystemObservationUnknown] : []),
|
||||
...(hasUnknownFilesystemEffects ? [incompleteFilesystemUnknown] : []),
|
||||
...(options.truncationDetails === undefined
|
||||
? []
|
||||
: captureCoverageUnknowns(options.truncationDetails).map(
|
||||
({ reason }) => reason,
|
||||
)),
|
||||
...(hasSensitiveScriptedInput ? [sensitiveInputUnknown] : []),
|
||||
],
|
||||
residual_unknowns: [
|
||||
@@ -330,10 +347,61 @@ export const buildCaptureResult = (
|
||||
...(hasSensitiveScriptedInput
|
||||
? [{ scope: "interaction" as const, reason: sensitiveInputUnknown }]
|
||||
: []),
|
||||
...(options.truncationDetails === undefined
|
||||
? []
|
||||
: captureCoverageUnknowns(options.truncationDetails).filter(
|
||||
({ scope }) => scope !== "terminal",
|
||||
)),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const captureCoverageUnknowns = (
|
||||
details: ProcessCaptureTruncationDetails,
|
||||
): UnverifiedProcessCapture["residual_unknowns"] => {
|
||||
const unknowns: Array<UnverifiedProcessCapture["residual_unknowns"][number]> =
|
||||
[];
|
||||
if (
|
||||
details.raw_terminal.observed_frames > details.raw_terminal.retained_frames
|
||||
)
|
||||
unknowns.push({
|
||||
scope: "terminal",
|
||||
reason:
|
||||
"Raw PTY chunks exceeded output_bytes and were omitted; rendered states also lack those inputs. See truncation_details.raw_terminal.",
|
||||
});
|
||||
if (
|
||||
details.rendered_terminal.observed_frames >
|
||||
details.rendered_terminal.retained_frames
|
||||
)
|
||||
unknowns.push({
|
||||
scope: "terminal",
|
||||
reason:
|
||||
"Cumulative rendered state and visible-line bytes exceeded output_bytes; whole rendered frames were omitted. Raw PTY coverage is reported separately. See truncation_details.rendered_terminal.",
|
||||
});
|
||||
for (const [name, coverage] of [
|
||||
["before", details.filesystem_before],
|
||||
["after_settlement", details.filesystem_after],
|
||||
] as const) {
|
||||
if (coverage.enumeration_truncated)
|
||||
unknowns.push({
|
||||
scope: "filesystem",
|
||||
reason: `Filesystem ${name} path enumeration was incomplete: ${coverage.enumeration_reasons.join(", ")}. See truncation_details.`,
|
||||
});
|
||||
if (coverage.hash_omissions.length > 0)
|
||||
unknowns.push({
|
||||
scope: "filesystem",
|
||||
reason: `Filesystem ${name} omitted ${String(coverage.hash_omissions.length)} whole-file digests; per-path reasons and remaining file_bytes budget are in truncation_details.`,
|
||||
});
|
||||
}
|
||||
if (details.process.sampling_partial)
|
||||
unknowns.push({
|
||||
scope: "process",
|
||||
reason:
|
||||
"Process sampling stopped with incomplete observations; sample_limit is reported in truncation_details.process.",
|
||||
});
|
||||
return unknowns;
|
||||
};
|
||||
|
||||
interface ExecutableFileIdentity {
|
||||
readonly dev: bigint;
|
||||
readonly ino: bigint;
|
||||
@@ -840,14 +908,16 @@ export const captureTerminalFrames = (options: {
|
||||
readonly onOutput: () => void;
|
||||
readonly renderer: TerminalRenderer;
|
||||
readonly recordEvent: RecordProcessCaptureEvent;
|
||||
}): (() => boolean) => {
|
||||
}): (() => TerminalRetention) => {
|
||||
let outputBytes = 0;
|
||||
let truncated = false;
|
||||
let observedBytes = 0;
|
||||
let observedFrames = 0;
|
||||
options.terminal.onData((data) => {
|
||||
options.onOutput();
|
||||
const bytes = Buffer.byteLength(data);
|
||||
observedBytes += bytes;
|
||||
observedFrames += 1;
|
||||
if (outputBytes + bytes > options.scenario.limits.output_bytes) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
outputBytes += bytes;
|
||||
@@ -871,7 +941,13 @@ export const captureTerminalFrames = (options: {
|
||||
options.renderer.write(data, atMs);
|
||||
options.recordEvent("frames", sequence);
|
||||
});
|
||||
return () => truncated;
|
||||
return () => ({
|
||||
budget_bytes: options.scenario.limits.output_bytes,
|
||||
observed_bytes: observedBytes,
|
||||
retained_bytes: outputBytes,
|
||||
observed_frames: observedFrames,
|
||||
retained_frames: options.frames.length,
|
||||
});
|
||||
};
|
||||
|
||||
export const resolveProcessResult = (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { IPty } from "@lydell/node-pty";
|
||||
import type { TerminalRetention } from "../../domain/process/processCaptureCoverage.js";
|
||||
import type {
|
||||
InteractionEvent,
|
||||
ProcessCapture,
|
||||
@@ -65,8 +66,8 @@ interface StartedCaptureRuntime {
|
||||
readonly startedAt: Date;
|
||||
readonly executableIdentity: ReturnType<typeof observeLaunchedExecutable>;
|
||||
readonly lastOutput: () => number;
|
||||
readonly framesTruncated: () => boolean;
|
||||
readonly stopSampler: () => Promise<{ readonly partial: boolean }>;
|
||||
readonly rawTerminalRetention: () => TerminalRetention;
|
||||
readonly stopSampler: ReturnType<typeof startProcessSampler>;
|
||||
}
|
||||
|
||||
const cleanupFailedStartup = async (options: {
|
||||
@@ -200,7 +201,7 @@ const startCaptureRuntime = async (
|
||||
scenario.executable,
|
||||
selectedExecutable,
|
||||
);
|
||||
const framesTruncated = captureTerminalFrames({
|
||||
const rawTerminalRetention = captureTerminalFrames({
|
||||
...options,
|
||||
terminal,
|
||||
started,
|
||||
@@ -228,7 +229,7 @@ const startCaptureRuntime = async (
|
||||
startedAt,
|
||||
executableIdentity,
|
||||
lastOutput: () => lastOutput,
|
||||
framesTruncated,
|
||||
rawTerminalRetention,
|
||||
stopSampler,
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
@@ -365,7 +366,8 @@ const completeCapture = async (options: {
|
||||
state: "available",
|
||||
value: settlement,
|
||||
};
|
||||
const samplingPartial = (await runtime.stopSampler()).partial;
|
||||
const sampling = await runtime.stopSampler();
|
||||
const samplingPartial = sampling.partial;
|
||||
await settleProcessCaptureJournal(options.eventJournal);
|
||||
assertNotCancelled(options.signal);
|
||||
let after: Awaited<ReturnType<typeof snapshotRoots>>;
|
||||
@@ -439,7 +441,8 @@ const completeCapture = async (options: {
|
||||
const truncated =
|
||||
options.initiallyTruncated ||
|
||||
after.truncated ||
|
||||
runtime.framesTruncated() ||
|
||||
runtime.rawTerminalRetention().observed_frames >
|
||||
runtime.rawTerminalRetention().retained_frames ||
|
||||
checkpoints.some(({ truncated: partial }) => partial) ||
|
||||
samplingPartial ||
|
||||
runtime.renderer.truncated();
|
||||
@@ -450,6 +453,17 @@ const completeCapture = async (options: {
|
||||
before: options.before,
|
||||
after,
|
||||
truncated,
|
||||
...(options.before.coverage === undefined || after.coverage === undefined
|
||||
? {}
|
||||
: {
|
||||
truncationDetails: {
|
||||
raw_terminal: runtime.rawTerminalRetention(),
|
||||
rendered_terminal: runtime.renderer.retention(),
|
||||
filesystem_before: options.before.coverage,
|
||||
filesystem_after: after.coverage,
|
||||
process: sampling.coverage,
|
||||
},
|
||||
}),
|
||||
scenario,
|
||||
rootPid: runtime.terminal.pid,
|
||||
samplingPartial,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import type { ProcessSamplingCoverage } from "../../domain/process/processCaptureCoverage.js";
|
||||
import type {
|
||||
ProcessSample,
|
||||
RecordProcessCaptureEvent,
|
||||
@@ -455,7 +456,10 @@ export const startProcessSampler = (options: {
|
||||
readonly limit: number;
|
||||
readonly samples: ProcessSample[];
|
||||
readonly recordEvent?: RecordProcessCaptureEvent;
|
||||
}): (() => Promise<{ readonly partial: boolean }>) => {
|
||||
}): (() => Promise<{
|
||||
readonly partial: boolean;
|
||||
readonly coverage: ProcessSamplingCoverage;
|
||||
}>) => {
|
||||
const { rootPid, runId, started, limit, samples } = options;
|
||||
const identities = new Map<number, string>();
|
||||
const lastObservations = new Map<number, string>();
|
||||
@@ -463,6 +467,9 @@ export const startProcessSampler = (options: {
|
||||
let stopped = false;
|
||||
let abortCurrent: (() => void) | undefined;
|
||||
let partial = false;
|
||||
let limitReached = false;
|
||||
let samplingFailures = 0;
|
||||
let firstSamplingFailure: string | null = null;
|
||||
let rootInitialized = false;
|
||||
|
||||
const sample = (): void => {
|
||||
@@ -501,6 +508,7 @@ export const startProcessSampler = (options: {
|
||||
if (lastObservations.get(value.pid) === observation) continue;
|
||||
if (samples.length >= limit) {
|
||||
partial = true;
|
||||
limitReached = true;
|
||||
continue;
|
||||
}
|
||||
lastObservations.set(value.pid, observation);
|
||||
@@ -510,8 +518,16 @@ export const startProcessSampler = (options: {
|
||||
}
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
if (!(cause instanceof Error && cause.name === "AbortError"))
|
||||
if (!(cause instanceof Error && cause.name === "AbortError")) {
|
||||
partial = true;
|
||||
samplingFailures += 1;
|
||||
firstSamplingFailure ??=
|
||||
cause instanceof Error
|
||||
? `${cause.name}: ${cause.message}`
|
||||
: typeof cause === "string"
|
||||
? cause
|
||||
: `Unexpected sampling rejection (${typeof cause})`;
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
abortCurrent = undefined;
|
||||
@@ -527,6 +543,17 @@ export const startProcessSampler = (options: {
|
||||
clearInterval(timer);
|
||||
abortCurrent?.();
|
||||
await pending;
|
||||
return { partial };
|
||||
return {
|
||||
partial,
|
||||
coverage: {
|
||||
sample_limit: limit,
|
||||
sampling_partial: partial,
|
||||
retained_samples: samples.length,
|
||||
sample_limit_reached: limitReached,
|
||||
sampling_failures: samplingFailures,
|
||||
first_sampling_failure: firstSamplingFailure,
|
||||
coverage: "sampled",
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createRequire } from "node:module";
|
||||
import type { TerminalRetention } from "../../domain/process/processCaptureCoverage.js";
|
||||
|
||||
import type {
|
||||
RecordProcessCaptureEvent,
|
||||
@@ -36,6 +37,8 @@ export class TerminalRenderer {
|
||||
readonly #frames: RenderedTerminalFrame[] = [];
|
||||
#pending: Promise<void> = Promise.resolve();
|
||||
#capturedBytes = 0;
|
||||
#observedBytes = 0;
|
||||
#observedFrames = 0;
|
||||
#truncated = false;
|
||||
|
||||
constructor(private readonly options: TerminalRendererOptions) {
|
||||
@@ -80,6 +83,17 @@ export class TerminalRenderer {
|
||||
return this.#truncated;
|
||||
}
|
||||
|
||||
/** Budget accounting after awaiting queued writes with frames(). */
|
||||
retention(): TerminalRetention {
|
||||
return {
|
||||
budget_bytes: this.options.maxBytes,
|
||||
observed_bytes: this.#observedBytes,
|
||||
retained_bytes: this.#capturedBytes,
|
||||
observed_frames: this.#observedFrames,
|
||||
retained_frames: this.#frames.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Release addon and terminal resources after all writes settle. */
|
||||
async dispose(): Promise<void> {
|
||||
await this.#pending;
|
||||
@@ -107,6 +121,8 @@ export class TerminalRenderer {
|
||||
const bytes =
|
||||
Buffer.byteLength(serializedState) +
|
||||
lines.reduce((total, line) => total + Buffer.byteLength(line), 0);
|
||||
this.#observedBytes += bytes;
|
||||
this.#observedFrames += 1;
|
||||
if (this.#capturedBytes + bytes > this.options.maxBytes) {
|
||||
this.#truncated = true;
|
||||
return;
|
||||
|
||||
Reference in new issue
Block a user