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:
Ysopwnable authored and GitHub committed 2026-10-09 21:56:43 +08:00
1 parent 7e96f25f8b
commit 33bf585185
14 files changed
+739 -50

No files matched your search

+39 -2
View File
@@ -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,
},
};
};
+81 -5
View File
@@ -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 = (
+20 -6
View File
@@ -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,
+30 -3
View File
@@ -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",
},
};
};
};
+16
View File
@@ -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;