diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 448d4a26a33..601788267b2 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -17071,16 +17071,29 @@ "oracle": "Apply snapshots onto adversarially dirty pane states (already on the alternate screen, stale content occupying cells the new frame leaves blank, scrollback present, wide glyphs, revived sessions with restarted PTY seq counters) and assert exact buffer equality with the snapshot frame; fault injection interleaves hidden chunks, live output, metadata-only replay, and clear-before-replay, then asserts ordered terminal buffer content, clear decisions, and replay diagnostics. Marker-presence checks are not acceptable evidence on restore paths.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-fit-overflow.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts" ], "testFiles": [ "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts", "src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts", "src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-fit-overflow.test.ts", "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts", "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts" ], "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-fit-overflow.test.ts", + "assertions": [ + "recovers small live output when the deadline interrupts an answered snapshot", + "recovers partially covered live output when the deadline interrupts an answered snapshot", + "recovers unmapped live output when the deadline interrupts an answered snapshot", + "recovers unhide gap live output when the deadline interrupts an answered snapshot", + "recovers fresh snapshot gap live output when the deadline interrupts an answered snapshot", + "recovers overflowed live output when the deadline interrupts an answered snapshot" + ] + }, { "file": "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts", "assertions": [ diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-fit-overflow.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-fit-overflow.test.ts index b6191338df1..2878dc69ce3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-fit-overflow.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-fit-overflow.test.ts @@ -148,52 +148,112 @@ describe('connectPanePty', () => { await restoreTerminalTestGlobals() }) - it('repaints overflowed live output when the deadline interrupts an answered snapshot', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - let onData: ConnectCallbacks['onData'] - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - onData = callbacks.onData - return 'pty-id' - }) - transportFactoryQueue.push(transport) - const fit = createDeferred() - safeFitAndThen.mockReturnValue({ completion: Promise.resolve(true), cancel: vi.fn() }) + it.each([ + { name: 'overflowed', pending: 'v'.repeat(512 * 1024 + 1), repaint: true, covered: 0 }, + { name: 'small', pending: 'Working\r\n', repaint: false, covered: 0 }, + { name: 'partially covered', pending: 'Working\r\n', repaint: false, covered: 4 }, + { name: 'unmapped', pending: 'Working\r\n', repaint: true, covered: 4 }, + { name: 'unhide gap', pending: 'Working\r\n', repaint: true, covered: 0 }, + { name: 'fresh snapshot gap', pending: 'Working\r\n', repaint: true, covered: 0 } + ])( + 'recovers $name live output when the deadline interrupts an answered snapshot', + async ({ name, pending, repaint, covered }) => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + let onData: ConnectCallbacks['onData'] + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + onData = callbacks.onData + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + const fit = createDeferred() + safeFitAndThen.mockReturnValue({ completion: Promise.resolve(true), cancel: vi.fn() }) - const getSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) - const hidden = 'hidden-before-flood\r\n' - const overflow = 'v'.repeat(512 * 1024 + 1) - const done = 'HIDDEN_FLOOD_DONE\r\n' - getSnapshot - .mockResolvedValueOnce({ data: hidden, cols: 120, rows: 40, seq: hidden.length }) - .mockResolvedValue({ data: done, cols: 120, rows: 40, seq: hidden.length + overflow.length }) - const pane = createPane(1) - const deps = createDeps({ isVisibleRef: { current: false }, startup: { command: 'codex' } }) - const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) - await flushAsyncTicks(6) - safeFitAndThen.mockClear() - safeFitAndThen.mockReturnValueOnce({ - completion: fit.promise, - cancel: vi.fn(() => fit.resolve(false)) - }) - vi.useFakeTimers() - onData?.(hidden, { seq: hidden.length, rawLength: hidden.length }) - ;(deps.isVisibleRef as { current: boolean }).current = true - onData?.('v', { seq: hidden.length + 1, rawLength: 1 }) - await flushAsyncTicks(30) - expect(safeFitAndThen).toHaveBeenCalledTimes(1) - onData?.(overflow, { seq: hidden.length + overflow.length, rawLength: overflow.length }) - await vi.advanceTimersByTimeAsync(750) - fit.resolve(true) - await flushAsyncTicks(30) - await vi.advanceTimersByTimeAsync(2_000) - await flushAsyncTicks(30) - const output = pane.terminal.write.mock.calls.map(([data]) => data).join('') - expect(output).toContain(done) - expect(output).not.toContain('main recovery was unavailable') - expect(getSnapshot).toHaveBeenCalledTimes(2) - disposable.dispose() - vi.useRealTimers() - expect(await renderHeadlessBuffer([output])).toEqual(await renderHeadlessBuffer([done])) - }) + const getSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + const hidden = 'hidden-before-flood\r\n' + const snapshot = covered > 0 ? `${hidden}v${pending.slice(0, covered)}` : hidden + const done = 'HIDDEN_FLOOD_DONE\r\n' + const rawLength = pending.length + (name === 'unmapped' ? 5 : 0) + let seq = hidden.length + 1 + rawLength + getSnapshot + .mockResolvedValueOnce({ data: snapshot, cols: 120, rows: 40, seq: snapshot.length }) + .mockResolvedValue({ + data: done, + cols: 120, + rows: 40, + seq: seq + 100 + }) + const pane = createPane(1) + const isVisibleRef = { current: false } + const deps = createDeps({ isVisibleRef, startup: { command: 'codex' } }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + safeFitAndThen.mockClear() + safeFitAndThen.mockReturnValueOnce({ + completion: fit.promise, + cancel: vi.fn(() => fit.resolve(false)) + }) + vi.useFakeTimers() + onData?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + if (name === 'unhide gap') { + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'hidden-drop', + hiddenDropEpisode: 1, + markerSeq: hidden.length + }) + } + isVisibleRef.current = true + onData?.('v', { seq: hidden.length + 1, rawLength: 1 }) + await flushAsyncTicks(30) + expect(safeFitAndThen).toHaveBeenCalledTimes(1) + onData?.(pending, { seq, rawLength }) + if (name === 'unhide gap' || name === 'fresh snapshot gap') { + isVisibleRef.current = name === 'unhide gap' + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: name === 'unhide gap' ? 'unhide' : 'hidden-drop', + hiddenDropEpisode: 1, + markerSeq: seq + 100 + }) + isVisibleRef.current = true + } + await vi.advanceTimersByTimeAsync(750) + const deadlineOutput = pane.terminal.write.mock.calls.map(([data]) => data).join('') + expect(deadlineOutput).not.toContain('main recovery was unavailable') + expect(getSnapshot).toHaveBeenCalledTimes(1) + let laterOutput = '' + for (let second = 0; second < 12; second += 1) { + if (!repaint) { + const tick = `tick-${second}\r\n` + laterOutput += tick + seq += tick.length + onData?.(tick, { seq, rawLength: tick.length }) + } + await vi.advanceTimersByTimeAsync(1_000) + } + expect(getSnapshot).toHaveBeenCalledTimes(repaint ? 2 : 1) + fit.resolve(true) + await flushAsyncTicks(30) + await vi.advanceTimersByTimeAsync(2_000) + await flushAsyncTicks(30) + const output = pane.terminal.write.mock.calls.map(([data]) => data).join('') + expect(output).not.toContain('main recovery was unavailable') + expect(getSnapshot).toHaveBeenCalledTimes(repaint ? 2 : 1) + disposable.dispose() + vi.useRealTimers() + const drained = `${hidden}v${pending}` + expect(await renderHeadlessBuffer([output])).toEqual( + await renderHeadlessBuffer([repaint ? done : drained + laterOutput]) + ) + if (!repaint) { + expect(await renderHeadlessBuffer([deadlineOutput])).toEqual( + await renderHeadlessBuffer([drained]) + ) + } + } + ) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts index 6d12fdb6e8c..ccb02505eb4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts @@ -11,6 +11,7 @@ import { import { shouldWritePtyOutputForeground } from './foreground-output-scan' import type { ConnectPanePtySession } from './connect-pane-pty-session' +import type { PendingHiddenOutputRestoreChunk } from './pending-hidden-output-restore-chunk' export function bindHiddenOutputRestoreDrain(session: ConnectPanePtySession): void { // 'drained' = painted all queued bytes; 'overflow' = queue blew its cap (stream outran fetch+replay); 'refetch' = offsets unmappable, need a fresher snapshot. @@ -128,16 +129,22 @@ export function bindHiddenOutputRestoreDrain(session: ConnectPanePtySession): vo ) { return } - // A fetched snapshot plus live overflow is backpressure, not unavailable recovery. The - // replay paints synchronously before it awaits its fit, so this deadline never lands - // mid-paint: adopt that painted image as the baseline — the overflow abandon below - // returns before arming one, and without it main's ACK backlog repaints as duplicates. - const replayed = session.hiddenOutputRestorePendingOverflow - ? session.hiddenOutputRestoreReplayingSnapshot - : null + // A painted snapshot awaiting fit proves recovery answered, regardless of live queue size. + const replayed = session.hiddenOutputRestoreReplayingSnapshot if (replayed) { session.setRestoredSnapshotBaseline(ptyId, replayed, replayed.paintsContent === true) - session.noteHiddenOutputRestoreFloodBackpressure() + if ( + session.hiddenOutputRestorePendingOverflow || + session.hiddenOutputRestoreFreshSnapshotNeeded || + (session.hiddenOutputRestoreUnhideSeq !== null && + replayed.seq < session.hiddenOutputRestoreUnhideSeq) || + session.hiddenOutputRestorePendingChunks.some( + (chunk: PendingHiddenOutputRestoreChunk) => + session.getChunkDataAfterSnapshot(chunk, replayed.seq) === null + ) + ) { + session.noteHiddenOutputRestoreFloodBackpressure() + } } session.abandonHiddenOutputRestoreAndDrainPendingForeground(ptyId, { quiet: replayed !== null