fix(tui): promoted forced-overflow rows into offer zone

Extended the audited-offer classification to cover rows already committed as forced-overflow when the offer boundary rises to include them, and widened the committed-prefix hard scan to the offer boundary so a single-row shift inside the offered zone catches instead of being tolerated by the tail sample.

Added regression asserting the offer boundary appearing after prior overflow still repairs the shifted tail on the next live growth.

Addresses codex-connector review on #4330.
This commit is contained in:
roboomp
2026-07-02 16:37:45 +00:00
parent 49b4ef50f8
commit 6ed19b5dbe
2 changed files with 81 additions and 13 deletions
+27 -13
View File
@@ -2755,18 +2755,27 @@ export class TUI extends Container {
let committedRowsResynced = false;
// Audit covers [0, auditRows) and the forced suffix [durableRows,
// committedRows); the durable middle [auditRows, durableRows) is exempt
// (in-place drift). Two reasons to run the audit this frame:
// (in-place drift). Three reasons to run the audit this frame:
// - the stable prefix does not cover every audited row (auditUpper); or
// - a forced-overflow row this frame became durable/permanent
// (committedPrefixDurableRows < hardAuditEnd): the barrier above it
// finalized, so its committed bytes must be re-checked even though the
// stable prefix says nothing moved — a stale committed copy there would
// silently drop the row. The hard scan in findCommittedPrefixResync
// covers [durableRows, hardAuditEnd) in full (no tail-sample miss).
// (committedPrefixDurableRows < min(committed, durableBoundary)): the
// barrier above it finalized, so its committed bytes must be re-checked
// even though the stable prefix says nothing moved — a stale committed
// copy there would silently drop the row; or
// - a forced-overflow row this frame joined the offered zone
// (committedPrefixOfferRows < min(committed, offerBoundary)): a
// finalized sibling under a live block asks for destructive-replay
// repair, so a single-row shift there must be caught even if
// tail-sample tolerance would otherwise skip it.
// The hard scan in findCommittedPrefixResync covers [durableRows,
// hardAuditEnd) in full (no tail-sample miss).
const auditUpper =
this.#committedPrefixDurableRows < this.#committedRows ? this.#committedRows : this.#committedPrefixAuditRows;
const hardAuditEnd = Math.min(this.#committedRows, durableBoundary);
const needHardAudit = this.#committedPrefixDurableRows < hardAuditEnd;
const durableHardEnd = Math.min(this.#committedRows, durableBoundary);
const offerHardEnd = Math.min(this.#committedRows, offerBoundary);
const hardAuditEnd = Math.max(durableHardEnd, offerHardEnd);
const needHardAudit =
this.#committedPrefixDurableRows < durableHardEnd || this.#committedPrefixOfferRows < offerHardEnd;
let repairOfferedScrollback = false;
const auditRan =
this.#hasEverRendered &&
@@ -2777,7 +2786,7 @@ export class TUI extends Container {
const committedRowsBeforeAudit = this.#committedRows;
const offeredRowsBeforeAudit = this.#committedPrefixOfferRows;
const durableRowsBeforeAudit = this.#committedPrefixDurableRows;
this.#auditCommittedPrefix(rawFrame, durableBoundary);
this.#auditCommittedPrefix(rawFrame, hardAuditEnd);
committedRowsResynced = this.#committedRows !== committedRowsBeforeAudit;
repairOfferedScrollback =
offeredRowsBeforeAudit > durableRowsBeforeAudit &&
@@ -3027,10 +3036,15 @@ export class TUI extends Container {
resliced || preDurableRows >= preCommittedRows || hardAudited
? Math.min(committed, durableBoundary)
: Math.min(preDurableRows, committed);
const offerRows =
resliced || preOfferRows >= preCommittedRows
? Math.min(committed, offerBoundary)
: Math.min(preOfferRows, committed);
// offerRows may EXTEND to include forced-overflow rows within a
// newly-visible offerBoundary — unlike auditRows/durableRows, retroactive
// promotion is safe: both offered and forced-overflow zones stay
// audited; offered only switches divergence repair from tolerant recommit
// to destructive replay (stronger, not weaker). A dropped offerBoundary
// still keeps the durability rule via `preOfferRows` (never demote
// already-offered rows to forced-overflow).
const offerCap = Math.min(committed, offerBoundary);
const offerRows = resliced ? offerCap : Math.max(Math.min(preOfferRows, committed), offerCap);
this.#committedPrefixAuditRows = auditRows;
this.#committedPrefixDurableRows = Math.max(auditRows, durableRows);
this.#committedPrefixOfferRows = Math.max(this.#committedPrefixDurableRows, offerRows);
@@ -994,6 +994,60 @@ describe("scrollback commit gap — commit-unstable barriers", () => {
}
});
it("promotes forced-overflow rows to offered when the offer boundary appears after commit", 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);
// Phase 1: only the live barrier overflows the viewport. Rows scroll
// off as forced-overflow (no offer boundary).
const f1 = rows("live-", 10);
root.lines = f1;
root.liveStart = 0;
root.offerSafe = undefined;
tui.requestRender();
await settle(term);
const commitAfterF1 = term.getScrollBuffer().length;
expect(commitAfterF1).toBeGreaterThan(0);
// Phase 2: finalized siblings are appended below the still-live block;
// the component now reports an offer boundary covering the whole frame.
// The engine must retroactively classify the already-committed rows
// under the offer boundary as offered so the next divergence takes the
// destructive-replay repair path, not tolerant recommit.
const f2 = [...rows("live-", 10), ...rows("tail-", 5)];
root.lines = f2;
root.liveStart = 0;
root.offerSafe = f2.length;
tui.requestRender();
await settle(term);
// Phase 3: live block grows by one row, shifting the whole tail up.
const f3 = [...rows("live-", 11), ...rows("tail-", 5)];
root.lines = f3;
root.liveStart = 0;
root.offerSafe = f3.length;
tui.requestRender();
await settle(term);
const buffer = term.getScrollBuffer().map(line => line.trimEnd());
for (const row of rows("tail-", 5)) {
expect(buffer.filter(line => line === row)).toHaveLength(1);
}
expect(eraseScrollbackCount(writes)).toBeGreaterThan(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);