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:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user