From e8aa4ef2439d8340eb9671d6e38892335fc1c4fb Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 19 Aug 2026 17:59:07 -0400 Subject: [PATCH] test(terminal): pin the hidden-output restore loss invariant (STA-4869) Three suites cover every route where a hidden pane's withheld bytes can end up neither reconciled nor disclosed. Each traces a marker that exists only in the authority buffer and asserts it is written exactly once OR the loss banner appears. Three of these fail at this commit, deliberately: the loss-disclosure suite's two [invariant] cases (host retry budget and permanently-unavailable answers landing while the pane is hidden) and the marker-handoff [invariant] case (a retiring hidden pane spending main's drop latch on a marker no handler receives). The fix lands in the next commit; keeping them red here proves the suites are not vacuous. The remaining routes (flood-abandon overflow, iteration cap, local deferred retry) are green controls: they are unreachable while hidden and self-heal. Refs STA-4869 --- ...ction-hidden-restore-flood-abandon.test.ts | 351 +++++++++++++ ...ion-hidden-restore-loss-disclosure.test.ts | 471 ++++++++++++++++++ ...tion-hidden-restore-marker-handoff.test.ts | 340 +++++++++++++ 3 files changed, 1162 insertions(+) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-flood-abandon.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-loss-disclosure.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-marker-handoff.test.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 new file mode 100644 index 00000000000..f4c06581c85 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-flood-abandon.test.ts @@ -0,0 +1,351 @@ +import type * as React from 'react' +import { afterEach, beforeEach, 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' + +// 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 +// banner for one deferred post-flood repaint. Same invariant as the non-quiet +// routes: every retained byte is reconciled exactly once, or the loss is +// 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(), + warnTerminalLifecycleAnomaly: vi.fn() +})) + +// The iteration-cap warning is the only externally visible proof that R4's +// backstop fired rather than the loop finishing on its own. +vi.mock('./terminal-lifecycle-diagnostics', async (importOriginal) => ({ + ...(await 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 { connectPanePty } = await import('./pty-connection') + 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 = connectPanePty(pane as never, createManager(1) as never, deps as never) + 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 as { current: boolean }).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 +} + +type Disclosure = { + bannerWrites: number + markerWrites: number + disclosedOrRecovered: boolean +} + +function observeDisclosure(drive: PaneDrive, marker: string): Disclosure { + const written = drive.writtenChunks() + const bannerWrites = written.filter((data) => data.includes(BANNER_FRAGMENT)).length + const markerWrites = countOccurrences(written.join(''), marker) + return { + bannerWrites, + markerWrites, + disclosedOrRecovered: bannerWrites > 0 || markerWrites === 1 + } +} + +function expectDisclosedOrRecovered(disclosure: Disclosure): void { + expect( + disclosure.disclosedOrRecovered, + `hidden output was neither reconciled nor disclosed: ${JSON.stringify(disclosure)}` + ).toBe(true) +} + +describe('hidden-output restore quiet abandonment (STA-4869)', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + // ── R3: foreground pending-queue overflow ─────────────────────────────── + describe('R3 pending-queue overflow', () => { + /** Drives one restore into a 512KB queue overflow and returns once the + * quiet abandon has run. */ + async function overflowRestoreQueue(drive: PaneDrive): Promise { + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + const firstSnapshot = createDeferred() + getMainBufferSnapshot.mockReturnValueOnce(firstSnapshot.promise as never) + await announceMainDrop(64) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + // The live stream outruns fetch+replay and blows the queue cap. + drive.deliver(OVERFLOW_CHUNK, 64 + OVERFLOW_CHUNK.length) + drive.deliver(OVERFLOW_CHUNK, 64 + 2 * OVERFLOW_CHUNK.length) + // Resolve inside the 750ms foreground deadline so the drain, not the + // deadline timer, owns the abandonment. + firstSnapshot.resolve(modelSnapshot('pre-overflow-model', 64)) + await flushAsyncTicks(20) + + // Proof the overflow arm ran: the snapshot replayed, and the queued + // backlog was discarded rather than drained under it. + const written = drive.writtenChunks().join('') + expect(written).toContain('pre-overflow-model') + expect(written).not.toContain('o'.repeat(1024)) + } + + it('[invariant] reconciles the retained bytes through the deferred post-flood repaint', async () => { + const drive = await connectVisiblePane() + vi.useFakeTimers() + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + await overflowRestoreQueue(drive) + + expect(drive.writtenChunks().join('')).not.toContain(BANNER_FRAGMENT) + getMainBufferSnapshot.mockResolvedValue(modelSnapshot(R3_MARKER, 4096) as never) + await vi.advanceTimersByTimeAsync(2_100) + await flushAsyncTicks(20) + + const disclosure = observeDisclosure(drive, R3_MARKER) + expect(disclosure.markerWrites).toBeLessThanOrEqual(1) + expectDisclosedOrRecovered(disclosure) + drive.disposable.dispose() + }) + + it('[invariant] keeps recovery armed while the user reads scrollback', async () => { + const { markTerminalFollowOutput, markTerminalPinnedViewport } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const drive = await connectVisiblePane() + drive.pane.terminal.buffer.active.baseY = 100 + drive.pane.terminal.buffer.active.viewportY = 42 + markTerminalPinnedViewport(drive.pane.terminal as never) + vi.useFakeTimers() + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + await overflowRestoreQueue(drive) + + getMainBufferSnapshot.mockResolvedValue(modelSnapshot(R4_MARKER, 4096) as never) + await vi.advanceTimersByTimeAsync(60_000) + await flushAsyncTicks(20) + // Parked, not abandoned: the repaint must not yank the viewport, and must + // not claim a loss it can still heal. + expect(drive.writtenChunks().join('')).not.toContain(BANNER_FRAGMENT) + expect(drive.pane.terminal.buffer.active.viewportY).toBe(42) + + // A background stint must not consume the parked recovery either. + drive.setVisible(false) + await vi.advanceTimersByTimeAsync(60_000) + drive.setVisible(true) + await flushAsyncTicks(20) + + drive.pane.terminal.buffer.active.viewportY = drive.pane.terminal.buffer.active.baseY + markTerminalFollowOutput(drive.pane.terminal as never) + await vi.advanceTimersByTimeAsync(2_100) + await flushAsyncTicks(20) + + const disclosure = observeDisclosure(drive, R4_MARKER) + expect(disclosure.markerWrites).toBeLessThanOrEqual(1) + expectDisclosedOrRecovered(disclosure) + drive.disposable.dispose() + }) + }) + + // ── R4: restore loop iteration cap ────────────────────────────────────── + describe('R4 loop iteration cap', () => { + it('[invariant] reconciles the retained bytes after the iteration backstop fires', async () => { + const drive = await connectVisiblePane() + vi.useFakeTimers() + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + const rounds = [0, 1, 2].map(() => createDeferred()) + for (const round of rounds) { + getMainBufferSnapshot.mockReturnValueOnce(round.promise as never) + } + await announceMainDrop(64) + await flushAsyncTicks(20) + + // Each round: the pane goes background long enough for one chunk to arrive + // with a restore in flight, which marks the in-flight snapshot stale and + // sends the loop around again. + for (const [index, round] of rounds.entries()) { + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(index + 1) + drive.setVisible(false) + drive.deliver(`stale-round-${index}\r\n`, 128 + index * 32) + drive.setVisible(true) + round.resolve(modelSnapshot(`round-${index}-model`, 64 + index)) + await flushAsyncTicks(20) + } + + // The backstop cut the loop without a banner; the deferred repaint owes + // the pane its bytes. + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(3) + expect(warnTerminalLifecycleAnomaly).toHaveBeenCalledWith( + 'hidden output restore hit its iteration cap', + expect.objectContaining({ ptyId: PTY_ID }) + ) + expect(drive.writtenChunks().join('')).not.toContain(BANNER_FRAGMENT) + getMainBufferSnapshot.mockResolvedValue(modelSnapshot(R4_MARKER, 8192) as never) + await vi.advanceTimersByTimeAsync(2_100) + await flushAsyncTicks(20) + + const disclosure = observeDisclosure(drive, R4_MARKER) + expect(disclosure.markerWrites).toBeLessThanOrEqual(1) + expectDisclosedOrRecovered(disclosure) + drive.disposable.dispose() + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-loss-disclosure.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-loss-disclosure.test.ts new file mode 100644 index 00000000000..9ec708d1f5b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-loss-disclosure.test.ts @@ -0,0 +1,471 @@ +import type * as React from 'react' +import { afterEach, beforeEach, 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' + +// STA-4869 — routes R1 (host retry budget), R2 (permanently-unavailable) and R5 +// (local deferred-retry exhaustion) all end in the NON-QUIET abandon at +// pty-connection.ts:7106, whose only loss disclosure is +// writeRestoreUnavailableWarning (7302). That function early-returns the banner +// when the pane is not foreground, and the abandon has already zeroed +// hiddenOutputRestoreNeeded/PtyId and cleared the flood repaint timer. +// +// Invariant asserted here: when renderer delivery was withheld while hidden, +// reveal must either reconcile every retained byte exactly once or leave a +// durable loss banner on the pane. Never neither. + +const { + resetAndRefreshAllTerminalWebglAtlases, + scheduleTerminalWebglAtlasRecovery, + scheduleRuntimeGraphSync, + shouldSeedCacheTimerOnInitialTitle, + toastInfo, + notifyCodexPaneBoundForStaleSweep +} = vi.hoisted(() => ({ + resetAndRefreshAllTerminalWebglAtlases: vi.fn(), + scheduleTerminalWebglAtlasRecovery: vi.fn(), + scheduleRuntimeGraphSync: vi.fn(), + shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), + toastInfo: vi.fn(), + notifyCodexPaneBoundForStaleSweep: vi.fn() +})) + +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 REMOTE_PTY_ID = 'remote:env-1@@terminal-sta4869' +const LOCAL_PTY_ID = 'pty-id' +// Overruns the renderer's 2MB hidden background queue: the bytes exist only in +// the authority's buffer afterwards, which is what reveal has to reconcile. +const HIDDEN_FLOOD = 'x'.repeat(2 * 1024 * 1024 + 1) +const LIVE_CHUNK = 'live-after-reveal\r\n' +const BANNER_FRAGMENT = 'main recovery was unavailable' +const R1_MARKER = 'R1-LOST-7f3a' +const R2_MARKER = 'R2-LOST-91c4' +const R5_MARKER = 'R5-LOST-b28e' + +type RetainedSnapshot = { + data: string + cols: number + rows: number + seq: number +} + +/** The authority's retained content — the only carrier of the dropped bytes. */ +function retainedSnapshot(marker: string): RetainedSnapshot { + return { + data: `${marker}\r\n`, + cols: 120, + rows: 40, + seq: HIDDEN_FLOOD.length + LIVE_CHUNK.length + } +} + +function hostAnswers(snapshot: RetainedSnapshot): unknown { + return { availability: { kind: 'snapshot' }, snapshot } +} + +type PaneDrive = { + pane: MockPane + deps: PaneConnectionDeps + disposable: { dispose: () => void } + deliver: (data: string, seq: number) => void + setVisible: (visible: boolean) => void + writtenChunks: () => string[] +} + +async function connectHiddenPane( + ptyId: string, + configureTransport: (transport: MockTransport) => void +): Promise { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport(ptyId) + configureTransport(transport) + 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 ptyId + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const deps = buildPaneConnectionDeps(() => mockStoreState, { + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + 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 as { current: boolean }).current = visible + }, + writtenChunks: () => pane.terminal.write.mock.calls.map(([data]) => String(data)) + } +} + +/** Hidden-time bytes are dropped, then the pane is revealed with live output + * still streaming — the reveal path the gate's recovery loop owns. */ +function floodWhileHiddenThenReveal(drive: PaneDrive): void { + drive.deliver(HIDDEN_FLOOD, HIDDEN_FLOOD.length) + drive.setVisible(true) + drive.deliver(LIVE_CHUNK, HIDDEN_FLOOD.length + LIVE_CHUNK.length) +} + +async function revealPane(drive: PaneDrive): Promise { + drive.setVisible(true) + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(drive.pane.terminal as never) + await flushAsyncTicks(20) +} + +function countOccurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1 +} + +type Disclosure = { + bannerWrites: number + markerWrites: number + disclosedOrRecovered: boolean +} + +function observeDisclosure(drive: PaneDrive, marker: string): Disclosure { + const written = drive.writtenChunks() + const bannerWrites = written.filter((data) => data.includes(BANNER_FRAGMENT)).length + const markerWrites = countOccurrences(written.join(''), marker) + return { + bannerWrites, + markerWrites, + disclosedOrRecovered: bannerWrites > 0 || markerWrites === 1 + } +} + +function expectDisclosedOrRecovered(disclosure: Disclosure): void { + expect( + disclosure.disclosedOrRecovered, + `hidden output was neither reconciled nor disclosed: ${JSON.stringify(disclosure)}` + ).toBe(true) +} + +/** Long enough for every deferred repaint, re-arm cycle and retry tick. */ +async function allowRecoveryWindow(): Promise { + await vi.advanceTimersByTimeAsync(60_000) + await flushAsyncTicks(20) +} + +describe('hidden-output restore loss disclosure (STA-4869)', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + // ── R1: host retry-worthy budget (7 answers) ──────────────────────────── + describe('R1 host retry budget exhaustion', () => { + const retryWorthy = { + availability: { kind: 'retry-worthy', cause: 'host-pending-output-overflowed' }, + snapshot: null + } + + async function driveToSeventhAnswer( + serializeBufferOutcome: ReturnType + ): Promise { + const drive = await connectHiddenPane(REMOTE_PTY_ID, (transport) => { + transport.serializeBuffer = vi.fn() + transport.serializeBufferOutcome = serializeBufferOutcome + }) + vi.useFakeTimers() + floodWhileHiddenThenReveal(drive) + await flushAsyncTicks(20) + expect(serializeBufferOutcome).toHaveBeenCalledTimes(1) + // Each retry-worthy answer quietly re-arms one post-suppression repaint. + for (let request = 2; request <= 6; request += 1) { + await vi.advanceTimersByTimeAsync(2_000) + await flushAsyncTicks(20) + expect(serializeBufferOutcome).toHaveBeenCalledTimes(request) + } + expect(drive.writtenChunks().join('')).not.toContain(BANNER_FRAGMENT) + return drive + } + + it('[control] discloses the loss when the budget ends while the pane is visible', async () => { + const serializeBufferOutcome = vi.fn().mockResolvedValue(retryWorthy) + const drive = await driveToSeventhAnswer(serializeBufferOutcome) + + await vi.advanceTimersByTimeAsync(2_000) + await flushAsyncTicks(20) + serializeBufferOutcome.mockResolvedValue(hostAnswers(retainedSnapshot(R1_MARKER))) + await allowRecoveryWindow() + + expect(observeDisclosure(drive, R1_MARKER)).toMatchObject({ disclosedOrRecovered: true }) + drive.disposable.dispose() + }) + + it('[invariant] discloses or recovers when the budget ends while the pane is hidden', async () => { + const seventhAnswer = createDeferred() + const serializeBufferOutcome = vi.fn().mockResolvedValue(retryWorthy) + const drive = await driveToSeventhAnswer(serializeBufferOutcome) + + serializeBufferOutcome.mockReturnValueOnce(seventhAnswer.promise) + await vi.advanceTimersByTimeAsync(2_000) + await flushAsyncTicks(20) + expect(serializeBufferOutcome).toHaveBeenCalledTimes(7) + + // The user switches tabs while the seventh request is still outstanding; + // the restore loop keeps running across the await. + drive.setVisible(false) + seventhAnswer.resolve(retryWorthy) + await flushAsyncTicks(20) + + // The host is healthy again and still holds the dropped bytes. + serializeBufferOutcome.mockResolvedValue(hostAnswers(retainedSnapshot(R1_MARKER))) + const requestsBeforeReveal = serializeBufferOutcome.mock.calls.length + await revealPane(drive) + await allowRecoveryWindow() + + const disclosure = observeDisclosure(drive, R1_MARKER) + expect(disclosure.markerWrites).toBeLessThanOrEqual(1) + expectDisclosedOrRecovered(disclosure) + // Whichever way the gap is closed, silence is not one of them: recovering + // it costs a fresh request, disclosing it costs a banner. + expect( + serializeBufferOutcome.mock.calls.length - requestsBeforeReveal + ).toBeGreaterThanOrEqual(disclosure.bannerWrites > 0 ? 0 : 1) + drive.disposable.dispose() + }) + }) + + // ── R2: permanently-unavailable ───────────────────────────────────────── + describe('R2 permanently-unavailable answer', () => { + const permanentlyUnavailable = { + availability: { kind: 'permanently-unavailable', reason: 'exceeds-client-replay-limit' }, + snapshot: null + } + + it('[control] banners once when the answer lands while the pane is visible', async () => { + const serializeBufferOutcome = vi.fn().mockResolvedValue(permanentlyUnavailable) + const drive = await connectHiddenPane(REMOTE_PTY_ID, (transport) => { + transport.serializeBuffer = vi.fn() + transport.serializeBufferOutcome = serializeBufferOutcome + }) + vi.useFakeTimers() + floodWhileHiddenThenReveal(drive) + await flushAsyncTicks(20) + await allowRecoveryWindow() + + expect(observeDisclosure(drive, R2_MARKER)).toMatchObject({ + bannerWrites: 1, + disclosedOrRecovered: true + }) + drive.disposable.dispose() + }) + + it('[invariant] discloses or recovers when the answer lands while the pane is hidden', async () => { + const answer = createDeferred() + const serializeBufferOutcome = vi.fn().mockReturnValue(answer.promise) + const drive = await connectHiddenPane(REMOTE_PTY_ID, (transport) => { + transport.serializeBuffer = vi.fn() + transport.serializeBufferOutcome = serializeBufferOutcome + }) + vi.useFakeTimers() + floodWhileHiddenThenReveal(drive) + await flushAsyncTicks(20) + expect(serializeBufferOutcome).toHaveBeenCalledTimes(1) + + drive.setVisible(false) + answer.resolve(permanentlyUnavailable) + await flushAsyncTicks(20) + + serializeBufferOutcome.mockResolvedValue(hostAnswers(retainedSnapshot(R2_MARKER))) + await revealPane(drive) + await allowRecoveryWindow() + + const disclosure = observeDisclosure(drive, R2_MARKER) + expect(disclosure.markerWrites).toBeLessThanOrEqual(1) + expectDisclosedOrRecovered(disclosure) + drive.disposable.dispose() + }) + }) + + // ── R5: local snapshot unavailable, deferred retries exhausted ────────── + describe('R5 local deferred-retry exhaustion', () => { + async function connectLocalHiddenPane(): Promise { + // A codex startup makes the renderer skip hidden bytes locally, which is + // the local mirror of the remote pause the other routes model. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport(LOCAL_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 LOCAL_PTY_ID + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const deps = buildPaneConnectionDeps(() => mockStoreState, { + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + 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 as { current: boolean }).current = visible + }, + writtenChunks: () => pane.terminal.write.mock.calls.map(([data]) => String(data)) + } + } + + it('[control] banners once when the retries run out while the pane is visible', async () => { + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + getMainBufferSnapshot.mockResolvedValue(null) + const drive = await connectLocalHiddenPane() + vi.useFakeTimers() + floodWhileHiddenThenReveal(drive) + await flushAsyncTicks(20) + + for (let attempt = 0; attempt < 3; attempt += 1) { + await vi.advanceTimersByTimeAsync(50) + await flushAsyncTicks(20) + } + + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(4) + expect(observeDisclosure(drive, R5_MARKER)).toMatchObject({ + bannerWrites: 1, + disclosedOrRecovered: true + }) + drive.disposable.dispose() + }) + + it('[invariant] keeps recovery armed when the pane hides before the retries run out', async () => { + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + getMainBufferSnapshot.mockResolvedValue(null) + const drive = await connectLocalHiddenPane() + vi.useFakeTimers() + floodWhileHiddenThenReveal(drive) + await flushAsyncTicks(20) + + // The pane goes background one retry into the budget. + await vi.advanceTimersByTimeAsync(50) + await flushAsyncTicks(20) + drive.setVisible(false) + await allowRecoveryWindow() + + // Main still holds the dropped bytes; reveal must spend them. + getMainBufferSnapshot.mockResolvedValue(retainedSnapshot(R5_MARKER)) + await revealPane(drive) + await allowRecoveryWindow() + + const disclosure = observeDisclosure(drive, R5_MARKER) + expect(disclosure.markerWrites).toBeLessThanOrEqual(1) + expectDisclosedOrRecovered(disclosure) + drive.disposable.dispose() + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-marker-handoff.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-marker-handoff.test.ts new file mode 100644 index 00000000000..a8b0ef0a1d0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-marker-handoff.test.ts @@ -0,0 +1,340 @@ +import type * as React from 'react' +import { afterEach, beforeEach, describe, expect, it, 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' + +// STA-4869 route R6 — the drop latch (pty-hidden-delivery-gate.ts:29) is +// single-use: unmarkHiddenRendererPty consumes it and main re-emits one +// 'unhide' marker (pty.ts:7675). The renderer channel has no latch of its own — +// dispatchPtyModelRestoreNeeded (pty-model-restore-channel.ts:18) drops a marker +// for which no handler is registered. A pane that releases the last hidden claim +// while retiring therefore spends the latch on a marker nobody can receive. +// +// This suite drives the REAL renderer channel from behind the +// setHiddenRendererPty IPC, delivering main's marker one turn later the way +// Electron does. Main's gate is modelled by mainDropLatch below rather than +// imported — src/main is outside the renderer tsconfig project — and its two +// rules are pinned against the real module in +// src/main/ipc/pty-hidden-delivery-gate.test.ts. + +const { + resetAndRefreshAllTerminalWebglAtlases, + scheduleTerminalWebglAtlasRecovery, + scheduleRuntimeGraphSync, + shouldSeedCacheTimerOnInitialTitle, + toastInfo, + notifyCodexPaneBoundForStaleSweep +} = vi.hoisted(() => ({ + resetAndRefreshAllTerminalWebglAtlases: vi.fn(), + scheduleTerminalWebglAtlasRecovery: vi.fn(), + scheduleRuntimeGraphSync: vi.fn(), + shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), + toastInfo: vi.fn(), + notifyCodexPaneBoundForStaleSweep: vi.fn() +})) + +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 R6_MARKER = 'R6-LOST-c5e2' +const HIDDEN_CHUNK = 'hidden-agent-output\r\n' + +type PaneDrive = { + pane: MockPane + deps: PaneConnectionDeps + binding: { syncProcessTracking: () => void; dispose: () => void } + deliver: (data: string, seq: number) => void + setVisible: (visible: boolean) => void + writtenChunks: () => string[] +} + +/** Main's droppedSinceHiddenPtys latch (pty-hidden-delivery-gate.ts:29): the + * first gated drop arms it, only unmark consumes it, and re-marking hidden + * preserves it. */ +const mainDropLatch = { + hidden: false, + dropped: false, + markHidden(): void { + this.hidden = true + }, + /** @returns whether main owes the renderer a restore marker. */ + recordDrop(): boolean { + if (this.dropped) { + return false + } + this.dropped = true + return true + }, + /** @returns droppedWhileHidden — the latch, consumed. */ + unmarkHidden(): boolean { + this.hidden = false + const dropped = this.dropped + this.dropped = false + return dropped + }, + reset(): void { + this.hidden = false + this.dropped = false + } +} + +/** Routes the renderer's hidden-state IPC into main's real gate and delivers + * main's marker back one turn later, as the IPC round trip does. */ +async function wireMainGateToRendererChannel(): Promise<{ markersDelivered: () => number }> { + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + let markersDelivered = 0 + vi.mocked(window.api.pty.setHiddenRendererPty).mockImplementation( + (id: string, hidden: boolean) => { + if (hidden) { + mainDropLatch.markHidden() + return + } + if (!mainDropLatch.unmarkHidden()) { + return + } + markersDelivered += 1 + queueMicrotask(() => { + _dispatchPtyModelRestoreNeededForTest({ id, reason: 'unhide', markerSeq: 4096 }) + }) + } + ) + return { markersDelivered: () => markersDelivered } +} + +/** Main ingests the chunk into its model and drops the renderer copy. */ +async function dropHiddenBytesInMain(): Promise { + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + expect(mainDropLatch.hidden).toBe(true) + if (mainDropLatch.recordDrop()) { + _dispatchPtyModelRestoreNeededForTest({ id: PTY_ID, reason: 'hidden-drop', markerSeq: 2048 }) + } + await flushAsyncTicks(6) +} + +async function connectPane(visible: boolean): Promise { + const { connectPanePty } = await import('./pty-connection') + 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: visible } + }) + const binding = connectPanePty(pane as never, createManager(1) as never, deps as never) as { + syncProcessTracking: () => void + dispose: () => void + } + await flushAsyncTicks(6) + expect(captured.current).not.toBeNull() + return { + pane, + deps, + binding, + deliver: (data, seq) => captured.current?.(data, { seq, rawLength: data.length }), + setVisible: (nextVisible) => { + ;(deps.isVisibleRef as { current: boolean }).current = nextVisible + }, + writtenChunks: () => pane.terminal.write.mock.calls.map(([data]) => String(data)) + } +} + +function observeDisclosure(drive: PaneDrive): { + bannerWrites: number + markerWrites: number + disclosedOrRecovered: boolean +} { + const written = drive.writtenChunks() + const bannerWrites = written.filter((data) => data.includes(BANNER_FRAGMENT)).length + const markerWrites = written.join('').split(R6_MARKER).length - 1 + return { + bannerWrites, + markerWrites, + disclosedOrRecovered: bannerWrites > 0 || markerWrites === 1 + } +} + +describe('hidden-output restore marker handoff (STA-4869 R6)', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mainDropLatch.reset() + mockStoreState = createInitialStoreState(() => mockStoreState) + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } as StoreState['settings'] + installTerminalTestGlobals() + vi.mocked(window.api.pty.getMainBufferSnapshot).mockResolvedValue({ + data: `${R6_MARKER}\r\n`, + cols: 120, + rows: 40, + seq: 4096 + } as never) + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + const { _resetPtyRendererDeliveryClaimsForTest } = + await import('./pty-renderer-delivery-claims') + _resetPtyRendererDeliveryClaimsForTest() + }) + + it('[mechanism] the renderer channel drops a marker with no registered handler and never replays it', async () => { + const { registerPtyModelRestoreNeededHandler, _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + const retiringHandler = vi.fn() + const unregister = registerPtyModelRestoreNeededHandler(PTY_ID, retiringHandler) + unregister() + + _dispatchPtyModelRestoreNeededForTest({ id: PTY_ID, reason: 'unhide', markerSeq: 4096 }) + expect(retiringHandler).not.toHaveBeenCalled() + + // The replacement view registers a moment later: nothing is held for it. + const replacementHandler = vi.fn() + registerPtyModelRestoreNeededHandler(PTY_ID, replacementHandler) + await flushAsyncTicks(6) + expect(replacementHandler).not.toHaveBeenCalled() + }) + + it('[control] a revealed pane consumes the unhide marker and repaints from the model', async () => { + const gate = await wireMainGateToRendererChannel() + const drive = await connectPane(false) + drive.deliver(HIDDEN_CHUNK, HIDDEN_CHUNK.length) + await dropHiddenBytesInMain() + + drive.setVisible(true) + drive.binding.syncProcessTracking() + await flushAsyncTicks(20) + + expect(gate.markersDelivered()).toBe(1) + expect(observeDisclosure(drive)).toMatchObject({ markerWrites: 1 }) + drive.binding.dispose() + }) + + it('[invariant] a hidden pane that retires must not spend the drop latch on a marker nobody receives', async () => { + const gate = await wireMainGateToRendererChannel() + const retiring = await connectPane(false) + retiring.deliver(HIDDEN_CHUNK, HIDDEN_CHUNK.length) + await dropHiddenBytesInMain() + expect(vi.mocked(window.api.pty.setHiddenRendererPty)).toHaveBeenCalledWith(PTY_ID, true) + + // The pane retires (tab remount / recovery): releasing the last hidden claim + // unhides the PTY in main, which answers with the one restore marker. + retiring.binding.dispose() + await flushAsyncTicks(20) + expect(gate.markersDelivered()).toBe(1) + + // The replacement pane binds the same PTY while visible. + const replacement = await connectPane(true) + replacement.binding.syncProcessTracking() + await flushAsyncTicks(20) + + // Main still holds the dropped bytes and answers any snapshot request with + // them, so the replacement pane owes the user either those bytes or a banner. + const disclosure = { + ...observeDisclosure(replacement), + modelSnapshotRequests: vi.mocked(window.api.pty.getMainBufferSnapshot).mock.calls.length + } + expect( + disclosure.disclosedOrRecovered, + `hidden output was neither reconciled nor disclosed after remount: ${JSON.stringify(disclosure)}` + ).toBe(true) + replacement.binding.dispose() + }) +})