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.