fix: attribute rendered frame omissions to their producing stage

This commit is contained in:
morluto committed 2026-10-24 02:35:59 +08:00
1 parent a0fcc60d89
commit 5934387f41
7 files changed
+70 -13

No files matched your search

+5 -4
View File
@@ -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.
+2 -1
View File
@@ -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({
@@ -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.",
});
@@ -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,
+7 -3
View File
@@ -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" };
};
};
+35 -1
View File
@@ -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();
}
},
);
+11
View File
@@ -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;