From ddf4e6bdf24602db4f65e6e951d017c4e28b2f31 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:04:28 -0700 Subject: [PATCH] fix(terminal): avoid duplicate snapshot replays after reveal (#30205) Retain reveal sequence coverage until a parsed snapshot covers it, preventing a duplicate text-only replay from clearing newer terminal images. Keep conservative fallback recovery for unknown episodes, missing metadata and different terminal processes. Verified against current main: 406 tests, full application typecheck, changed-code quality and four fresh hidden rendered restore checks pass. No xterm patches or dependency changes. --- ...ction-hidden-restore-flood-abandon.test.ts | 197 +----------- ...ion-hidden-restore-unhide-coverage.test.ts | 290 ++++++++++++++++++ ...en-restore-unhide-repaint-fallback.test.ts | 169 ++++++++++ ...hidden-restore-unhide-retired-task.test.ts | 144 +++++++++ ...ty-connection-test-hidden-restore-flood.ts | 124 ++++++++ .../connect-pane-pty-session.ts | 1 + .../foreground-output-refresh.ts | 7 +- .../hidden-output-restore-abandon.ts | 1 + .../hidden-output-restore-request.ts | 6 +- .../hidden-output-snapshot-serialize.ts | 6 + .../pty-connection/hidden-restore-state.ts | 1 + .../hidden-unhide-snapshot-coverage.ts | 75 +++++ .../transport-output-callbacks.ts | 1 + 13 files changed, 833 insertions(+), 189 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-unhide-coverage.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-unhide-repaint-fallback.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-unhide-retired-task.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-test-hidden-restore-flood.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-unhide-snapshot-coverage.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-flood-abandon.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-flood-abandon.test.ts index a0a13b1a5aa..f8293c52267 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-flood-abandon.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-flood-abandon.test.ts @@ -1,22 +1,17 @@ -import type * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import './pty-connection-mocks.spec' +import { describe, expect, it, vi } from 'vitest' import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' import { - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockPane, - type MockTransport -} from './pty-connection-test-pane-fixtures' -import { buildPaneConnectionDeps, type PaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' -import type { StoreState } from './pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from './pty-connection-test-environment' -import { connectMockPanePty } from './pty-connection-test-connect-mock-pane' + announceMainDrop, + BANNER_FRAGMENT, + connectVisiblePane, + installHiddenRestoreFloodSuiteHooks, + modelSnapshot, + OVERFLOW_CHUNK, + PTY_ID, + type MainSnapshot, + type PaneDrive +} from './pty-connection-test-hidden-restore-flood' // STA-4869 — routes R3 (pending-queue overflow, pty-connection.ts:7696) and R4 // (loop iteration cap, 7702) end in the QUIET abandon, which trades the loss @@ -25,21 +20,7 @@ import { connectMockPanePty } from './pty-connection-test-connect-mock-pane' // disclosed. These cases must also survive the pane being scrolled back or // hidden when the deferred repaint comes due. -const { - resetAndRefreshAllTerminalWebglAtlases, - scheduleTerminalWebglAtlasRecovery, - scheduleRuntimeGraphSync, - shouldSeedCacheTimerOnInitialTitle, - toastInfo, - notifyCodexPaneBoundForStaleSweep, - warnTerminalLifecycleAnomaly -} = vi.hoisted(() => ({ - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalWebglAtlasRecovery: vi.fn(), - scheduleRuntimeGraphSync: vi.fn(), - shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), - toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn(), +const { warnTerminalLifecycleAnomaly } = vi.hoisted(() => ({ warnTerminalLifecycleAnomaly: vi.fn() })) @@ -50,146 +31,8 @@ vi.mock('./terminal-lifecycle-diagnostics', async (importOriginal) => ({ warnTerminalLifecycleAnomaly })) -let mockStoreState: StoreState -let transportFactoryQueue: MockTransport[] = [] -let createdTransportOptions: Record[] = [] -let storeSubscribers: ((state: StoreState) => void)[] = [] - -vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync })) - -vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ - ...(await importOriginal>()), - resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mockStoreState, - subscribe: (listener: (state: StoreState) => void) => { - storeSubscribers.push(listener) - return () => { - storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) - } - } - } -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') - return buildAgentStatusModuleMock(await importOriginal>()) -}) - -vi.mock('./cache-timer-seeding', () => ({ - shouldSeedCacheTimerOnInitialTitle -})) - -vi.mock('sonner', () => ({ toast: { info: toastInfo } })) - -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ - notifyCodexPaneBoundForStaleSweep -})) - -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useCallback: unknown>(fn: T): T => fn - } -}) - -vi.mock('./pty-transport', () => ({ - createIpcPtyTransport: vi.fn((options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - }) -})) - -vi.mock('./remote-runtime-pty-transport', () => ({ - createRemoteRuntimePtyTransport: vi.fn( - (_environmentId: string, options: Record) => { - createdTransportOptions.push(options) - const nextTransport = transportFactoryQueue.shift() - if (!nextTransport) { - throw new Error('No mock transport queued') - } - return nextTransport - } - ) -})) - -vi.mock('./pty-dispatcher', async (importOriginal) => ({ - ...(await importOriginal>()), - getEagerPtyBufferHandle: vi.fn(() => undefined) -})) - -const PTY_ID = 'pty-id' -const BANNER_FRAGMENT = 'main recovery was unavailable' const R3_MARKER = 'R3-LOST-4d17' const R4_MARKER = 'R4-LOST-8ba0' -// One char over HIDDEN_OUTPUT_RESTORE_PENDING_CHARS (512KB): the queue discards -// its whole backlog and latches overflow. -const OVERFLOW_CHUNK = 'o'.repeat(512 * 1024 + 1) - -type MainSnapshot = { data: string; cols: number; rows: number; seq: number } - -function modelSnapshot(marker: string, seq: number): MainSnapshot { - return { data: `${marker}\r\n`, cols: 120, rows: 40, seq } -} - -type PaneDrive = { - pane: MockPane - deps: PaneConnectionDeps - disposable: { dispose: () => void } - deliver: (data: string, seq: number) => void - setVisible: (visible: boolean) => void - writtenChunks: () => string[] -} - -async function connectVisiblePane(): Promise { - const transport = createMockTransport(PTY_ID) - const captured: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null - } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks?: ConnectCallbacks }) => { - captured.current = callbacks?.onData ?? null - return PTY_ID - }) - transportFactoryQueue.push(transport) - const pane = createPane(1) - const deps = buildPaneConnectionDeps(() => mockStoreState, { - isVisibleRef: { current: true } - }) - const disposable = await connectMockPanePty(pane, createManager(1), deps) - await flushAsyncTicks(6) - expect(captured.current).not.toBeNull() - return { - pane, - deps, - disposable, - deliver: (data, seq) => captured.current?.(data, { seq, rawLength: data.length }), - setVisible: (visible) => { - deps.isVisibleRef.current = visible - }, - writtenChunks: () => pane.terminal.write.mock.calls.map(([data]) => String(data)) - } -} - -async function announceMainDrop(markerSeq: number): Promise { - const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') - _dispatchPtyModelRestoreNeededForTest({ - id: PTY_ID, - reason: 'hidden-drop', - markerSeq - }) -} function countOccurrences(haystack: string, needle: string): number { return haystack.split(needle).length - 1 @@ -220,19 +63,7 @@ function expectDisclosedOrRecovered(disclosure: Disclosure): void { } describe('hidden-output restore quiet abandonment (STA-4869)', () => { - beforeEach(async () => { - vi.resetModules() - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) + installHiddenRestoreFloodSuiteHooks() // ── R3: foreground pending-queue overflow ─────────────────────────────── describe('R3 pending-queue overflow', () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-unhide-coverage.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-unhide-coverage.test.ts new file mode 100644 index 00000000000..d9ae87da730 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-unhide-coverage.test.ts @@ -0,0 +1,290 @@ +import './pty-connection-mocks.spec' +import { describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' +import { + announceMainDrop, + BANNER_FRAGMENT, + connectVisiblePane, + installHiddenRestoreFloodSuiteHooks, + modelSnapshot, + PTY_ID, + type MainSnapshot +} from './pty-connection-test-hidden-restore-flood' + +// An unhide marker during an in-flight restore is retired by that snapshot when it covers +// the marker's seq, instead of costing a second destructive replay. + +describe('hidden-output restore unhide coverage (STA-4869)', () => { + installHiddenRestoreFloodSuiteHooks() + + it('acknowledges a first-seen unhide episode before newer live output avoids another replay', async () => { + const drive = await connectVisiblePane() + vi.useFakeTimers() + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + const first = createDeferred() + getMainBufferSnapshot.mockReturnValueOnce(first.promise) + getMainBufferSnapshot.mockResolvedValue(modelSnapshot('ACKNOWLEDGED_CAPTURE', 128)) + const ackRestore = vi.mocked(window.api.pty.ackHiddenOutputRestoreApplied) + try { + await announceMainDrop(64, undefined, 'pending-cap') + await announceMainDrop(64, 1, 'unhide') + first.resolve(modelSnapshot('FIRST_CAPTURE', 128)) + await flushAsyncTicks(40) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(ackRestore.mock.calls).toEqual([[PTY_ID, undefined]]) + await vi.advanceTimersByTimeAsync(2_100) + await flushAsyncTicks(40) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(ackRestore.mock.calls).toEqual([ + [PTY_ID, undefined], + [PTY_ID, 1] + ]) + const live = 'LIVE_IMAGE_AFTER_ACK_WINDOW\r\n' + drive.deliver(live, 128 + live.length) + await flushAsyncTicks(10) + expect(drive.writtenChunks().join('')).toContain(live) + const writesBeforeQuietReveal = drive.writtenChunks().length + await vi.advanceTimersByTimeAsync(2_100) + await flushAsyncTicks(40) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(ackRestore).toHaveBeenCalledTimes(2) + expect(drive.writtenChunks().slice(writesBeforeQuietReveal).join('')).not.toContain('\x1b[2J') + } finally { + drive.disposable.dispose() + } + }) + + it('keeps the unhide requirement until a repeatedly stale snapshot finally covers it', async () => { + const drive = await connectVisiblePane() + vi.useFakeTimers() + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + const first = createDeferred() + getMainBufferSnapshot + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce(modelSnapshot('SECOND_STALE_CAPTURE', 128)) + .mockResolvedValueOnce(modelSnapshot('THIRD_COVERING_CAPTURE', 512)) + try { + await announceMainDrop(64, 1) + await announceMainDrop(256, 1, 'unhide') + first.resolve(modelSnapshot('FIRST_STALE_CAPTURE', 128)) + await flushAsyncTicks(100) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(3) + expect(drive.writtenChunks().join('')).toContain('THIRD_COVERING_CAPTURE') + expect(drive.writtenChunks().join('')).not.toContain(BANNER_FRAGMENT) + } finally { + drive.disposable.dispose() + } + }) + + it.each([undefined, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + 'does not treat snapshot sequence %s as covering the pending unhide', + async (seq) => { + const drive = await connectVisiblePane() + vi.useFakeTimers() + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + const first = createDeferred<{ + data: string + cols: number + rows: number + seq?: number + }>() + getMainBufferSnapshot.mockReturnValueOnce(first.promise) + getMainBufferSnapshot.mockResolvedValue(modelSnapshot('FINITE_COVERING_CAPTURE', 512)) + try { + await announceMainDrop(64, 1) + await announceMainDrop(256, 1, 'unhide') + first.resolve({ ...modelSnapshot('UNPROVEN_CAPTURE', 128), seq }) + await flushAsyncTicks(70) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(drive.writtenChunks().join('')).toContain('FINITE_COVERING_CAPTURE') + } finally { + drive.disposable.dispose() + } + } + ) + + it('consumes a covered unhide marker received after the response while its replay is parsing', async () => { + const drive = await connectVisiblePane() + vi.useFakeTimers() + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + getMainBufferSnapshot.mockResolvedValue(modelSnapshot('REPLAY_PARSING', 128)) + const pendingParses: (() => void)[] = [] + const originalWrite = drive.pane.terminal.write.getMockImplementation() + drive.pane.terminal.write.mockImplementation((data, callback) => { + if ((data === '' || callback?.name === 'runParsedSteps') && callback) { + pendingParses.push(callback) + } + }) + const finishParsing = async (): Promise => { + for (let round = 0; round < 10; round += 1) { + while (pendingParses.length > 0) { + pendingParses.shift()?.() + } + await flushAsyncTicks(10) + } + } + try { + await announceMainDrop(64, 1) + await flushAsyncTicks(20) + expect(drive.writtenChunks().join('')).toContain('REPLAY_PARSING') + expect(window.api.pty.ackHiddenOutputRestoreApplied).not.toHaveBeenCalled() + await announceMainDrop(128, 1, 'unhide') + await finishParsing() + expect(window.api.pty.ackHiddenOutputRestoreApplied).toHaveBeenCalledWith(PTY_ID, 1) + const live = 'LIVE_AFTER_PARSED_REPLAY\r\n' + drive.deliver(live, 128 + live.length) + await finishParsing() + const beforeTimer = drive.writtenChunks().length + + await vi.advanceTimersByTimeAsync(2_100) + await finishParsing() + expect.soft(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(drive.writtenChunks().slice(beforeTimer).join('')).not.toContain('\x1b[2J') + } finally { + if (originalWrite) { + drive.pane.terminal.write.mockImplementation(originalWrite) + } + drive.disposable.dispose() + } + }) + + it('does not clear newer live output for an unhide marker the successful snapshot covered', async () => { + const drive = await connectVisiblePane() + vi.useFakeTimers() + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + const first = createDeferred() + getMainBufferSnapshot.mockReturnValueOnce(first.promise) + getMainBufferSnapshot.mockResolvedValue(modelSnapshot('UNNECESSARY_SECOND_REPLAY', 256)) + try { + await announceMainDrop(64, 1) + await announceMainDrop(64, 1, 'unhide') + first.resolve(modelSnapshot('COVERED_DROP', 128)) + await flushAsyncTicks(30) + expect(window.api.pty.ackHiddenOutputRestoreApplied).toHaveBeenCalledWith(PTY_ID, 1) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + const live = 'LIVE_AFTER_SUCCESS\r\n' + drive.deliver(live, 128 + live.length) + await flushAsyncTicks(10) + expect(drive.writtenChunks().join('')).toContain(live) + const beforeTimer = drive.writtenChunks().length + await vi.advanceTimersByTimeAsync(2_100) + await flushAsyncTicks(30) + + expect.soft(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(drive.writtenChunks().slice(beforeTimer).join('')).not.toContain('\x1b[2J') + } finally { + drive.disposable.dispose() + } + }) + + it.each(['pending-cap', 'delivery-heal'] as const)( + 'keeps a %s marker gated right after a covering unhide restore completes', + async (reason) => { + const drive = await connectVisiblePane() + vi.useFakeTimers() + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + const first = createDeferred() + getMainBufferSnapshot.mockReturnValueOnce(first.promise) + getMainBufferSnapshot.mockResolvedValue(modelSnapshot('POST_FLOOD_REPAINT', 512)) + try { + await announceMainDrop(64, 1) + await announceMainDrop(64, 1, 'unhide') + first.resolve(modelSnapshot('COVERED_DROP', 128)) + await flushAsyncTicks(30) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(500) + await announceMainDrop(256, undefined, reason) + await flushAsyncTicks(30) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(2_100) + await flushAsyncTicks(30) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(drive.writtenChunks().join('')).toContain('POST_FLOOD_REPAINT') + } finally { + drive.disposable.dispose() + } + } + ) + + it.each([ + { loss: 'a newer episode', markerSeq: 256, episode: 2 }, + { + loss: 'a repeatedly reported newer episode', + markerSeq: 256, + episode: 2, + repeat: true + }, + { + loss: 'a sequence-less repeat after a sequenced marker', + markerSeq: 256, + episode: 1, + fallback: 'unhide' + }, + { + loss: 'a pending cap after a sequenced marker', + markerSeq: 256, + episode: 1, + fallback: 'pending-cap' + }, + { + loss: 'covered bytes in a newly reported episode', + markerSeq: 64, + episode: 2 + }, + { + loss: 'covered bytes without an episode', + markerSeq: 64, + episode: undefined + }, + { loss: 'newer bytes in the same episode', markerSeq: 256, episode: 1 }, + { loss: 'an episode-less marker', markerSeq: 256, episode: undefined }, + { loss: 'a sequence-less marker', markerSeq: undefined, episode: 1 } + ])('still repaints $loss reported while the older snapshot was fetching', async (scenario) => { + const { markerSeq, episode } = scenario + const drive = await connectVisiblePane() + vi.useFakeTimers() + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + const first = createDeferred() + getMainBufferSnapshot.mockReturnValueOnce(first.promise) + getMainBufferSnapshot.mockResolvedValue(modelSnapshot('NEW_EPISODE_RECOVERED', 512)) + try { + await announceMainDrop(64, 1) + await announceMainDrop(markerSeq, episode, 'unhide') + if ('repeat' in scenario && scenario.repeat) { + await announceMainDrop(markerSeq, episode, 'unhide') + } + if ('fallback' in scenario && scenario.fallback === 'unhide') { + await announceMainDrop(undefined, episode, 'unhide') + } else if ('fallback' in scenario && scenario.fallback === 'pending-cap') { + await announceMainDrop(undefined, undefined, 'pending-cap') + } + first.resolve(modelSnapshot('ONLY_OLD_EPISODE_COVERED', 128)) + await flushAsyncTicks(30) + expect(window.api.pty.ackHiddenOutputRestoreApplied).toHaveBeenCalledWith(PTY_ID, 1) + + const repairAlreadyPainted = getMainBufferSnapshot.mock.calls.length === 2 + const live = 'LIVE_AFTER_COVERING_REPAIR\r\n' + if (repairAlreadyPainted) { + drive.deliver(live, 512 + live.length) + await flushAsyncTicks(10) + } + const writesBeforeTimer = drive.writtenChunks().length + + await vi.advanceTimersByTimeAsync(2_100) + await flushAsyncTicks(30) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + if (repairAlreadyPainted) { + expect(drive.writtenChunks().slice(writesBeforeTimer).join('')).not.toContain('\x1b[2J') + } + expect(drive.writtenChunks().join('')).toContain('NEW_EPISODE_RECOVERED') + if (episode !== undefined) { + expect(window.api.pty.ackHiddenOutputRestoreApplied).toHaveBeenCalledWith(PTY_ID, episode) + } + } finally { + drive.disposable.dispose() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-unhide-repaint-fallback.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-unhide-repaint-fallback.test.ts new file mode 100644 index 00000000000..c097d02670c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-unhide-repaint-fallback.test.ts @@ -0,0 +1,169 @@ +import './pty-connection-mocks.spec' +import { describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' +import { + announceMainDrop, + BANNER_FRAGMENT, + connectVisiblePane, + installHiddenRestoreFloodSuiteHooks, + modelSnapshot, + OVERFLOW_CHUNK, + PTY_ID, + type MainSnapshot +} from './pty-connection-test-hidden-restore-flood' + +// Unhide markers the in-flight snapshot cannot retire fall back to the deferred quiet repaint +// or a retry, and must still recover without a loss banner. + +describe('hidden-output restore unhide repaint fallback (STA-4869)', () => { + installHiddenRestoreFloodSuiteHooks() + + it('does not leave a parked repaint after a repeated episode is repaired', async () => { + const { markTerminalFollowOutput, markTerminalPinnedViewport } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const drive = await connectVisiblePane() + vi.useFakeTimers() + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + const first = createDeferred() + getMainBufferSnapshot.mockReturnValueOnce(first.promise) + getMainBufferSnapshot.mockResolvedValue(modelSnapshot('PARKED_EPISODE_RECOVERED', 512)) + try { + await announceMainDrop(64, 1) + drive.pane.terminal.buffer.active.baseY = 100 + drive.pane.terminal.buffer.active.viewportY = 42 + markTerminalPinnedViewport(drive.pane.terminal) + await announceMainDrop(256, 2, 'unhide') + await vi.advanceTimersByTimeAsync(2_100) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + await announceMainDrop(256, 2, 'unhide') + first.resolve(modelSnapshot('PRE_EPISODE_CAPTURE', 128)) + await flushAsyncTicks(50) + const repairAlreadyPainted = getMainBufferSnapshot.mock.calls.length === 2 + if (repairAlreadyPainted) { + const live = 'LIVE_AFTER_PARKED_REPAIR\r\n' + drive.deliver(live, 512 + live.length) + await flushAsyncTicks(10) + } + const writesBeforeFollowing = drive.writtenChunks().length + drive.pane.terminal.buffer.active.viewportY = drive.pane.terminal.buffer.active.baseY + markTerminalFollowOutput(drive.pane.terminal) + await vi.advanceTimersByTimeAsync(2_100) + await flushAsyncTicks(50) + expect.soft(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + if (repairAlreadyPainted) { + expect(drive.writtenChunks().slice(writesBeforeFollowing).join('')).not.toContain('\x1b[2J') + } + } finally { + drive.disposable.dispose() + } + }) + + it('keeps the deferred repaint when pending live bytes overflow before the snapshot drains', async () => { + const drive = await connectVisiblePane() + vi.useFakeTimers() + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + const first = createDeferred() + getMainBufferSnapshot.mockReturnValueOnce(first.promise) + getMainBufferSnapshot.mockResolvedValue(modelSnapshot('OVERFLOW_RECOVERED', 1_000_000)) + try { + await announceMainDrop(64, 1) + await announceMainDrop(64, 1) + drive.deliver(OVERFLOW_CHUNK, 64 + OVERFLOW_CHUNK.length) + first.resolve(modelSnapshot('PRE_OVERFLOW_ONLY', 128)) + await flushAsyncTicks(30) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(2_100) + await flushAsyncTicks(30) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(drive.writtenChunks().join('')).toContain('OVERFLOW_RECOVERED') + expect(drive.writtenChunks().join('')).not.toContain(BANNER_FRAGMENT) + } finally { + drive.disposable.dispose() + } + }) + + it('keeps the existing repaint behavior for a pending-cap marker', async () => { + const drive = await connectVisiblePane() + vi.useFakeTimers() + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + const first = createDeferred() + getMainBufferSnapshot.mockReturnValueOnce(first.promise) + getMainBufferSnapshot.mockResolvedValue(modelSnapshot('PENDING_CAP_RECOVERED', 512)) + try { + await announceMainDrop(64, 1) + await announceMainDrop(64, undefined, 'pending-cap') + first.resolve(modelSnapshot('EARLIER_CAPTURE', 128)) + await flushAsyncTicks(30) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(2_100) + await flushAsyncTicks(30) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(drive.writtenChunks().join('')).toContain('PENDING_CAP_RECOVERED') + } finally { + drive.disposable.dispose() + } + }) + + it('fetches a fresh snapshot when hidden bytes arrived during the first fetch', async () => { + const drive = await connectVisiblePane() + vi.useFakeTimers() + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + const first = createDeferred() + const second = createDeferred() + getMainBufferSnapshot.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + try { + await announceMainDrop(64, 1) + await announceMainDrop(64, 1) + drive.setVisible(false) + drive.deliver('HIDDEN_AFTER_CAPTURE\r\n', 256) + drive.setVisible(true) + first.resolve(modelSnapshot('FIRST_CAPTURE', 128)) + await flushAsyncTicks(30) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(2_100) + second.resolve(modelSnapshot('FRESH_CAPTURE', 512)) + await flushAsyncTicks(30) + expect(drive.writtenChunks().join('')).toContain('FRESH_CAPTURE') + expect(drive.writtenChunks().join('')).not.toContain(BANNER_FRAGMENT) + } finally { + drive.disposable.dispose() + } + }) + + it.each(['reject', 'unavailable'] as const)( + 'keeps recovery pending after the first snapshot is %s', + async (failure) => { + const drive = await connectVisiblePane() + vi.useFakeTimers() + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + const first = createDeferred() + getMainBufferSnapshot.mockReturnValueOnce(first.promise) + getMainBufferSnapshot.mockResolvedValue(modelSnapshot('RECOVERED_AFTER_REFUSAL', 512)) + try { + await announceMainDrop(64, 1) + await announceMainDrop(64, 1, 'unhide') + if (failure === 'reject') { + first.reject(new Error('snapshot unavailable')) + } else { + first.resolve(null) + } + await flushAsyncTicks(30) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(window.api.pty.ackHiddenOutputRestoreApplied).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(75) + await flushAsyncTicks(30) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(drive.writtenChunks().join('')).toContain('RECOVERED_AFTER_REFUSAL') + expect(window.api.pty.ackHiddenOutputRestoreApplied).toHaveBeenCalledWith(PTY_ID, 1) + expect(drive.writtenChunks().join('')).not.toContain(BANNER_FRAGMENT) + } finally { + drive.disposable.dispose() + } + } + ) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-unhide-retired-task.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-unhide-retired-task.test.ts new file mode 100644 index 00000000000..47a2400c12d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-unhide-retired-task.test.ts @@ -0,0 +1,144 @@ +import './pty-connection-mocks.spec' +import { describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' +import { + announceMainDrop, + connectVisiblePane, + installHiddenRestoreFloodSuiteHooks, + modelSnapshot, + PTY_ID, + type MainSnapshot +} from './pty-connection-test-hidden-restore-flood' + +// A restore task retired by timeout, PTY swap, or dispose must neither paint nor consume +// the unhide recovery its successor owes the pane. + +describe('hidden-output restore unhide with a retired task (STA-4869)', () => { + installHiddenRestoreFloodSuiteHooks() + + it('ignores a timed-out task without consuming the next restore request', async () => { + const drive = await connectVisiblePane() + vi.useFakeTimers() + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + const first = createDeferred() + const second = createDeferred() + getMainBufferSnapshot.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + try { + await announceMainDrop(64, 1) + await announceMainDrop(64, 1, 'unhide') + const waitingLive = 'LIVE_HELD_FOR_TIMEOUT\r\n' + drive.deliver(waitingLive, 64 + waitingLive.length) + await vi.advanceTimersByTimeAsync(800) + await flushAsyncTicks(30) + expect(drive.writtenChunks().join('')).toContain(waitingLive) + + await announceMainDrop(256, 2) + await vi.advanceTimersByTimeAsync(2_100) + await flushAsyncTicks(30) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + first.resolve(modelSnapshot('STALE_TIMED_OUT_CAPTURE', 128)) + await flushAsyncTicks(30) + second.resolve(modelSnapshot('RECOVERED_AFTER_TIMEOUT', 512)) + await flushAsyncTicks(30) + await vi.advanceTimersByTimeAsync(2_100) + await flushAsyncTicks(30) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(drive.writtenChunks().join('')).not.toContain('STALE_TIMED_OUT_CAPTURE') + expect(drive.writtenChunks().join('')).toContain('RECOVERED_AFTER_TIMEOUT') + expect(window.api.pty.ackHiddenOutputRestoreApplied).toHaveBeenCalledWith(PTY_ID, 2) + } finally { + drive.disposable.dispose() + } + }) + + it('recovers a successor unhide with one quiet repaint, not a retired-task requirement too', async () => { + const drive = await connectVisiblePane() + vi.useFakeTimers() + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + const oldPtySnapshot = createDeferred() + const newPtySnapshot = createDeferred() + getMainBufferSnapshot + .mockReturnValueOnce(oldPtySnapshot.promise) + .mockReturnValueOnce(newPtySnapshot.promise) + .mockResolvedValue(modelSnapshot('SECOND_SUCCESSOR_REPLAY', 1024)) + try { + await announceMainDrop(64, 1) + await announceMainDrop(64, 1, 'unhide') + drive.transport.getPtyId.mockReturnValue('successor-pty') + drive.setVisible(false) + drive.deliver('NEW_PTY_OUTPUT\r\n', 32) + drive.setVisible(true) + await announceMainDrop(32, 2, 'hidden-drop', 'successor-pty') + await announceMainDrop(512, 2, 'unhide', 'successor-pty') + + oldPtySnapshot.resolve(modelSnapshot('RETIRED_PTY_CAPTURE', 2048)) + await flushAsyncTicks(30) + await vi.advanceTimersByTimeAsync(2_100) + await flushAsyncTicks(30) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + // Seq below the marker: only a recorded unhide requirement would force a second replay. + newPtySnapshot.resolve(modelSnapshot('SUCCESSOR_QUIET_REPAINT', 128)) + await flushAsyncTicks(30) + await vi.advanceTimersByTimeAsync(2_100) + await flushAsyncTicks(30) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + const written = drive.writtenChunks().join('') + expect(written).not.toContain('RETIRED_PTY_CAPTURE') + expect(written).toContain('SUCCESSOR_QUIET_REPAINT') + expect(written).not.toContain('SECOND_SUCCESSOR_REPLAY') + expect(window.api.pty.ackHiddenOutputRestoreApplied).not.toHaveBeenCalledWith(PTY_ID, 1) + expect(window.api.pty.ackHiddenOutputRestoreApplied).toHaveBeenCalledWith('successor-pty', 2) + } finally { + drive.disposable.dispose() + } + }) + + it('recovers an unhide-only successor while a retired PTY snapshot is pending', async () => { + const drive = await connectVisiblePane() + vi.useFakeTimers() + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + const oldPtySnapshot = createDeferred() + getMainBufferSnapshot + .mockReturnValueOnce(oldPtySnapshot.promise) + .mockResolvedValue(modelSnapshot('SUCCESSOR_UNHIDE_RECOVERED', 1024)) + try { + await announceMainDrop(64, 1) + drive.transport.getPtyId.mockReturnValue('successor-pty') + drive.setVisible(false) + drive.deliver('NEW_PTY_OUTPUT\r\n', 32) + drive.setVisible(true) + await announceMainDrop(512, 2, 'unhide', 'successor-pty') + + oldPtySnapshot.resolve(modelSnapshot('RETIRED_PTY_CAPTURE', 2048)) + await flushAsyncTicks(30) + await vi.advanceTimersByTimeAsync(2_100) + await flushAsyncTicks(30) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(drive.writtenChunks().join('')).not.toContain('RETIRED_PTY_CAPTURE') + expect(drive.writtenChunks().join('')).toContain('SUCCESSOR_UNHIDE_RECOVERED') + expect(window.api.pty.ackHiddenOutputRestoreApplied).not.toHaveBeenCalledWith(PTY_ID, 1) + expect(window.api.pty.ackHiddenOutputRestoreApplied).toHaveBeenCalledWith('successor-pty', 2) + } finally { + drive.disposable.dispose() + } + }) + + it('cannot replay or acknowledge an unhide after its pane is disposed', async () => { + const drive = await connectVisiblePane() + vi.useFakeTimers() + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + const first = createDeferred() + getMainBufferSnapshot.mockReturnValueOnce(first.promise) + await announceMainDrop(64, 1) + await announceMainDrop(64, 1, 'unhide') + drive.disposable.dispose() + const writesBeforeDisposal = drive.writtenChunks().slice() + first.resolve(modelSnapshot('DISPOSED_PANE_CAPTURE', 128)) + await flushAsyncTicks(30) + await vi.advanceTimersByTimeAsync(2_100) + await flushAsyncTicks(30) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(drive.writtenChunks()).toEqual(writesBeforeDisposal) + expect(window.api.pty.ackHiddenOutputRestoreApplied).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-hidden-restore-flood.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-hidden-restore-flood.ts new file mode 100644 index 00000000000..28088d1b4ec --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-hidden-restore-flood.ts @@ -0,0 +1,124 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, expect, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockPane, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps, type PaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' +import { connectMockPanePty } from './pty-connection-test-connect-mock-pane' +import type { PtyModelRestoreReason } from '../../../../shared/pty-model-restore-marker' + +// Shared visible-pane driver for the hidden-restore flood/unhide suites (STA-4869). + +let mockStoreState: StoreState +let transportFactoryQueue: MockTransport[] = [] +let createdTransportOptions: Record[] = [] +let storeSubscribers: ((state: StoreState) => void)[] = [] + +export const PTY_ID = 'pty-id' +export const BANNER_FRAGMENT = 'main recovery was unavailable' +// One char over HIDDEN_OUTPUT_RESTORE_PENDING_CHARS (512KB): the queue discards +// its whole backlog and latches overflow. +export const OVERFLOW_CHUNK = 'o'.repeat(512 * 1024 + 1) + +export type MainSnapshot = { + data: string + cols: number + rows: number + seq: number +} + +export function modelSnapshot(marker: string, seq: number): MainSnapshot { + return { data: `${marker}\r\n`, cols: 120, rows: 40, seq } +} + +export type PaneDrive = { + pane: MockPane + transport: MockTransport + deps: PaneConnectionDeps + disposable: { dispose: () => void } + deliver: (data: string, seq: number) => void + setVisible: (visible: boolean) => void + writtenChunks: () => string[] +} + +export function installHiddenRestoreFloodSuiteHooks(): void { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) +} + +export async function connectVisiblePane(): Promise { + const transport = createMockTransport(PTY_ID) + const captured: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks?: ConnectCallbacks }) => { + captured.current = callbacks?.onData ?? null + return PTY_ID + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const deps = buildPaneConnectionDeps(() => mockStoreState, { + isVisibleRef: { current: true } + }) + const disposable = await connectMockPanePty(pane, createManager(1), deps) + await flushAsyncTicks(6) + expect(captured.current).not.toBeNull() + return { + pane, + transport, + deps, + disposable, + deliver: (data, seq) => captured.current?.(data, { seq, rawLength: data.length }), + setVisible: (visible) => { + deps.isVisibleRef.current = visible + }, + writtenChunks: () => pane.terminal.write.mock.calls.map(([data]) => String(data)) + } +} + +export async function announceMainDrop( + markerSeq: number | undefined, + hiddenDropEpisode?: number, + reason: PtyModelRestoreReason = 'hidden-drop', + ptyId = PTY_ID +): Promise { + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: ptyId, + reason, + ...(markerSeq !== undefined ? { markerSeq } : {}), + ...(hiddenDropEpisode !== undefined ? { hiddenDropEpisode } : {}) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts index 417ccd812da..3f0df083da5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts @@ -10,6 +10,7 @@ export type ConnectPanePtySession = { deps: PtyConnectionDeps // Why typed: every PTY write must name its input kind, and the bag's `any` would hide a missing one. transport: PtyTransport + hiddenOutputRestoreUnhideSeq: number | null // oxlint-disable-next-line typescript/no-explicit-any -- session bag for mechanical extract [key: string]: any } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts index db161711ba9..cfd5c078bcf 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts @@ -1,3 +1,4 @@ +import * as unhideCoverage from './hidden-unhide-snapshot-coverage' import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime-pty-id' import { recordTerminalFreezeBreadcrumb } from '../terminal-freeze-breadcrumbs' import { redactPtyIdForDiagnostics } from '../../../../../shared/pty-delivery-diagnostics' @@ -36,8 +37,7 @@ import type { ConnectPanePtySession } from './connect-pane-pty-session' export function bindForegroundOutputRefresh(session: ConnectPanePtySession): void { // Extends the suppression window; every backpressure signal resets the timer so the deferred repaint fires once, SUPPRESS_MS after the last signal. session.noteHiddenOutputRestoreFloodBackpressure = function (): void { - session.hiddenOutputRestoreFloodSuppressedUntil = - Date.now() + HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS + session.extendHiddenOutputRestoreFloodSuppression() const ptyId = session.transport.getPtyId() if (ptyId === null) { return @@ -59,6 +59,7 @@ export function bindForegroundOutputRefresh(session: ConnectPanePtySession): voi return } const episode = typeof event.hiddenDropEpisode === 'number' ? event.hiddenDropEpisode : null + const previousDropEpisode = session.hiddenOutputRestoreLatestDropEpisode // Why: main's latch stays armed until a restore paints, so a gap it can never serve re-marks // on every unhide. A repeat of the disclosed episode is that same gap; any other marker may // be new loss and proceeds, failing toward recovery. @@ -81,7 +82,7 @@ export function bindForegroundOutputRefresh(session: ConnectPanePtySession): voi session.transport.resetCrossChunkParserState?.() // Why gated (rc.7.perf loop): on a visible pane these markers come from our own restore starving ACKs; re-arming per marker kept the fetch loop alive all flood, so defer to one post-flood repaint. if (session.isForegroundRestoreBackpressureContext()) { - session.noteHiddenOutputRestoreFloodBackpressure() + unhideCoverage.handleUnhideRestoreBackpressure(session, event, previousDropEpisode) return } // Why the emulator too: it carries state across chunks exactly like the diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts index 29238650f7d..9915eb103ce 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts @@ -40,6 +40,7 @@ export function bindAbandonHiddenOutputRestore(session: ConnectPanePtySession): const hadPendingOverflow = session.hiddenOutputRestorePendingOverflow const replayingSnapshot = session.hiddenOutputRestoreReplayingSnapshot session.hiddenOutputRestoreReplayingSnapshot = null + session.hiddenOutputRestoreUnhideSeq = null session.hiddenOutputRestoreGeneration += 1 if ( session.hiddenOutputSnapshotScrollRestore?.valid && diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts index 8acca6423f3..198664290c8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts @@ -1,3 +1,4 @@ +import * as unhideCoverage from './hidden-unhide-snapshot-coverage' import { useAppStore } from '@/store' import { isTerminalWritePipelineCertifiedDead } from '@/lib/pane-manager/terminal-write-pipeline-health' import { requestTerminalPaneRecovery } from '../terminal-pane-recovery' @@ -226,9 +227,7 @@ export function bindHiddenOutputRestoreRequest(session: ConnectPanePtySession): restoredSnapshotPaintsPrintableContent(snapshot) ) session.ackHiddenOutputRestoreApplied(currentPtyId, coveredDropEpisode) - session.hiddenOutputRestoreReplayingSnapshot = null - const needsFreshSnapshot = session.hiddenOutputRestoreFreshSnapshotNeeded - session.hiddenOutputRestoreFreshSnapshotNeeded = false + const needsFreshSnapshot = unhideCoverage.consumeSnapshotRequirement(session, snapshot.seq) const drainOutcome = session.drainPendingLiveChunksAfterSnapshot(snapshot.seq) if (drainOutcome === 'drained' && !needsFreshSnapshot) { session.hiddenOutputRestoreNeeded = false @@ -267,6 +266,7 @@ export function bindHiddenOutputRestoreRequest(session: ConnectPanePtySession): const hiddenOutputRestoreTask = session.hiddenOutputRestoreInFlight let trackedHiddenOutputRestore: Promise trackedHiddenOutputRestore = hiddenOutputRestoreTask.finally(() => { + unhideCoverage.repairUnhandledMarker(session, trackedHiddenOutputRestore, ptyId) if (session.hiddenOutputRestoreInFlight === trackedHiddenOutputRestore) { session.hiddenOutputRestoreInFlight = null } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-snapshot-serialize.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-snapshot-serialize.ts index c14c69c9a05..39645a47e79 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-snapshot-serialize.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-snapshot-serialize.ts @@ -3,6 +3,7 @@ import { isHostAnsweredSnapshotRetryCause } from '@/runtime/remote-runtime-termi import { onTerminalScrollIntentFollowOutput } from '@/lib/pane-manager/terminal-scroll-intent' import { shouldWritePtyOutputForeground } from './foreground-output-scan' +import { HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS } from './hidden-output-restore-limits' import { readE2eHiddenSnapshotOverride } from './e2e-terminal-pty-harness' import type { PtyBufferSnapshot } from '../pty-transport-types' @@ -152,6 +153,11 @@ export function bindSerializeHiddenOutputSnapshot(session: ConnectPanePtySession } } + session.extendHiddenOutputRestoreFloodSuppression = function (): void { + session.hiddenOutputRestoreFloodSuppressedUntil = + Date.now() + HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS + } + session.resetHiddenOutputRestoreFloodSuppression = function (): void { session.hiddenOutputRestoreFloodSuppressedUntil = 0 session.clearHiddenOutputRestoreFloodRepaintTimer() diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-restore-state.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-restore-state.ts index 3d4246b06f2..e2c3f2c5829 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-restore-state.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-restore-state.ts @@ -61,6 +61,7 @@ export function bindHiddenRestoreState(session: ConnectPanePtySession): void { session.hiddenOutputRestoreNeeded = false session.hiddenOutputRestorePtyId = null session.hiddenOutputRestoreReplayingSnapshot = null + session.hiddenOutputRestoreUnhideSeq = null session.hiddenOutputRestoreGeneration += 1 } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-unhide-snapshot-coverage.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-unhide-snapshot-coverage.ts new file mode 100644 index 00000000000..2a420c212fc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-unhide-snapshot-coverage.ts @@ -0,0 +1,75 @@ +import type { PtyModelRestoreNeededEvent } from '../../../../../shared/pty-model-restore-marker' +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function handleUnhideRestoreBackpressure( + session: ConnectPanePtySession, + event: PtyModelRestoreNeededEvent, + previousDropEpisode: number | null +): void { + if ( + event.reason !== 'unhide' || + session.hiddenOutputRestoreInFlight === null || + // An in-flight restore for another PTY can't cover this marker; the quiet repaint is its only recovery. + session.hiddenOutputRestorePtyId !== event.id || + typeof event.markerSeq !== 'number' || + !Number.isFinite(event.markerSeq) + ) { + if (session.hiddenOutputRestorePtyId === event.id) { + session.hiddenOutputRestoreUnhideSeq = null + } + session.noteHiddenOutputRestoreFloodBackpressure() + return + } + if ( + previousDropEpisode === null || + !Number.isFinite(previousDropEpisode) || + previousDropEpisode !== event.hiddenDropEpisode || + session.hiddenOutputRestoreFloodRepaintTimer !== null || + session.cancelHiddenOutputRestoreFloodRepaintPark !== null + ) { + // Leave recovery to the quiet timer when this snapshot cannot retire the episode. + session.hiddenOutputRestoreUnhideSeq = null + session.noteHiddenOutputRestoreFloodBackpressure() + return + } + // A current or successor restore must cover the retained marker before finishing. + session.hiddenOutputRestoreUnhideSeq = Math.max( + session.hiddenOutputRestoreUnhideSeq ?? 0, + event.markerSeq + ) + // Still backpressure: keep later markers gated, but the covering restore replaces the quiet repaint. + session.extendHiddenOutputRestoreFloodSuppression() +} + +export function consumeSnapshotRequirement( + session: ConnectPanePtySession, + snapshotSeq: number | undefined +): boolean { + const unhideSeq = session.hiddenOutputRestoreUnhideSeq + const freshSnapshotNeeded = session.hiddenOutputRestoreFreshSnapshotNeeded + const uncoveredUnhide = + typeof unhideSeq === 'number' && + (typeof snapshotSeq !== 'number' || !Number.isFinite(snapshotSeq) || snapshotSeq < unhideSeq) + session.hiddenOutputRestoreReplayingSnapshot = null + session.hiddenOutputRestoreUnhideSeq = uncoveredUnhide ? unhideSeq : null + session.hiddenOutputRestoreFreshSnapshotNeeded = false + return freshSnapshotNeeded || uncoveredUnhide +} + +export function repairUnhandledMarker( + session: ConnectPanePtySession, + task: Promise, + ptyId: string +): void { + if ( + session.hiddenOutputRestoreInFlight === task && + !session.disposed && + session.transport.getPtyId() === ptyId && + typeof session.hiddenOutputRestoreUnhideSeq === 'number' && + !session.hiddenOutputRestoreNeeded && + !session.hiddenOutputRestoreRetryDeferred + ) { + session.hiddenOutputRestoreUnhideSeq = null + session.noteHiddenOutputRestoreFloodBackpressure() + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts b/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts index 3df0803e532..a217b2b66c4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts @@ -137,6 +137,7 @@ export function bindCaptureTransportOutputCallbacks(session: ConnectPanePtySessi // Latest hidden-drop episode main reported, and the one a banner already disclosed: // main's drop latch outlives unhide, so an unservable gap re-marks on every reveal. session.hiddenOutputRestoreLatestDropEpisode = null + session.hiddenOutputRestoreUnhideSeq = null session.hiddenOutputRestoreDisclosedGap = null session.hiddenOutputSnapshotScrollRestore = null // Why: hidden recovery state belongs to one PTY stream. Reattach/restart