fix(tui): resolved stability regressions in committed prefix audits

- Refactored committed prefix auditing into distinct byte-stable, durable, and forced-overflow zones.
- Integrated a hard scan detector and refined boundary management to prevent data loss during commit-unstable barrier shifts.
- Implemented robust regression testing via the streaming-scrollback-defer harness to verify frame continuity.
- Updated audit logic to independently manage boundaries, ensuring forced-overflow rows remain correctly finalized.
This commit is contained in:
can1357
2026-06-21 07:37:11 +02:00
parent c5cf47aa7f
commit 26e9c1122b
4 changed files with 661 additions and 102 deletions
+6 -1
View File
@@ -1,6 +1,11 @@
# Changelog
## [Unreleased]
### Fixed
- Fixed an issue where output was lost when a commit-unstable barrier at the top of the viewport was removed or modified
- Fixed race conditions in the committed prefix audit that could cause content to be incorrectly dropped during streaming updates
- Improved the commit fidelity of forced-overflow rows to ensure they are consistently committed to the terminal scrollback buffer
## [16.1.8] - 2026-06-20
@@ -1766,4 +1771,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
### Fixed
- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
+176 -77
View File
@@ -812,52 +812,95 @@ const RESYNC_TAIL_SAMPLES = 8;
* re-anchor the commit index when it does not. Returns the resync row index,
* or -1 when no resync is needed.
*
* The detector exploits the asymmetry between the two mutation classes: an
* in-place edit or restyle of committed rows disturbs only the touched rows
* (alignment below them is intact — the stale copy in history is the
* long-accepted artifact), while any insertion or deletion shifts EVERY row
* below it, including the rows just above the commit boundary. So the prefix
* *tail* is sampled (up to 8 non-blank rows within the last 24, compared
* SGR-stripped so theme changes stay quiet, tolerating one mismatch for a
* legitimate single-row edit): aligned ⇒ no resync; misaligned ⇒ resync at
* the first non-equivalent row, recommitting from there — duplication, never
* loss. Highly repetitive tails (identical filler rows) can mask a shift, in
* which case the skipped rows are content-identical to the committed ones —
* observationally harmless. Exported for the render-stress harness, whose
* Audits the committed prefix [0, auditTo) EXCEPT the exempt window
* [exemptFrom, exemptTo): rows in the window are durable snapshots (a streaming
* table re-aligning its columns) that may drift legitimately, so their drift
* never triggers a re-anchor. Rows below the window — including forced-overflow
* rows committed only because they scrolled above the viewport under a
* commit-unstable barrier — ARE audited.
*
* Two detectors run over the audited rows:
*
* 1. Hard scan of the forced suffix [exemptTo, byteStableEnd): forced-overflow
* rows that THIS frame asserts are now permanent (index < byteStableEnd —
* the barrier above them finalized or cleared). A content change there is
* real finalized content, so ANY mismatch re-anchors. Scanned in FULL, not
* sampled, so a single edit far above the commit boundary with an unchanged
* tail still re-anchors (duplication, never loss) instead of being committed
* nowhere and painted nowhere.
* 2. Tail sample (only when the hard scan is clean): exploits the asymmetry
* between the two mutation classes — an in-place edit/restyle of a committed
* row disturbs only the touched rows (alignment below intact; the stale copy
* in history is the long-accepted artifact), while an insertion/deletion
* shifts EVERY row below it. So up to 8 non-blank rows within the last 24
* audited rows are compared SGR-stripped (theme changes stay quiet),
* tolerating one mismatch for a legitimate single-row edit of a row that is
* still volatile (index >= byteStableEnd): aligned ⇒ no resync; misaligned ⇒
* resync at the first non-equivalent audited row. The tolerance keeps an
* offscreen still-live barrier (a ticking spinner) from spraying duplicate
* snapshots every frame; the hard scan above is what forbids it from
* swallowing a finalized row.
*
* Highly repetitive tails (identical filler rows) can mask a shift in the tail
* sample, in which case the skipped rows are content-identical to the committed
* ones — observationally harmless. Exported for the render-stress harness, whose
* shadow commit ledger must mirror the engine's law exactly.
*/
export function findCommittedPrefixResync(
frame: readonly string[],
prefix: readonly string[],
auditLimit: number = prefix.length,
auditTo: number = prefix.length,
exemptFrom: number = auditTo,
exemptTo: number = exemptFrom,
byteStableEnd = 0,
): number {
// Audit only the byte-stable leading prefix [0, auditLimit); rows committed
// under a durable snapshot end (beyond auditLimit) may drift legitimately and
// are exempt, so their drift never triggers a re-anchor.
const committed = Math.min(prefix.length, Math.max(0, Math.trunc(auditLimit)));
const committed = Math.min(prefix.length, Math.max(0, Math.trunc(auditTo)));
if (committed === 0) return -1;
// Exempt window [exFrom, exTo) clamped into the committed prefix. Rows there
// are durable-snapshot drift and skipped by both detectors and the scan.
const exFrom = Math.max(0, Math.min(committed, Math.trunc(exemptFrom)));
const exTo = Math.max(exFrom, Math.min(committed, Math.trunc(exemptTo)));
const audited = (i: number): boolean => i < exFrom || i >= exTo;
if (frame.length >= committed) {
let samples = 0;
let mismatches = 0;
const lookback = Math.min(RESYNC_TAIL_LOOKBACK, committed);
for (let j = 1; j <= lookback && samples < RESYNC_TAIL_SAMPLES; j++) {
const row = frame[committed - j]!;
const old = prefix[committed - j]!;
if (row === old) {
if (!isBlankRow(row)) samples++;
continue;
// 1. Hard scan: forced-overflow rows now asserted permanent. Full scan, no
// tolerance — a finalized row that changed must re-anchor.
const hardEnd = Math.min(committed, Math.max(0, Math.trunc(byteStableEnd)));
let hardMismatch = false;
for (let i = exTo; i < hardEnd; i++) {
if (!rowsEquivalent(frame[i]!, prefix[i]!)) {
hardMismatch = true;
break;
}
if (isBlankRow(row) && isBlankRow(old)) continue;
samples++;
if (!rowsEquivalent(row, old)) mismatches++;
}
// No signal (all-blank tail) or at most one edited row: aligned.
if (samples === 0 || mismatches <= 1) return -1;
if (!hardMismatch) {
// 2. Tail sample. Walk up from the commit boundary, skipping exempt
// rows, until LOOKBACK audited rows or SAMPLES non-blank comparisons.
let samples = 0;
let mismatches = 0;
let scanned = 0;
for (let j = 1; j <= committed && scanned < RESYNC_TAIL_LOOKBACK && samples < RESYNC_TAIL_SAMPLES; j++) {
const idx = committed - j;
if (!audited(idx)) continue;
scanned++;
const row = frame[idx]!;
const old = prefix[idx]!;
if (row === old) {
if (!isBlankRow(row)) samples++;
continue;
}
if (isBlankRow(row) && isBlankRow(old)) continue;
samples++;
if (!rowsEquivalent(row, old)) mismatches++;
}
// No signal (all-blank/all-exempt tail) or at most one edited row: aligned.
if (samples === 0 || mismatches <= 1) return -1;
}
}
// Misaligned (or the frame no longer covers the prefix): re-anchor at the
// first row whose content actually changed.
// Misaligned (hard mismatch, tail-sample shift, or the frame no longer covers
// the prefix): re-anchor at the first audited row whose content changed.
const limit = Math.min(committed, frame.length);
for (let i = 0; i < limit; i++) {
if (!audited(i)) continue;
if (!rowsEquivalent(frame[i]!, prefix[i]!)) return i;
}
return limit < committed ? limit : -1;
@@ -957,15 +1000,23 @@ export class TUI extends Container {
// #auditCommittedPrefix). Holds references to component-cached strings, so
// the audit is a pointer walk in the common case.
#committedPrefix: string[] = [];
// Length of the leading committed prefix [0, #committedPrefixAuditRows) that
// is BYTE-STABLE and therefore audited. Rows [auditRows, committedRows) were
// committed under a component's snapshot-safe (durable, non-byte-stable) end:
// their scroll-off snapshot is permanent so dropping them is forbidden, but
// they may drift afterward (a streaming table widening), so re-auditing them
// would re-anchor on every drift and spray duplicate snapshots. Once a
// snapshot row commits (auditRows < committedRows) the cap is permanent until
// a wholesale re-slice (full paint / shrink / geometry) re-bases it.
// The committed prefix [0, committedRows) splits into three audit zones by
// two monotone marks auditRows ≤ durableRows ≤ committedRows:
// [0, auditRows) BYTE-STABLE — audited (re-anchor on any shift).
// [auditRows, durableRows) DURABLE snapshot — exempt: rows may drift in
// place (a streaming table widening) without re-anchoring, so their
// expected drift never sprays duplicate snapshots.
// [durableRows, committedRows) FORCED-overflow — audited: rows committed
// only because they scrolled above the window under a commit-unstable
// barrier; auditing them re-anchors (duplication, never loss) when the
// barrier later shifts/finalizes/removes, instead of stranding a stale
// prefix that silently drops the rows beneath it.
// Both marks re-base on a wholesale re-slice (full paint / shrink / geometry)
// and otherwise advance per the persistence rules in #updateCommittedAuditRows.
// #auditCommittedPrefix audits [0, committedRows) skipping the exempt window
// [auditRows, durableRows).
#committedPrefixAuditRows = 0;
#committedPrefixDurableRows = 0;
// Frame row currently mapped to screen row 0. Monotonic between full
// paints: a shrink never re-exposes scrolled-off rows (they cannot be
// un-scrolled without rewriting history); live rows repaint at fixed
@@ -2530,6 +2581,29 @@ export class TUI extends Container {
const commitSafeEnd = this.#nativeScrollbackCommitSafeEnd;
const snapshotSafeEnd = this.#nativeScrollbackSnapshotSafeEnd;
// Commit boundaries (also used by the window/commit math in section 3),
// hoisted above the audit gate because the resync needs byteStableBoundary
// to tell a now-permanent forced row (must re-anchor) from a still-live one.
// The commit floor is windowTop in every non-frozen path (see chunkTo), so
// whatever scrolls above the window is committed — never committed nowhere
// AND painted nowhere (the loss bug). The boundaries no longer gate the
// commit; they define the audit-exempt span. byteStableBoundary: rows below
// it are byte-stable (never re-layout), audited. durableBoundary: rows in
// [byteStableBoundary, durableBoundary) are durable — permanent on scroll-off
// but may drift in place (a streaming table re-aligning), committed
// audit-EXEMPT. Rows at/beyond durableBoundary committed only because they
// scrolled above the window (a commit-unstable barrier over a long tail) are
// forced-overflow rows: audited, so a later shift/finalize/removal re-anchors
// (duplication, never loss) instead of stranding a stale prefix. Built on the
// finalized prefix (live-region start); the whole frame when the root reports
// no seam (shell semantics: whatever scrolls is final).
const frameLength = rawFrame.length;
const byteStableBoundary = Math.max(0, Math.min(frameLength, commitSafeEnd ?? liveRegionStart ?? frameLength));
const durableBoundary = Math.max(
byteStableBoundary,
Math.min(frameLength, snapshotSafeEnd ?? byteStableBoundary),
);
// 2. Transition state captured before any emitter runs.
const prevWindowTop = this.#windowTopRow;
const prevHardwareCursorRow = this.#hardwareCursorRow;
@@ -2559,23 +2633,31 @@ export class TUI extends Container {
// that provably did not change since the last (aligned) frame cannot
// have diverged.
let committedRowsResynced = false;
// Audit covers [0, auditRows) and the forced-overflow suffix
// [durableRows, committedRows); the durable middle is exempt. The gate
// fires only when the stable prefix does not already cover every audited
// row: the upper audited bound is committedRows when a forced suffix
// exists, else the byte-stable auditRows — so a wholly durable/exempt
// prefix (a tall snapshot block) never pays the resync walk.
const auditUpper =
this.#committedPrefixDurableRows < this.#committedRows ? this.#committedRows : this.#committedPrefixAuditRows;
if (
this.#hasEverRendered &&
!geometryChanged &&
!this.#clearScrollbackOnNextRender &&
this.#renderStablePrefixRows < this.#committedPrefixAuditRows
this.#renderStablePrefixRows < auditUpper
) {
const committedRowsBeforeAudit = this.#committedRows;
this.#auditCommittedPrefix(rawFrame);
this.#auditCommittedPrefix(rawFrame, byteStableBoundary);
committedRowsResynced = this.#committedRows !== committedRowsBeforeAudit;
}
// Committed-prefix state this frame's commit math extends from (post-audit).
// Drives the byte-stable audit-rows cap recomputed after the emit.
// Drives the audit-rows / durable-rows caps recomputed after the emit.
const preCommitRows = this.#committedRows;
const preCommitAuditRows = this.#committedPrefixAuditRows;
const preCommitDurableRows = this.#committedPrefixDurableRows;
// 3. Window and commit math (lengths only; content prepared below).
const frameLength = rawFrame.length;
let hasVisibleOverlay = false;
for (const entry of this.overlayStack) {
if (this.#isOverlayVisible(entry)) {
@@ -2583,18 +2665,6 @@ export class TUI extends Container {
break;
}
}
// Two commit boundaries. byteStableBoundary: rows below it are byte-stable
// (asserted never to re-layout) and stay under the committed-prefix audit.
// durableBoundary: rows below it are durable — their scroll-off snapshot is
// permanent (dropping them is forbidden) but may still drift afterward, so
// they commit audit-EXEMPT. Both build on the finalized prefix (live-region
// start); the whole frame when the root reports no seam (shell semantics:
// whatever scrolls is final).
const byteStableBoundary = Math.max(0, Math.min(frameLength, commitSafeEnd ?? liveRegionStart ?? frameLength));
const durableBoundary = Math.max(
byteStableBoundary,
Math.min(frameLength, snapshotSafeEnd ?? byteStableBoundary),
);
// 4. Classify. A resize is an explicit user gesture: normally the engine
// erases and replays so history rewraps at the new geometry (the reader
@@ -2612,7 +2682,7 @@ export class TUI extends Container {
if (fullPaint) {
committedPrefixResliced = true;
windowTop = Math.max(0, frameLength - height);
chunkTo = Math.min(durableBoundary, windowTop);
chunkTo = windowTop;
} else if (
frameLength <= this.#committedRows ||
(committedRowsResynced &&
@@ -2630,7 +2700,7 @@ export class TUI extends Container {
// is preferable to a live editor gap and matches the existing
// "duplication, never loss" resync contract.
windowTop = Math.max(0, frameLength - height);
chunkTo = Math.min(durableBoundary, windowTop);
chunkTo = windowTop;
committedPrefixResliced = true;
this.#committedRows = chunkTo;
this.#committedPrefix = rawFrame.slice(0, chunkTo);
@@ -2648,10 +2718,7 @@ export class TUI extends Container {
// pane keeps its own (old-wrap) history — and re-bases the audit
// prefix at the new width so the accepted wrap drift does not read
// as a violation on the next ordinary frame.
chunkTo =
hasVisibleOverlay || geometryChanged
? this.#committedRows
: Math.max(this.#committedRows, Math.min(durableBoundary, windowTop));
chunkTo = hasVisibleOverlay || geometryChanged ? this.#committedRows : windowTop;
if (geometryChanged) {
committedPrefixResliced = true;
this.#committedPrefix = rawFrame.slice(0, this.#committedRows);
@@ -2711,7 +2778,14 @@ export class TUI extends Container {
windowTop,
});
this.#committedPrefix = rawFrame.slice(0, chunkTo);
this.#updateCommittedAuditRows(true, preCommitRows, preCommitAuditRows, byteStableBoundary);
this.#updateCommittedAuditRows(
true,
preCommitRows,
preCommitAuditRows,
preCommitDurableRows,
byteStableBoundary,
durableBoundary,
);
this.#clearScrollbackOnNextRender = false;
this.#hasEverRendered = true;
if (!firstPaint && frameLength > height) this.#armPostFullPaintSettle();
@@ -2730,7 +2804,14 @@ export class TUI extends Container {
for (let i = this.#committedPrefix.length; i < chunkTo; i++) {
this.#committedPrefix.push(rawFrame[i] ?? "");
}
this.#updateCommittedAuditRows(committedPrefixResliced, preCommitRows, preCommitAuditRows, byteStableBoundary);
this.#updateCommittedAuditRows(
committedPrefixResliced,
preCommitRows,
preCommitAuditRows,
preCommitDurableRows,
byteStableBoundary,
durableBoundary,
);
}
/**
@@ -2740,13 +2821,21 @@ export class TUI extends Container {
* restyles keep their alignment and are left alone (stale styling in
* history was always the accepted artifact).
*/
#auditCommittedPrefix(rawFrame: readonly string[]): void {
#auditCommittedPrefix(rawFrame: readonly string[], byteStableBoundary: number): void {
const prefix = this.#committedPrefix;
if (prefix.length === 0) return;
const resyncTo = findCommittedPrefixResync(rawFrame, prefix, this.#committedPrefixAuditRows);
const resyncTo = findCommittedPrefixResync(
rawFrame,
prefix,
prefix.length,
this.#committedPrefixAuditRows,
this.#committedPrefixDurableRows,
byteStableBoundary,
);
if (resyncTo < 0) return;
this.#committedRows = resyncTo;
this.#committedPrefixAuditRows = Math.min(this.#committedPrefixAuditRows, resyncTo);
this.#committedPrefixDurableRows = Math.min(this.#committedPrefixDurableRows, resyncTo);
prefix.length = resyncTo;
if ($flag("PI_DEBUG_REDRAW")) {
const msg = `[${new Date().toISOString()}] commit resync: committed prefix diverged at row ${resyncTo}; recommitting\n`;
@@ -2755,27 +2844,37 @@ export class TUI extends Container {
}
/**
* Recompute the byte-stable audit-rows cap after a commit. The audited prefix
* [0, auditRows) holds rows committed while byte-stable; rows committed under a
* durable snapshot end (beyond byteStableBoundary) are excluded so the audit
* never re-anchors on their expected drift (a streaming table widening). A
* wholesale re-slice (full paint / shrink / geometry) re-bases the prefix from
* the current frame, so the cap is just min(committed, byteStableBoundary). An
* incremental extend keeps the cap once any snapshot row has committed
* (auditRows < committedRows): a later rise in byteStableBoundary (a table
* finalizing) must not pull already-committed stale snapshots back under audit.
* Recompute the audit-rows / durable-rows marks after a commit (see the
* #committedPrefixAuditRows field doc for the three audit zones).
*
* auditRows tracks the byte-stable boundary; durableRows the durable snapshot
* boundary. A wholesale re-slice (full paint / shrink / geometry) re-bases
* each mark from the current frame (min(committed, boundary)). An incremental
* extend keeps a mark once a row past it has committed (mark < committed): a
* later RISE in a boundary (a table finalizing) must neither pull
* already-committed stale snapshots back under the byte-stable cap nor
* retroactively exempt forced-overflow rows already audited. durableRows is
* floored at auditRows so the exempt window can never invert.
*/
#updateCommittedAuditRows(
resliced: boolean,
preCommittedRows: number,
preAuditRows: number,
preDurableRows: number,
byteStableBoundary: number,
durableBoundary: number,
): void {
const committed = this.#committedRows;
this.#committedPrefixAuditRows =
const auditRows =
resliced || preAuditRows >= preCommittedRows
? Math.min(committed, byteStableBoundary)
: Math.min(preAuditRows, committed);
const durableRows =
resliced || preDurableRows >= preCommittedRows
? Math.min(committed, durableBoundary)
: Math.min(preDurableRows, committed);
this.#committedPrefixAuditRows = auditRows;
this.#committedPrefixDurableRows = Math.max(auditRows, durableRows);
}
/**
+45 -2
View File
@@ -278,6 +278,13 @@ export interface Scenario {
// fixed-line components never produced. Wrapped content must agree with the
// real Ghostty-backed terminal's cell widths.
reflow: boolean;
// Models a commit-unstable barrier (a provisional tool preview, a displaceable
// `job` poll) pinned at row 0: the main component reports a live-region seam at
// 0 with no commit-safe/snapshot-safe end, so every committed row is a
// forced-overflow row. Exercises the engine's no-loss commit floor on the seam
// path — where durableBoundary collapses below windowTop — which the
// seam-agnostic shadow ledger covers without modification.
barrierSeam: boolean;
tags: readonly ScenarioTag[];
replayOperations?: readonly OperationKind[];
}
@@ -987,10 +994,12 @@ class StressComponent implements Component, Focusable {
focused = false;
#model: StressModel;
#reflow: boolean;
#barrierSeam: boolean;
constructor(model: StressModel, reflow = false) {
constructor(model: StressModel, reflow = false, barrierSeam = false) {
this.#model = model;
this.#reflow = reflow;
this.#barrierSeam = barrierSeam;
}
invalidate(): void {}
@@ -999,6 +1008,16 @@ class StressComponent implements Component, Focusable {
const lines = this.#model.renderedLines(width, this.focused);
return this.#reflow ? reflowToWidth(lines, width) : lines;
}
// Commit-unstable barrier seam: live region from row 0, no commit-safe or
// snapshot-safe end. Collapses durableBoundary to 0, so the whole committed
// prefix is forced-overflow — the engine must still commit every scrolled-off
// row (no-loss floor). A regression to a min(durableBoundary, windowTop) clamp
// would commit nothing here and diverge from the shadow tape (chunkTo =
// windowTop), tripping the native-scrollback fidelity oracle.
getNativeScrollbackLiveRegionStart(): number | undefined {
return this.#barrierSeam ? 0 : undefined;
}
}
class StressOverlayModel {
@@ -1165,7 +1184,7 @@ class StressDriver {
this.#scheduler = new StressRenderScheduler();
const maxHeight = maxOf(scenario.heightChoices);
this.#model = new StressModel(this.#streams.content, maxHeight + 12, scenario.uniqueContent, "root-");
this.#component = new StressComponent(this.#model, scenario.reflow);
this.#component = new StressComponent(this.#model, scenario.reflow, scenario.barrierSeam);
this.#children = [0, 1].map(id => {
const model = new StressModel(
this.#streams.children,
@@ -3428,6 +3447,7 @@ function materializeScenario(
template.envMode !== "tmux" && template.terminalMode === "normal" && template.platform !== "win32";
const foregroundStream = template.foregroundStream ?? false;
const reflow = template.reflow ?? false;
const barrierSeam = template.barrierSeam ?? false;
return {
...template,
seed,
@@ -3439,6 +3459,7 @@ function materializeScenario(
uniqueContent: template.uniqueContent ?? false,
foregroundStream,
reflow,
barrierSeam,
tags: scenarioTags(template, strictScrollback, foregroundStream),
replayOperations,
};
@@ -3544,11 +3565,13 @@ type ScenarioTemplate = Omit<
| "reflow"
| "tags"
| "replayOperations"
| "barrierSeam"
> & {
scrollbackRows?: number;
uniqueContent?: boolean;
foregroundStream?: boolean;
reflow?: boolean;
barrierSeam?: boolean;
};
function writeReplayLog(scenario: Scenario, operations: readonly OperationLogEntry[]): string {
@@ -3786,6 +3809,26 @@ function coreTemplates(): ScenarioTemplate[] {
reflow: true,
foregroundStream: true,
},
{
// Commit-unstable barrier (provisional tool preview / displaceable poll)
// pinned at row 0 over content that overflows the viewport. The engine's
// commit floor must push every scrolled-off row into native scrollback
// even though the barrier is never byte-stable, driving the seam path
// where durableBoundary collapses below windowTop — the exact gap that
// silently dropped rows. A regression to a min(durableBoundary,
// windowTop) clamp makes the engine diverge from the seam-agnostic
// shadow ledger and trips the native-scrollback fidelity oracle.
name: "darwin-normal-barrier-seam-small",
platform: "darwin",
terminalMode: "normal",
envMode: "plain",
geometryMode: "small",
columns: 32,
rows: 4,
widthChoices: [8, 16, 24, 32],
heightChoices: [3, 4, 6],
barrierSeam: true,
},
];
}
@@ -142,7 +142,7 @@ describe("streaming scrollback defer", () => {
savedTerminalEnv = {};
});
it("keeps mutable live-region head rows out of native scrollback", async () => {
it("commits the live-region head to native scrollback without loss", async () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
@@ -162,36 +162,39 @@ describe("streaming scrollback defer", () => {
tui.requestRender();
await settle(term);
// The sealed prefix is stable and may enter native scrollback. The
// live block's head (think-0/think-1) has physically left the viewport,
// but it is still mutable; committing it would leave stale rows in
// history when the live block re-renders or collapses.
// The live block's head (think-0/think-1) scrolls above the 4-row
// viewport. The engine floor commits every row that scrolls off, so the
// head reaches native scrollback instead of vanishing — committed
// nowhere, painted nowhere (the loss bug). No ED3 erase.
expect(eraseScrollbackCount(writes)).toBe(0);
expect(term.getScrollBuffer().map(line => line.trimEnd())).toEqual([
...rows("prior-", 12),
...rows("think-", 6).slice(-4),
...rows("think-", 6),
]);
// Append-only growth: the committed head is byte-identical, so it never
// re-anchors or duplicates; the new tail just extends.
live.setLines(rows("think-", 8));
tui.requestRender();
await settle(term);
const buffer = term.getScrollBuffer().map(line => line.trimEnd());
expect(eraseScrollbackCount(writes)).toBe(0);
expect(buffer).toEqual([...rows("prior-", 12), ...rows("think-", 8).slice(-4)]);
expect(buffer).toEqual([...rows("prior-", 12), ...rows("think-", 8)]);
} finally {
tui.stop();
}
});
it("keeps a tall all-live block transient when no sealed prefix exists", async () => {
it("commits a tall all-live block's scrolled head to native scrollback", async () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
// The only block is the live one (liveRegionStart === 0). Rows above
// the viewport are mutable, so they must stay out of native scrollback
// instead of being committed as stale history.
// The only block is the live one (liveRegionStart === 0). Rows that scroll
// above the viewport are committed by the engine floor so they reach native
// scrollback rather than vanishing; the block grows append-only here, so no
// committed row is ever rewritten (no duplication).
const live = new LiveLineList([]);
try {
@@ -205,11 +208,10 @@ describe("streaming scrollback defer", () => {
tui.requestRender();
await settle(term);
// tool-0..tool-5 scrolled above the 4-row viewport, but the whole
// block is mutable; only tool-6..tool-9 should remain in the native
// buffer.
// tool-0..tool-5 scrolled above the 4-row viewport and reach native
// scrollback; tool-6..tool-9 stay in the viewport. Nothing is lost.
expect(eraseScrollbackCount(writes)).toBe(0);
expect(term.getScrollBuffer().map(line => line.trimEnd())).toEqual(rows("tool-", 10).slice(-4));
expect(term.getScrollBuffer().map(line => line.trimEnd())).toEqual(rows("tool-", 10));
} finally {
tui.stop();
}
@@ -247,7 +249,7 @@ describe("streaming scrollback defer", () => {
}
});
it("does not leave stale mutable live-region rows in native scrollback after a rerender", async () => {
it("recommits fresh rows without loss when the live region is replaced wholesale", async () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(24, 4);
overrideProbe(term, undefined);
@@ -271,16 +273,21 @@ describe("streaming scrollback defer", () => {
tui.requestRender();
await settle(term);
// The volatile block's head force-committed while it overflowed, then was
// replaced wholesale. The committed-prefix audit re-anchors at the first
// diverged row and recommits the fresh content: every running-fresh row
// reaches the tape (no loss). A stale pending-stale copy may stay frozen in
// native scrollback — duplication, never loss — which a full repaint (ED3
// on a real resize / Ctrl+L) clears; no ED3 fires during streaming.
const buffer = term.getScrollBuffer().map(line => line.trimEnd());
expect(eraseScrollbackCount(writes)).toBe(0);
expect(buffer.some(line => line.startsWith("pending-stale-"))).toBe(false);
expect(buffer).toContain("running-fresh-9");
for (const row of rows("running-fresh-", 10)) expect(buffer).toContain(row);
} finally {
tui.stop();
}
});
it("keeps the topmost live seam when a lower sibling also reports one", async () => {
it("keeps the topmost live seam and recommits fresh rows when a lower sibling also reports one", async () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(24, 4);
overrideProbe(term, undefined);
@@ -291,7 +298,7 @@ describe("streaming scrollback defer", () => {
// Status loader below the transcript: also reports a seam. Commits are
// prefix-only, so the engine must keep the TOPMOST seam — letting the
// lower sibling's seam win would move the boundary past the transcript's
// still-mutable rows and commit them as stale history.
// still-mutable rows.
const loader = new LiveLineList(["Working..."]);
try {
@@ -311,10 +318,11 @@ describe("streaming scrollback defer", () => {
tui.requestRender();
await settle(term);
// Fresh content recommits with no loss after the wholesale replace; a
// stale copy may remain frozen above it (duplication, never loss). No ED3.
const buffer = term.getScrollBuffer().map(line => line.trimEnd());
expect(eraseScrollbackCount(writes)).toBe(0);
expect(buffer.some(line => line.startsWith("pending-stale-"))).toBe(false);
expect(buffer).toContain("running-fresh-9");
for (const row of rows("running-fresh-", 10)) expect(buffer).toContain(row);
} finally {
tui.stop();
}
@@ -626,3 +634,407 @@ describe("streaming scrollback defer", () => {
}
});
});
/**
* Root child that reports an arbitrary `NativeScrollbackLiveRegion` seam, so a
* test can reproduce any barrier shape the TranscriptContainer emits without
* standing up the whole transcript. `undefined` on a method means "no seam".
*/
class SeamComponent implements Component, NativeScrollbackLiveRegion {
lines: string[] = [];
liveStart: number | undefined;
commitSafe: number | undefined;
snapSafe: number | undefined;
invalidate(): void {}
render(width: number): string[] {
return this.lines.map(line => line.slice(0, width));
}
getNativeScrollbackLiveRegionStart(): number | undefined {
return this.liveStart;
}
getNativeScrollbackCommitSafeEnd(): number | undefined {
return this.commitSafe;
}
getNativeScrollbackSnapshotSafeEnd(): number | undefined {
return this.snapSafe;
}
}
/** Indices in `buffer` where `needle` begins as a contiguous run. */
function contiguousAt(buffer: string[], needle: string[]): number[] {
const hits: number[] = [];
for (let i = 0; i + needle.length <= buffer.length; i++) {
let match = true;
for (let j = 0; j < needle.length; j++) {
if (buffer[i + j] !== needle[j]) {
match = false;
break;
}
}
if (match) hits.push(i);
}
return hits;
}
/**
* Structural no-loss invariant: the current frame appears as a contiguous run in
* order, and below its last occurrence there is only blank viewport padding
* (fresh content is the most recent thing on the tape; stale duplicates may sit
* above it — duplication, never loss). A row in neither history nor the viewport
* was "committed nowhere, painted nowhere" — the bug. The blank tolerance covers
* sub-viewport frames, whose viewport is padded with blank rows beneath the
* content. Returns the trimmed tape for further assertions.
*/
function expectNoLoss(term: VirtualTerminal, frame: string[]): string[] {
const buffer = term.getScrollBuffer().map(line => line.trimEnd());
const trimmed = frame.map(line => line.trimEnd());
const hits = contiguousAt(buffer, trimmed);
expect(hits.length).toBeGreaterThan(0);
// Nothing but blank padding may follow the frame's last (most recent) run:
// any non-blank row below it is content painted out of order or a duplicate
// of fresher content sitting above its source — both are loss-class bugs.
const tailStart = hits.at(-1)! + trimmed.length;
for (let i = tailStart; i < buffer.length; i++) {
expect(buffer[i]).toBe("");
}
return buffer;
}
describe("scrollback commit gap — commit-unstable barriers", () => {
let savedTerminalEnv: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of ["TERM_PROGRAM", "PI_TUI_RESIZE_IN_PLACE"]) {
savedTerminalEnv[key] = Bun.env[key];
delete Bun.env[key];
}
});
afterEach(() => {
for (const key in savedTerminalEnv) {
const value = savedTerminalEnv[key];
if (value === undefined) delete Bun.env[key];
else Bun.env[key] = value;
}
savedTerminalEnv = {};
});
it("does not drop the tail when a pending barrier above it is removed (S5/S6)", async () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const root = new SeamComponent();
try {
tui.addChild(root);
tui.start();
await settle(term);
const writes = capture(term);
// Small commit-unstable barrier above a long finalized tail, overflowing
// the 4-row viewport. liveStart=0 pins the seam; no commit/snapshot end.
const f1 = ["[tool pending]", ...rows("ans-", 8)];
root.lines = f1;
root.liveStart = 0;
tui.requestRender();
await settle(term);
expectNoLoss(term, f1);
// Barrier removed (agent moved past the tool / poll superseded): the tail
// shifts up. The audit must re-anchor instead of trusting the stale
// committed prefix and skipping the shifted rows.
const f2 = rows("ans-", 8);
root.lines = f2;
root.liveStart = undefined;
tui.requestRender();
await settle(term);
const buffer = expectNoLoss(term, f2);
for (const row of f2) expect(buffer).toContain(row);
expect(term.getViewport().map(line => line.trimEnd())).toEqual(rows("ans-", 8).slice(-4));
expect(eraseScrollbackCount(writes)).toBe(0);
} finally {
tui.stop();
}
});
it("does not drop result rows when a provisional preview is replaced by a longer result (S4)", async () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const root = new SeamComponent();
try {
tui.addChild(root);
tui.start();
await settle(term);
const writes = capture(term);
const preview = rows("preview-", 10);
root.lines = preview;
root.liveStart = 0;
tui.requestRender();
await settle(term);
expectNoLoss(term, preview);
const result = rows("result-", 9);
root.lines = result;
root.liveStart = undefined;
tui.requestRender();
await settle(term);
const buffer = expectNoLoss(term, result);
for (const row of result) expect(buffer).toContain(row);
expect(term.getViewport().map(line => line.trimEnd())).toEqual(rows("result-", 9).slice(-4));
expect(eraseScrollbackCount(writes)).toBe(0);
} finally {
tui.stop();
}
});
it("does not drop rows when a barrier partially collapses above a long tail (S10)", async () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const root = new SeamComponent();
try {
tui.addChild(root);
tui.start();
await settle(term);
const writes = capture(term);
// 3-row barrier over an 8-row tail (len 11), overflowing.
const f1 = [...rows("bar-", 3), ...rows("tail-", 8)];
root.lines = f1;
root.liveStart = 0;
tui.requestRender();
await settle(term);
expectNoLoss(term, f1);
// Barrier collapses to 1 row but the frame stays longer than the
// committed prefix (NOT the shrink-into-prefix branch), so the audit must
// catch the upward tail shift.
const f2 = ["bar-collapsed", ...rows("tail-", 8)];
root.lines = f2;
root.liveStart = undefined;
tui.requestRender();
await settle(term);
const buffer = expectNoLoss(term, f2);
for (const row of rows("tail-", 8)) expect(buffer).toContain(row);
expect(term.getViewport().map(line => line.trimEnd())).toEqual(f2.slice(-4));
expect(eraseScrollbackCount(writes)).toBe(0);
} finally {
tui.stop();
}
});
it("keeps a finalized tail in order when its live barrier sibling is removed (multi-child S6)", async () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 5);
overrideProbe(term, undefined);
const tui = new TUI(term);
// Realistic transcript shape: a still-live barrier block above a finalized
// tail block. The container concatenates them; the topmost seam (the
// barrier at row 0) pins the boundary, forcing the finalized tail to commit
// under it as it overflows.
const barrier = new LiveLineList(["[tool pending]"]);
const tail = new LineList(rows("out-", 10));
try {
tui.addChild(barrier);
tui.addChild(tail);
tui.start();
await settle(term);
const writes = capture(term);
// Force overflow: the concatenated frame is 11 rows over a 5-row viewport.
tui.requestRender();
await settle(term);
expectNoLoss(term, ["[tool pending]", ...rows("out-", 10)]);
// Remove the barrier. The finalized tail shifts up by one row; every
// out-* row must remain, in order, contiguous at the tape bottom.
tui.removeChild(barrier);
tui.requestRender();
await settle(term);
const buffer = expectNoLoss(term, rows("out-", 10));
// Strong in-order check: the whole tail is one contiguous run at the
// bottom (not merely each row present somewhere).
expect(buffer.slice(-10)).toEqual(rows("out-", 10));
expect(term.getViewport().map(line => line.trimEnd())).toEqual(rows("out-", 10).slice(-5));
expect(eraseScrollbackCount(writes)).toBe(0);
} finally {
tui.stop();
}
});
it("survives a streaming-then-removed barrier across many frames without loss", async () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 5);
overrideProbe(term, undefined);
const tui = new TUI(term);
const root = new SeamComponent();
try {
tui.addChild(root);
tui.start();
await settle(term);
// Pending barrier above a tail that grows every frame, overflowing
// further each tick; the barrier pins the seam at 0 (commit-unstable).
for (let n = 1; n <= 20; n++) {
const frame = ["[pending]", ...rows("row-", n)];
root.lines = frame;
root.liveStart = 0;
tui.requestRender();
await settle(term);
expectNoLoss(term, frame);
}
const final = rows("row-", 20);
root.lines = final;
root.liveStart = undefined;
tui.requestRender();
await settle(term);
const buffer = expectNoLoss(term, final);
for (const row of final) expect(buffer).toContain(row);
expect(term.getViewport().map(line => line.trimEnd())).toEqual(rows("row-", 20).slice(-5));
} finally {
tui.stop();
}
});
it("audits a forced card below a durable prose tail without spraying its snapshot", async () => {
if (process.platform === "win32") return;
// Coexistence case: a commit-stable streaming block whose volatile tail is
// durable-exempt (snapSafe past its body) with a finalized card committed
// BELOW it. The card is a forced-overflow row that MUST stay audited, while
// the prose tail's in-place re-wrap must NOT spray duplicate snapshots.
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const root = new SeamComponent();
try {
tui.addChild(root);
tui.start();
await settle(term);
const writes = capture(term);
for (let n = 0; n < 12; n++) {
const prose = rows("prose-", 8);
prose[7] = `prose-7 [w${n}]`; // volatile tail re-wraps in place
root.lines = [...prose, "card-0", "card-1"];
root.liveStart = 0;
root.commitSafe = 7; // byte-stable through prose-6
root.snapSafe = 8; // durable through the whole prose body
tui.requestRender();
await settle(term);
}
const buffer = term.getScrollBuffer().map(line => line.trimEnd());
// No spray: durable prose head committed once, not once-per-drift.
expect(contiguousAt(buffer, ["prose-0", "prose-1", "prose-2"]).length).toBeLessThanOrEqual(2);
// No loss: the finalized card below the durable tail reached the tape.
expect(buffer).toContain("card-0");
expect(buffer).toContain("card-1");
expect(eraseScrollbackCount(writes)).toBe(0);
} finally {
tui.stop();
}
});
it("does not lose a single-row finalize edit above an unchanged tail (reviewer repro)", async () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const root = new SeamComponent();
try {
tui.addChild(root);
tui.start();
await settle(term);
const writes = capture(term);
// Commit-unstable barrier ("preview") above an 8-row tail, overflowing.
const f1 = ["preview", ...rows("tail-", 8)];
root.lines = f1;
root.liveStart = 0;
tui.requestRender();
await settle(term);
expectNoLoss(term, f1);
// Finalize: the seam clears and ONLY row 0 changes (preview→result); the
// whole tail is byte-identical. The committed head "preview" scrolled
// above the viewport, so the change is invisible unless the audit
// re-anchors. Tail-sample tolerance alone would skip it (one mismatch
// over an aligned tail) and "result" would be committed nowhere, painted
// nowhere — the reviewer's loss. The hard scan of the now-permanent
// forced suffix forces the re-anchor.
const f2 = ["result", ...rows("tail-", 8)];
root.lines = f2;
root.liveStart = undefined; // seam cleared: forced rows are now permanent
tui.requestRender();
await settle(term);
const buffer = expectNoLoss(term, f2);
expect(buffer).toContain("result");
expect(term.getViewport().map(line => line.trimEnd())).toEqual(f2.slice(-4));
expect(eraseScrollbackCount(writes)).toBe(0);
} finally {
tui.stop();
}
});
it("does not lose a single-row finalize edit far above a long unchanged tail (deep tail)", async () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const root = new SeamComponent();
try {
tui.addChild(root);
tui.start();
await settle(term);
const writes = capture(term);
// The changed row sits ~30 rows above the commit boundary with an
// unchanged tail — far outside the 24-row tail-sample lookback, so only a
// FULL scan of the now-permanent forced suffix catches it.
const f1 = ["preview", ...rows("tail-", 30)];
root.lines = f1;
root.liveStart = 0;
tui.requestRender();
await settle(term);
expectNoLoss(term, f1);
const f2 = ["result", ...rows("tail-", 30)];
root.lines = f2;
root.liveStart = undefined;
tui.requestRender();
await settle(term);
const buffer = expectNoLoss(term, f2);
// "result" reached the tape exactly once: the stale "preview" head was
// never overwritten in place (no ED3) but the changed row is "result",
// not a second "preview" — duplication of identical rows, never loss.
expect(buffer).toContain("result");
expect(buffer.filter(line => line === "result")).toHaveLength(1);
expect(eraseScrollbackCount(writes)).toBe(0);
} finally {
tui.stop();
}
});
});