diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 1a1af5ab5..ddaef7f67 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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); diff --git a/packages/tui/test/streaming-scrollback-defer.test.ts b/packages/tui/test/streaming-scrollback-defer.test.ts index 3b020d871..dda8219b7 100644 --- a/packages/tui/test/streaming-scrollback-defer.test.ts +++ b/packages/tui/test/streaming-scrollback-defer.test.ts @@ -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);