From 5934387f4185129efe9dc8b7338d8bc3fa5b25bd Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:28:39 +0800 Subject: [PATCH] fix: attribute rendered frame omissions to their producing stage --- docs/process-capture.md | 9 ++--- src/domain/process/processCaptureCoverage.ts | 3 +- .../capture/ProcessCaptureLifecycle.ts | 2 +- .../capture/ProcessNormalization.test.ts | 12 +++++-- src/process/capture/ProcessNormalization.ts | 10 ++++-- src/process/capture/TerminalRenderer.test.ts | 36 ++++++++++++++++++- src/process/capture/TerminalRenderer.ts | 11 ++++++ 7 files changed, 70 insertions(+), 13 deletions(-) diff --git a/docs/process-capture.md b/docs/process-capture.md index 5b43d4967b..88057ef5e8 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -433,10 +433,11 @@ Every capture requires `truncation_details`, with separate accounting for: bytes, plus observed/retained frame counts. This is independent of the raw chunk budget and does not count JSON encoding. Repeated screen snapshots can exhaust it even when all raw output fits. Rendered states use retained PTY - input, so raw omissions also limit rendered coverage. When bounded - normalization omits a rendered frame before its final size is known, - `unknown_size_frames` reports that count; `observed_bytes` includes only - rendered frame sizes that were determined exactly. + input, so raw omissions also limit rendered coverage. `unknown_size_frames` + counts frames whose complete size was not calculated; it does not by itself + identify normalization as the cause. `normalization_over_budget_frames` + identifies omissions caused by an ordered normalization stage. + `observed_bytes` includes only rendered frame sizes determined exactly. - `filesystem_before` and `filesystem_after`: file-count/depth limits, enumeration failures, whole-file hash budget and bytes successfully hashed. Directory names are read incrementally within the remaining entry capacity. diff --git a/src/domain/process/processCaptureCoverage.ts b/src/domain/process/processCaptureCoverage.ts index b4c5a3b69d..da365bb403 100644 --- a/src/domain/process/processCaptureCoverage.ts +++ b/src/domain/process/processCaptureCoverage.ts @@ -9,6 +9,7 @@ const byteRetentionSchema = z.strictObject({ observed_frames: z.int().nonnegative(), retained_frames: z.int().nonnegative(), unknown_size_frames: z.int().nonnegative().optional(), + normalization_over_budget_frames: z.int().nonnegative().optional(), }); const enumerationOmissionReasonSchema = z.enum([ @@ -60,7 +61,7 @@ export const processCaptureTruncationDetailsSchema = z.strictObject({ "UTF-8 bytes of original PTY chunks; whole chunks are retained or omitted.", ), rendered_terminal: byteRetentionSchema.describe( - "Cumulative UTF-8 bytes of serialized state plus visible lines, evaluated from retained PTY input; whole frames are retained or omitted. When normalization exceeds a bound before the frame size is known, observed_bytes contains the exact total for size-known frames and unknown_size_frames counts the other omissions.", + "Cumulative UTF-8 bytes of serialized state plus visible lines, evaluated from retained PTY input; whole frames are retained or omitted. observed_bytes contains the exact total for size-known frames; unknown_size_frames counts frames whose complete size was not calculated. normalization_over_budget_frames identifies the subset stopped by an ordered normalization stage rather than final representation retention.", ), normalized_terminal: z .strictObject({ diff --git a/src/process/capture/ProcessCaptureLifecycle.ts b/src/process/capture/ProcessCaptureLifecycle.ts index 22134e9601..0cffa3da4b 100644 --- a/src/process/capture/ProcessCaptureLifecycle.ts +++ b/src/process/capture/ProcessCaptureLifecycle.ts @@ -455,7 +455,7 @@ const captureCoverageUnknowns = ( unknowns.push({ scope: "terminal", reason: - (details.rendered_terminal.unknown_size_frames ?? 0) > 0 + (details.rendered_terminal.normalization_over_budget_frames ?? 0) > 0 ? "Rendered frames were omitted because a normalization stage exceeded the remaining output_bytes budget; final rendered byte totals are unknown for those frames. See truncation_details.rendered_terminal." : "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.", }); diff --git a/src/process/capture/ProcessNormalization.test.ts b/src/process/capture/ProcessNormalization.test.ts index a8accf105e..b851b962c4 100644 --- a/src/process/capture/ProcessNormalization.test.ts +++ b/src/process/capture/ProcessNormalization.test.ts @@ -108,7 +108,10 @@ it("bounds ordered normalization before replacement expansion and preserves late rootPid, ); - expect(normalize("a".repeat(100), 1_000)).toEqual({ state: "over_budget" }); + expect(normalize("a".repeat(100), 1_000)).toEqual({ + state: "over_budget", + reason: "normalization", + }); expect(normalize("z", 1_000)).toEqual({ state: "normalized", value: "z", @@ -131,7 +134,10 @@ it("keeps ordered intermediate expansion explicit even when a later rule could s rootPid, ); - expect(normalize("a", 10)).toEqual({ state: "over_budget" }); + expect(normalize("a", 10)).toEqual({ + state: "over_budget", + reason: "normalization", + }); }); it("counts UTF-8 bytes across replacement boundaries like the final JavaScript string", () => { @@ -215,7 +221,7 @@ it("counts UTF-8 bytes across replacement boundaries like the final JavaScript s "/temporary", rootPid, )(input, Buffer.byteLength(expected) - 1), - ).toEqual({ state: "over_budget" }); + ).toEqual({ state: "over_budget", reason: "normalization" }); const loneSurrogate = scenario({ paths: false, diff --git a/src/process/capture/ProcessNormalization.ts b/src/process/capture/ProcessNormalization.ts index f134b053e6..329d366f58 100644 --- a/src/process/capture/ProcessNormalization.ts +++ b/src/process/capture/ProcessNormalization.ts @@ -10,7 +10,10 @@ export type BoundedProcessTextNormalization = readonly value: string; readonly utf8Bytes: number; } - | { readonly state: "over_budget" }; + | { + readonly state: "over_budget"; + readonly reason: "normalization" | "retention"; + }; type RegexReplacement = (match: string, ...groups: string[]) => string; @@ -270,7 +273,8 @@ export const createBoundedProcessTextNormalizer = ( rule.replacement, maxBytes, ); - if (result.state === "over_budget") return result; + if (result.state === "over_budget") + return { state: "over_budget", reason: "normalization" }; normalized = result.value; } const identity = boundedIdentity(normalized, maxBytes); @@ -280,7 +284,7 @@ export const createBoundedProcessTextNormalizer = ( value: identity.value, utf8Bytes: identity.utf8Bytes, } - : { state: "over_budget" }; + : { state: "over_budget", reason: "retention" }; }; }; diff --git a/src/process/capture/TerminalRenderer.test.ts b/src/process/capture/TerminalRenderer.test.ts index efb1005414..9381979e97 100644 --- a/src/process/capture/TerminalRenderer.test.ts +++ b/src/process/capture/TerminalRenderer.test.ts @@ -25,7 +25,7 @@ const boundedNormalize = const utf8Bytes = Buffer.byteLength(normalized); return utf8Bytes <= maxBytes ? { state: "normalized", value: normalized, utf8Bytes } - : { state: "over_budget" }; + : { state: "over_budget", reason: "retention" }; }; const renderFrames = async ( @@ -152,6 +152,7 @@ it("omits rendered frames with unknown expanded size and keeps later bounded sta observed_frames: 2, retained_frames: 1, unknown_size_frames: 1, + normalization_over_budget_frames: 1, }); expect(renderer.truncated()).toBe(true); } finally { @@ -248,3 +249,36 @@ it("retains disposal failures for a later cleanup retry", async () => { await rm(temporaryRoot, { recursive: true, force: true }); } }); + +it.each([1024, 2048])( + "keeps unchanged representation retention distinct from normalization at budget %s", + async (maxBytes) => { + const renderer = new TerminalRenderer({ + columns: 600, + rows: 1, + scrollback: 0, + maxBytes, + normalize: createBoundedProcessTextNormalizer( + parseProcessScenario({ + executable: "unused", + normalization: { paths: false, pids: false, ports: false }, + }), + "/tmp", + -1, + ), + }); + try { + renderer.write("a".repeat(600), 0); + const frames = await renderer.frames(); + expect(frames).toHaveLength(maxBytes === 1024 ? 0 : 1); + expect(renderer.retention().normalization_over_budget_frames ?? 0).toBe( + 0, + ); + expect(renderer.retention().unknown_size_frames ?? 0).toBe( + maxBytes === 1024 ? 1 : 0, + ); + } finally { + await renderer.dispose(); + } + }, +); diff --git a/src/process/capture/TerminalRenderer.ts b/src/process/capture/TerminalRenderer.ts index fc51730e6c..749e22aca7 100644 --- a/src/process/capture/TerminalRenderer.ts +++ b/src/process/capture/TerminalRenderer.ts @@ -47,6 +47,7 @@ export class TerminalRenderer { #observedFrames = 0; #truncated = false; #unknownSizeFrames = 0; + #normalizationOverBudgetFrames = 0; constructor(private readonly options: TerminalRendererOptions) { this.#terminal = new HeadlessPackage.Terminal({ @@ -105,6 +106,12 @@ export class TerminalRenderer { ...(this.#unknownSizeFrames === 0 ? {} : { unknown_size_frames: this.#unknownSizeFrames }), + ...(this.#normalizationOverBudgetFrames === 0 + ? {} + : { + normalization_over_budget_frames: + this.#normalizationOverBudgetFrames, + }), }; } @@ -142,6 +149,8 @@ export class TerminalRenderer { Math.min(Number.MAX_SAFE_INTEGER, remainingBytes + trailingSpaceBytes), ); if (normalized.state === "over_budget") { + if (normalized.reason === "normalization") + this.#normalizationOverBudgetFrames += 1; this.#unknownSizeFrames += 1; this.#truncated = true; return; @@ -164,6 +173,8 @@ export class TerminalRenderer { remainingBytes, ); if (normalizedState.state === "over_budget") { + if (normalizedState.reason === "normalization") + this.#normalizationOverBudgetFrames += 1; this.#unknownSizeFrames += 1; this.#truncated = true; return;