From a93f73c0327002b9ed63d99e97ebfd5cd865d4df Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:33:23 -0700 Subject: [PATCH] fix(terminal): skip an escape tail an abandoned hidden-output restore would print (#33905) * fix(terminal): skip an escape tail an abandoned hidden-output restore would print (#33766) Main drops a hidden pane's renderer delivery after its model ingests the bytes, so the first chunk sent on reveal can begin inside an escape whose start was dropped. When the restore is abandoned, the renderer resets its parser and writes the queued chunks, and xterm printed the tail (';242m', the rest of a Codex OSC title). Main tracks each PTY's escape state over every accepted payload and labels the first delivery after a hidden or pending-cap drop with the escape it resumes inside (gapEscapeState). Only the abandon path strips that continuation; a successful restore keeps every byte so the snapshot's pending escape tail can complete. No label means no change. * fix(terminal): make the escape-continuation scan's switch exhaustive (#33766) --- src/main/ipc/pty/delivery/accept.ts | 16 ++- src/main/ipc/pty/delivery/exit.ts | 3 +- src/main/ipc/pty/delivery/flush.ts | 3 +- src/main/ipc/pty/delivery/payload.ts | 6 +- src/main/ipc/pty/delivery/pending.ts | 10 +- .../ipc/pty/delivery/pty-gap-escape-state.ts | 40 ++++++ .../pty/delivery/pty-hidden-delivery-gate.ts | 8 ++ .../delivery/pty-ipc-gap-escape-state.test.ts | 124 ++++++++++++++++++ .../pty-pending-data-drain-contract.ts | 2 + src/main/ipc/pty/session.ts | 2 + src/preload/api/pty-api.ts | 1 + .../pty-bridge-stream-and-serialization.ts | 2 + ...ion-hidden-restore-loss-disclosure.test.ts | 87 +++++++++++- .../hidden-output-restore-abandon.ts | 15 ++- .../hidden-output-restore-chunk.ts | 3 + .../pty-connection/live-data-callback.ts | 16 ++- .../pending-hidden-output-restore-chunk.ts | 1 + .../terminal-pane/pty-dispatcher.ts | 7 + src/shared/terminal-partial-escape-tail.ts | 75 +++++++++++ 19 files changed, 409 insertions(+), 12 deletions(-) create mode 100644 src/main/ipc/pty/delivery/pty-gap-escape-state.ts create mode 100644 src/main/ipc/pty/delivery/pty-ipc-gap-escape-state.test.ts diff --git a/src/main/ipc/pty/delivery/accept.ts b/src/main/ipc/pty/delivery/accept.ts index 01a344e40db..52f4ce7513a 100644 --- a/src/main/ipc/pty/delivery/accept.ts +++ b/src/main/ipc/pty/delivery/accept.ts @@ -18,6 +18,11 @@ import { shouldSendInteractiveOutputNow } from './interactive' import { requestDeliveryResyncForGatedPty } from './accounting' import { warnIfDroppingHiddenBytesForVisiblePty } from './debug-snapshot' import { clearFlushTimerIfIdle } from './flush' +import { + advancePtyEscapeBoundary, + markPtyRendererStreamGap, + takePtyGapEscapeState +} from './pty-gap-escape-state' import type { PtyIpcSession } from '../session' export function rendererPtyIsKnownHidden(id: string): boolean { @@ -54,6 +59,7 @@ export function acceptPtyDataForRenderer( if (session.rendererExitingPtyIds.has(payload.id)) { return } + const escapeStateBefore = advancePtyEscapeBoundary(payload.id, payload.data) if (shouldDropHiddenRendererPtyData(payload.id, session.getSettings?.())) { const droppedChars = payload.data.length const drop = recordHiddenRendererPtyDataDrop(payload.id, droppedChars) @@ -72,6 +78,7 @@ export function acceptPtyDataForRenderer( if (payload.data.length === 0 && !payload.transformed) { return } + const gapEscapeState = takePtyGapEscapeState(payload.id, escapeStateBefore) const containsBackgroundOutput = rendererPtyIsKnownHidden(payload.id) const overflowMarkedBeforeAppend = session.pendingOverflowMarkedPtys.has(payload.id) const pending = appendPendingPtyData( @@ -83,8 +90,12 @@ export function acceptPtyDataForRenderer( preservesSeq, containsBackgroundOutput, rawLength, - payload.transformed === true + payload.transformed === true, + gapEscapeState ) + if (pending.droppedOutput === true) { + markPtyRendererStreamGap(payload.id) + } const shouldEmitPendingCapRestoreMarker = pending.droppedOutput === true && !overflowMarkedBeforeAppend && @@ -127,7 +138,8 @@ export function acceptPtyDataForRenderer( : {}), ...(pending.transformed ? { transformed: true } : {}), ...(pending.containsBackgroundOutput === true ? { background: true } : {}), - ...(pending.droppedOutput === true ? { droppedOutput: true } : {}) + ...(pending.droppedOutput === true ? { droppedOutput: true } : {}), + ...(pending.gapEscapeState ? { gapEscapeState: pending.gapEscapeState } : {}) }) } finally { session.updateProducerFlowControl(payload.id) diff --git a/src/main/ipc/pty/delivery/exit.ts b/src/main/ipc/pty/delivery/exit.ts index c8dfa658268..79399fe2b4d 100644 --- a/src/main/ipc/pty/delivery/exit.ts +++ b/src/main/ipc/pty/delivery/exit.ts @@ -101,7 +101,8 @@ function preparePtyExitForRenderer( remaining.startSeq, remaining.containsBackgroundOutput, remaining.rawLength, - remaining.transformed + remaining.transformed, + remaining.gapEscapeState ) ) } diff --git a/src/main/ipc/pty/delivery/flush.ts b/src/main/ipc/pty/delivery/flush.ts index 4368a884a5f..b0d0fc855a1 100644 --- a/src/main/ipc/pty/delivery/flush.ts +++ b/src/main/ipc/pty/delivery/flush.ts @@ -177,7 +177,8 @@ export function flushPendingData(session: PtyIpcSession): void { pending.startSeq, pending.containsBackgroundOutput, pending.rawLength, - pending.transformed + pending.transformed, + pending.gapEscapeState ) ) if (!sent) { diff --git a/src/main/ipc/pty/delivery/payload.ts b/src/main/ipc/pty/delivery/payload.ts index 98af384acc7..6f86abd75b5 100644 --- a/src/main/ipc/pty/delivery/payload.ts +++ b/src/main/ipc/pty/delivery/payload.ts @@ -10,9 +10,13 @@ export function makePtyDataPayload( startSeq: number | undefined, containsBackgroundOutput: boolean | undefined, rawLength = data.length, - transformed = false + transformed = false, + gapEscapeState?: string ): PtyDataPayload { const payload: PtyDataPayload = { id, data } + if (gapEscapeState) { + payload.gapEscapeState = gapEscapeState + } if (typeof startSeq === 'number') { payload.seq = startSeq + rawLength } diff --git a/src/main/ipc/pty/delivery/pending.ts b/src/main/ipc/pty/delivery/pending.ts index c027fad1926..b0e21c18bb4 100644 --- a/src/main/ipc/pty/delivery/pending.ts +++ b/src/main/ipc/pty/delivery/pending.ts @@ -107,7 +107,8 @@ export function appendPendingPtyData( preservesSeq: boolean, containsBackgroundOutput: boolean, rawLength = data.length, - transformed = false + transformed = false, + gapEscapeState?: string ): PendingPtyData { // Once dropped, keep bounded query/mode state while the restore sentinel supersedes ordinary output. if (existing?.droppedOutput === true) { @@ -140,7 +141,8 @@ export function appendPendingPtyData( ...(typeof startSeq === 'number' ? { startSeq } : {}), ...(rawLength !== data.length ? { rawLength } : {}), ...(transformed ? { transformed: true } : {}), - ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}), + ...(gapEscapeState ? { gapEscapeState } : {}) } return dropOversizedPendingPtyData(session, id, pending) } @@ -150,7 +152,9 @@ export function appendPendingPtyData( ...(!preservesSeq || existing.transformed || transformed ? { rawLength: existingRawLength + rawLength, transformed: true as const } : {}), - ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}), + // Describes the batch start; a gap inside an existing batch has no start to label. + ...(existing.gapEscapeState ? { gapEscapeState: existing.gapEscapeState } : {}) } if (typeof existing.startSeq === 'number') { next.startSeq = existing.startSeq diff --git a/src/main/ipc/pty/delivery/pty-gap-escape-state.ts b/src/main/ipc/pty/delivery/pty-gap-escape-state.ts new file mode 100644 index 00000000000..300c3a98242 --- /dev/null +++ b/src/main/ipc/pty/delivery/pty-gap-escape-state.ts @@ -0,0 +1,40 @@ +/** Label the first delivery after a renderer-stream gap with the escape it resumes inside, so an abandoned restore can skip that tail (#33766). */ +import { advanceTerminalEscapeBoundary } from '../../../../shared/terminal-partial-escape-tail' + +// Escape-parser state at the end of each PTY's accepted output, delivered or dropped. +const escapeBoundaryByPty = new Map() +const gapPendingPtys = new Set() + +/** Advance over an accepted payload; returns the state in effect before it. */ +export function advancePtyEscapeBoundary(id: string, data: string): string { + const before = escapeBoundaryByPty.get(id) ?? '' + const after = advanceTerminalEscapeBoundary(before, data) + if (after) { + escapeBoundaryByPty.set(id, after) + } else { + escapeBoundaryByPty.delete(id) + } + return before +} + +export function markPtyRendererStreamGap(id: string): void { + gapPendingPtys.add(id) +} + +/** The open escape a delivery resumes inside, once per gap; undefined when the stream resumes clean. */ +export function takePtyGapEscapeState(id: string, stateBefore: string): string | undefined { + if (!gapPendingPtys.delete(id)) { + return undefined + } + return stateBefore || undefined +} + +export function clearPtyGapEscapeState(id: string): void { + escapeBoundaryByPty.delete(id) + gapPendingPtys.delete(id) +} + +export function _resetPtyGapEscapeStateForTest(): void { + escapeBoundaryByPty.clear() + gapPendingPtys.clear() +} diff --git a/src/main/ipc/pty/delivery/pty-hidden-delivery-gate.ts b/src/main/ipc/pty/delivery/pty-hidden-delivery-gate.ts index fb506a7c8e8..eebfada3ebb 100644 --- a/src/main/ipc/pty/delivery/pty-hidden-delivery-gate.ts +++ b/src/main/ipc/pty/delivery/pty-hidden-delivery-gate.ts @@ -1,5 +1,10 @@ /** Drop hidden renderer delivery after model ingestion; reveal restores a snapshot, while sidecar interest keeps raw bytes flowing. */ import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { + _resetPtyGapEscapeStateForTest, + clearPtyGapEscapeState, + markPtyRendererStreamGap +} from './pty-gap-escape-state' export type HiddenPtyDeliveryGateSettings = Pick< GlobalSettings, @@ -104,6 +109,7 @@ export function recordHiddenRendererPtyDataDrop( ): { shouldEmitRestoreMarker: boolean; hiddenDropEpisode: number } { droppedHiddenDeliveryChars += chars droppedHiddenDeliveryChunks += 1 + markPtyRendererStreamGap(id) const latch = droppedSinceHiddenPtys.get(id) if (latch?.state === 'unrevealed') { return { shouldEmitRestoreMarker: false, hiddenDropEpisode: latch.episode } @@ -131,6 +137,7 @@ export function clearHiddenRendererPtyDeliveryState(id: string): void { runtimeOwnedHiddenRendererPtys.delete(id) deliveryInterestRendererPtys.delete(id) droppedSinceHiddenPtys.delete(id) + clearPtyGapEscapeState(id) } export type HiddenRendererPtyDeliveryDebug = { @@ -159,5 +166,6 @@ export function _resetHiddenRendererPtyDeliveryGateForTest(): void { runtimeOwnedHiddenRendererPtys.clear() deliveryInterestRendererPtys.clear() droppedSinceHiddenPtys.clear() + _resetPtyGapEscapeStateForTest() resetHiddenRendererPtyDeliveryDebugCounters() } diff --git a/src/main/ipc/pty/delivery/pty-ipc-gap-escape-state.test.ts b/src/main/ipc/pty/delivery/pty-ipc-gap-escape-state.test.ts new file mode 100644 index 00000000000..bf60f7cef3b --- /dev/null +++ b/src/main/ipc/pty/delivery/pty-ipc-gap-escape-state.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite } from '../../pty-ipc-test-harness' +import { registerPtyHandlers } from '../register-handlers' + +vi.mock('electron', () => import('../../pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('../../pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('../../pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('../../pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../../../opencode/hook-service', () => + import('../../pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../../../mimo/hook-service', () => + import('../../pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../../../agent-hooks/server', () => + import('../../pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../../../pi/titlebar-extension-service', () => + import('../../pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../../../pwsh', () => + import('../../pty-ipc-mock-registry').then((m) => m.pwshModuleMock()) +) +vi.mock('../../../wsl', async (importOriginal) => + (await import('../../pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../../../telemetry/client', () => + import('../../pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../../../telemetry/classify-error', () => + import('../../pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../../../cli/linux-terminal-orca-cli-shim', () => + import('../../pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../../../memory/pty-registry', () => + import('../../pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../../../agent-hooks/migration-unsupported-pty-state', () => + import('../../pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../../../codex/codex-pane-account-registry', () => + import('../../pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../../../codex/codex-state-db-backfill-recovery', () => + import('../../pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) +// The observable daemon provider adopts the spawn's sessionId as the PTY id. +const PTY_ID = 'daemon-session' + +describe('the first delivery after a hidden drop (#33766)', () => { + const { + handlers, + mainWindow, + installObservableDaemonTestProvider, + getPtySetHiddenRendererPtyListener + } = setupPtyIpcSuite() + + async function hideDropAndResume(dropped: string, resumed: string) { + vi.useFakeTimers() + let seq = 0 + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn((_id: string, _data: string, _at: number, chars: number) => (seq += chars)), + getPtyOutputSequence: vi.fn(() => seq), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPreAllocatedHandleForPty: vi.fn() + } + const daemon = installObservableDaemonTestProvider() + try { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the suite's window and runtime stubs cover every member registerPtyHandlers reads. + registerPtyHandlers(mainWindow as never, runtime as never) + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, sessionId: PTY_ID }) + const id = PTY_ID + const setHidden = getPtySetHiddenRendererPtyListener() + setHidden(null, { id, hidden: true }) + daemon.emitData(id, dropped) + vi.advanceTimersByTime(50) + setHidden(null, { id, hidden: false }) + mainWindow.webContents.send.mockClear() + daemon.emitData(id, resumed) + vi.advanceTimersByTime(50) + const delivered = mainWindow.webContents.send.mock.calls + .filter(([channel]) => channel === 'pty:data') + .map(([, payload]): Record => payload) + return { delivered, seq } + } finally { + vi.useRealTimers() + } + } + + it.each([ + [ + 'an OSC title', + 'done\x1b]0;Sequent', + 'ial refactoring PRs | cleanup-dumb-exports\x07> prompt', + '\x1b]' + ], + ['an SGR sequence', 'diff \x1b[38;5', ';242m+ line', '\x1b['], + ['a CSI whose ESC alone was dropped', 'diff \x1b', '[38;5;242m+ line', '\x1b'] + ])( + 'labels the first delivery after a drop that split %s, without trimming it', + async (_name, dropped, resumed, gapEscapeState) => { + const { delivered, seq } = await hideDropAndResume(dropped, resumed) + // A restore whose snapshot carries the open escape still needs every continuation byte. + expect(delivered).toEqual([ + { id: expect.any(String), data: resumed, seq, rawLength: resumed.length, gapEscapeState } + ]) + } + ) + + it('leaves a delivery after a clean drop boundary unlabeled, including a bell after text', async () => { + const { delivered } = await hideDropAndResume('hidden\x1b[0m', 'build done\x07') + expect(delivered.map((payload) => payload.data).join('')).toBe('build done\x07') + expect(delivered.some((payload) => 'gapEscapeState' in payload)).toBe(false) + }) +}) diff --git a/src/main/ipc/pty/delivery/pty-pending-data-drain-contract.ts b/src/main/ipc/pty/delivery/pty-pending-data-drain-contract.ts index 7d704e71045..6b85c4eb974 100644 --- a/src/main/ipc/pty/delivery/pty-pending-data-drain-contract.ts +++ b/src/main/ipc/pty/delivery/pty-pending-data-drain-contract.ts @@ -7,6 +7,8 @@ export type PendingPtyData = { rawLength?: number transformed?: true containsBackgroundOutput?: boolean + /** Escape the stream was inside when `data` begins, set only right after a delivery gap. */ + gapEscapeState?: string droppedOutput?: true droppedMode2031Data?: string droppedMode2031ScanState?: Mode2031ReplyScanState diff --git a/src/main/ipc/pty/session.ts b/src/main/ipc/pty/session.ts index e24c8e033d3..81e9fc3218b 100644 --- a/src/main/ipc/pty/session.ts +++ b/src/main/ipc/pty/session.ts @@ -28,6 +28,8 @@ export type PtyDataPayload = { transformed?: boolean background?: boolean droppedOutput?: boolean + /** Escape left open by bytes dropped just before this chunk; lets an abandoned restore skip its tail. */ + gapEscapeState?: string } export type RendererPtyDeliveryAccounting = { diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index dbdaa7fd897..e0ab7ba5e21 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -251,6 +251,7 @@ export type PtyApi = { transformed?: boolean background?: boolean droppedOutput?: boolean + gapEscapeState?: string }) => void ) => () => void onReplay: (callback: (data: { id: string; data: string }) => void) => () => void diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index 467a7fc8145..2630e78dfb7 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -49,6 +49,7 @@ export const ptyStreamAndSerializationApi = { transformed?: boolean background?: boolean droppedOutput?: boolean + gapEscapeState?: string }) => void ): (() => void) => { const listener = ( @@ -61,6 +62,7 @@ export const ptyStreamAndSerializationApi = { transformed?: boolean background?: boolean droppedOutput?: boolean + gapEscapeState?: string } ) => callback(data) ipcRenderer.on('pty:data', listener) 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 index 59e1ce47008..c5c780003a8 100644 --- 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 @@ -1,7 +1,7 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { RESET_AFTER_BYTE_GAP } from '../../../../shared/terminal-mode-reset-profiles' -import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' +import { flushAsyncTicks, createDeferred, renderHeadlessBuffer } from './pty-connection-test-async' import { createMockTransport, createPane, @@ -11,6 +11,7 @@ import { type MockTransport } from './pty-connection-test-pane-fixtures' import { buildPaneConnectionDeps, type PaneConnectionDeps } from './pty-connection-test-deps' +import type { PtyDataMeta } from './pty-dispatcher' import { createInitialStoreState } from './pty-connection-test-store-fixtures' import type { StoreState } from './pty-connection-test-store-state' import { @@ -153,6 +154,7 @@ type PaneDrive = { deps: PaneConnectionDeps disposable: { dispose: () => void } deliver: (data: string, seq: number) => void + deliverWithMeta?: (data: string, meta: PtyDataMeta) => void setVisible: (visible: boolean) => void setOutputPaused: (paused: boolean, dropsReported?: boolean) => void reportPausedOutputDropped: () => void @@ -528,7 +530,7 @@ describe('hidden-output restore loss disclosure (STA-4869)', () => { // Gate local hidden output to mirror the host pause exercised by remote routes. const transport = createMockTransport(LOCAL_PTY_ID) const captured: { - current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + current: ((data: string, meta?: PtyDataMeta) => void) | null } = { current: null } transport.connect.mockImplementation( async ({ callbacks }: { callbacks?: ConnectCallbacks }) => { @@ -551,6 +553,7 @@ describe('hidden-output restore loss disclosure (STA-4869)', () => { deps, disposable, deliver: (data, seq) => captured.current?.(data, { seq, rawLength: data.length }), + deliverWithMeta: (data, meta) => captured.current?.(data, meta), setVisible: (visible) => { deps.isVisibleRef.current = visible }, @@ -625,6 +628,86 @@ describe('hidden-output restore loss disclosure (STA-4869)', () => { expectDisclosedOrRecovered(disclosure) drive.disposable.dispose() }) + + // #33766: main labels the first chunk after its hidden drop with the escape the drop left open. + async function abandonAfterResumedChunk(resumed: string, gapEscapeState?: string) { + vi.mocked(window.api.pty.getMainBufferSnapshot).mockResolvedValue(null) + const drive = await connectLocalHiddenPane() + vi.useFakeTimers() + drive.deliver(HIDDEN_FLOOD, HIDDEN_FLOOD.length) + drive.setVisible(true) + const seq = HIDDEN_FLOOD.length + resumed.length + drive.deliverWithMeta?.(resumed, { + seq, + rawLength: resumed.length, + ...(gapEscapeState ? { gapEscapeState } : {}) + }) + await flushAsyncTicks(20) + for (let attempt = 0; attempt < 3; attempt += 1) { + await vi.advanceTimersByTimeAsync(50) + await flushAsyncTicks(20) + } + drive.disposable.dispose() + vi.useRealTimers() + return (await renderHeadlessBuffer(drive.writtenChunks(), 120, 8)).join('\n') + } + + it.each([ + [ + 'an OSC title', + '\x1b]', + 'ial refactoring PRs | cleanup-dumb-exports\x07> prompt', + 'ial refactoring' + ], + ['an SGR sequence', '\x1b[', ';5;242m> prompt', ';242m'] + ])( + 'skips the tail of %s main says the gap left open', + async (_name, gapEscapeState, resumed, tail) => { + const screen = await abandonAfterResumedChunk(resumed, gapEscapeState) + expect({ + banner: screen.includes(BANNER_FRAGMENT), + prompt: screen.includes('> prompt'), + tail: screen.includes(tail) + }).toEqual({ banner: true, prompt: true, tail: false }) + } + ) + + it('keeps a labeled continuation when the snapshot at the gap carries the open escape', async () => { + // The snapshot predates the resumed chunk, so its pending escape needs every continuation byte. + vi.mocked(window.api.pty.getMainBufferSnapshot).mockResolvedValue({ + data: 'history\r\n', + cols: 120, + rows: 40, + seq: HIDDEN_FLOOD.length, + pendingEscapeTailAnsi: '\x1b[38;5' + }) + const drive = await connectLocalHiddenPane() + vi.useFakeTimers() + drive.deliver(HIDDEN_FLOOD, HIDDEN_FLOOD.length) + drive.setVisible(true) + const resumed = ';242m+ line' + drive.deliverWithMeta?.(resumed, { + seq: HIDDEN_FLOOD.length + resumed.length, + rawLength: resumed.length, + gapEscapeState: '\x1b[' + }) + await allowRecoveryWindow() + drive.disposable.dispose() + vi.useRealTimers() + const written = drive.writtenChunks().join('') + const screen = (await renderHeadlessBuffer(drive.writtenChunks(), 120, 8)).join('\n') + expect({ + continuationWritten: written.includes('\x1b[38;5') && written.includes(resumed), + line: screen.includes('+ line'), + tail: screen.includes(';242m'), + banner: screen.includes(BANNER_FRAGMENT) + }).toEqual({ continuationWritten: true, line: true, tail: false, banner: false }) + }) + + it('keeps unlabeled output that ends in a bell', async () => { + const screen = await abandonAfterResumedChunk('build done\x07') + expect(screen.includes('build done')).toBe(true) + }) }) // The PTY-swap arm requires a prior successful snapshot, which resets its abandon budget; a deferred repaint must keep any subsequent gap recoverable. 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 fd2f568e63e..8607ef32ed9 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 @@ -1,4 +1,5 @@ import { cancelScheduledHiddenOutputRestore } from '../hidden-output-restore-scheduler' +import { skipOpenEscapeContinuation } from '../../../../../shared/terminal-partial-escape-tail' import { shouldWritePtyOutputForeground } from './foreground-output-scan' import type { ConnectPanePtySession } from './connect-pane-pty-session' @@ -76,11 +77,23 @@ export function bindAbandonHiddenOutputRestore(session: ConnectPanePtySession): return } const replayedSeq = typeof replayingSnapshot?.seq === 'number' ? replayingSnapshot.seq : null + // The reset above aborted any open escape, so a chunk main labels as resuming inside one must skip its tail (#33766). + const followsByteGap = !opts.quiet || !opts.preserveReplayState + let openEscape = '' let pendingData = '' for (const chunk of pendingChunks) { const sliced = replayedSeq === null ? chunk.data : session.getChunkDataAfterSnapshot(chunk, replayedSeq) - pendingData += sliced ?? chunk.data + let data = sliced ?? chunk.data + if (followsByteGap && chunk.gapEscapeState && data === chunk.data) { + openEscape = chunk.gapEscapeState + } + if (openEscape) { + const skipped = skipOpenEscapeContinuation(openEscape, data) + data = skipped.rest + openEscape = skipped.stillOpen + } + pendingData += data } if (replayingSnapshot && replayedSeq !== null) { session.setRestoredSnapshotBaseline( diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-chunk.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-chunk.ts index dd65ea50c12..da840895815 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-chunk.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-chunk.ts @@ -47,6 +47,9 @@ export function bindHiddenOutputRestoreChunk(session: ConnectPanePtySession): vo if (typeof meta?.rawLength === 'number') { pending.rawLength = meta.rawLength } + if (meta?.gapEscapeState) { + pending.gapEscapeState = meta.gapEscapeState + } session.hiddenOutputRestorePendingChunks.push(pending) session.hiddenOutputRestorePendingChars += data.length session.armHiddenOutputRestoreForegroundDeadline() diff --git a/src/renderer/src/components/terminal-pane/pty-connection/live-data-callback.ts b/src/renderer/src/components/terminal-pane/pty-connection/live-data-callback.ts index 474d2d0250d..2d61a36ca4b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/live-data-callback.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/live-data-callback.ts @@ -20,6 +20,7 @@ export function bindLiveDataCallback(session: ConnectPanePtySession): void { if (streamGeneration !== session.transportStreamGeneration) { return } + const arrivedData = data if (session.deferredReattachLiveData !== null) { const ackCredit = takeCurrentTerminalDeliveryCredit() session.deferredReattachLiveData.enqueue({ @@ -156,7 +157,11 @@ export function bindLiveDataCallback(session: ConnectPanePtySession): void { if (pendingForegroundQuery?.statefulQueryData) { session.queueLiveChunkDuringRestore(pendingForegroundQuery.statefulQueryData) } - session.queueLiveChunkDuringRestore(orderedRendererData, rendererMeta) + session.queueLiveChunkDuringRestore( + orderedRendererData, + // The gap label describes the chunk's original first byte only. + orderedRendererData === arrivedData ? rendererMeta : withoutGapEscapeState(rendererMeta) + ) session.requestHiddenOutputRestoreIfNeeded() } else if (session.hiddenOutputRestoreInFlight) { session.hiddenOutputRestoreNeeded = true @@ -187,3 +192,12 @@ export function bindLiveDataCallback(session: ConnectPanePtySession): void { } ) } + +function withoutGapEscapeState(meta: PtyDataMeta | undefined): PtyDataMeta | undefined { + if (!meta?.gapEscapeState) { + return meta + } + const next = { ...meta } + delete next.gapEscapeState + return next +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pending-hidden-output-restore-chunk.ts b/src/renderer/src/components/terminal-pane/pty-connection/pending-hidden-output-restore-chunk.ts index 086b894ebff..5525dfcb5ba 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pending-hidden-output-restore-chunk.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pending-hidden-output-restore-chunk.ts @@ -2,4 +2,5 @@ export type PendingHiddenOutputRestoreChunk = { data: string seq?: number rawLength?: number + gapEscapeState?: string } diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 2349ce44e4c..ee7e8c6a8e9 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -54,6 +54,8 @@ export type PtyDataMeta = { background?: boolean /** Main dropped this PTY's buffered output at the pending cap; repaint from the main-owned snapshot, not the live stream. */ droppedOutput?: boolean + /** Escape main's dropped bytes left open where this chunk begins; only an abandoned restore uses it. */ + gapEscapeState?: string } /** Sidecar PTY-data observers, invoked AFTER the primary handler so a side-effect-only watcher can't delay xterm rendering. */ @@ -116,6 +118,7 @@ function handleDispatchedPtyData(payload: { transformed?: boolean background?: boolean droppedOutput?: boolean + gapEscapeState?: string }): void { let meta: PtyDataMeta | undefined if (typeof payload.seq === 'number') { @@ -138,6 +141,10 @@ function handleDispatchedPtyData(payload: { meta ??= {} meta.droppedOutput = true } + if (typeof payload.gapEscapeState === 'string' && payload.gapEscapeState.length > 0) { + meta ??= {} + meta.gapEscapeState = payload.gapEscapeState + } const chars = payload.rawLength ?? payload.data.length const dispatch = (): void => { if (isPtyDataHandlerShutdownPending(payload.id)) { diff --git a/src/shared/terminal-partial-escape-tail.ts b/src/shared/terminal-partial-escape-tail.ts index 5ceb7ddc3ea..64a9678d6d8 100644 --- a/src/shared/terminal-partial-escape-tail.ts +++ b/src/shared/terminal-partial-escape-tail.ts @@ -157,6 +157,81 @@ function scanPartialEscapeTail(stream: string, stateOnly: boolean): string { return stateOnly ? STATE_PREFIX[state] : state === 'ground' ? '' : stream.slice(start) } +const STATE_BY_PREFIX: Record = { + '\x1b': 'esc', + '\x1b ': 'escIntermediate', + '\x1b[': 'csi', + '\x1b]': 'osc', + '\x1bP': 'string' +} + +/** Chars of chunk that finish the escape left open by advanceTerminalEscapeBoundary's statePrefix; a new ESC ends it unconsumed. -1 when it stays open. */ +export function openEscapeContinuationLength(statePrefix: string, chunk: string): number { + let state: ScanState = STATE_BY_PREFIX[statePrefix] ?? 'ground' + for (let i = 0; i < chunk.length; i++) { + if (state === 'ground') { + return i + } + const code = chunk.charCodeAt(i) + if (code === CAN || code === SUB) { + return i + 1 + } + switch (state) { + case 'esc': + if (code === ESC) { + return i + } + state = stateAfterEscByte(code) + if (state === 'ground') { + return i + 1 + } + break + case 'escIntermediate': + if (code === ESC) { + return i + } + if (code >= 0x30 && code <= 0x7e) { + return i + 1 + } + break + case 'csi': + if (code === ESC) { + return i + } + if (code >= 0x40 && code <= 0x7e) { + return i + 1 + } + break + case 'osc': + case 'string': + if (code === BEL && state === 'osc') { + return i + 1 + } + // Keep an ESC that may start a new sequence; if it was a split ST, its backslash later completes a no-op ST. + if (code === ESC) { + return chunk.charCodeAt(i + 1) === 0x5c ? i + 2 : i + } + break + // Unreachable from a statePrefix (both map to 'esc'); an ESC inside a string either finishes ST or starts a kept sequence. + case 'oscEsc': + case 'stringEsc': + return code === 0x5c ? i + 1 : i + } + } + return state === 'ground' ? chunk.length : -1 +} + +/** Drop the part of chunk that continues the escape open in statePrefix; stillOpen carries it when the chunk ends first. */ +export function skipOpenEscapeContinuation( + statePrefix: string, + chunk: string +): { rest: string; stillOpen: string } { + const length = openEscapeContinuationLength(statePrefix, chunk) + return length === -1 + ? { rest: '', stillOpen: advanceTerminalEscapeBoundary(statePrefix, chunk) } + : { rest: chunk.slice(length), stillOpen: '' } +} + /** Advance the incremental tail; abandon it past the cap. */ export function advancePartialEscapeTail(pendingTail: string, chunk: string): string { // Only ESC leaves ground, so no tail + no ESC avoids concatenation and the full chunk walk.