diff --git a/src/main/ipc/pty-pending-data-drain-contract.ts b/src/main/ipc/pty-pending-data-drain-contract.ts index d1d4c5be1a8..202baf75c67 100644 --- a/src/main/ipc/pty-pending-data-drain-contract.ts +++ b/src/main/ipc/pty-pending-data-drain-contract.ts @@ -7,6 +7,7 @@ export type PendingPtyData = { transformed?: true containsBackgroundOutput?: boolean droppedOutput?: true + denseSgrDrop?: true droppedMode2031Data?: string droppedMode2031ScanState?: Mode2031ReplyScanState projectionAdmissionIds?: readonly string[] diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index efd9e3ac827..259e70f93a8 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -10,6 +10,7 @@ import { import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text' import { redactPtyIdForDiagnostics } from '../../shared/pty-delivery-diagnostics' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultWorkspaceSession } from '../../shared/constants' +import { stripTerminalSgr } from '../../shared/terminal-sgr-load' import type { TuiAgent } from '../../shared/types' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-host-authority' @@ -588,7 +589,10 @@ describe('registerPtyHandlers', () => { } } - function getPtyWriteListener(): (event: unknown, args: { id: string; data: string }) => void { + function getPtyWriteListener(): ( + event: unknown, + args: { id: string; data: string; rendererBacklogDropped?: true } + ) => void { const writeCall = onMock.mock.calls.find((call: unknown[]) => call[0] === 'pty:write') if (!writeCall) { throw new Error('missing pty:write listener') @@ -16194,6 +16198,249 @@ describe('registerPtyHandlers', () => { } }) + it('strips dense pending styling without dropping an earlier echo or query', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + let sequence = 0 + const runtime = { + setPtyController: vi.fn(), + setRemoteTerminalSourceRangeConsumerHooks: vi.fn(), + preAllocateHandleForPty: vi.fn(), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyData: vi.fn((_id: string, _data: string, _at: number, rawLength: number) => { + sequence += rawLength + }), + getDriver: vi.fn(() => ({ kind: 'host' })), + getPtyOutputSequence: vi.fn(() => sequence), + acceptPtyDataBounded: vi.fn((_id: string, _data: string, _at: number, rawLength: number) => { + sequence += rawLength + return { sequence, completion: Promise.resolve() } + }) + } + + try { + registerPtyHandlers(mainWindow as never, runtime as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const dense = Array.from( + { length: 4_096 }, + (_value, index) => `\x1b[38;5;${index % 216}mX\x1b[0m` + ).join('') + const pendingData = `${dense}__PRIOR_ECHO__\x1b[6n\x1b[38;5` + const currentData = `${dense}__ECHO__` + mockProc.emitData(pendingData) + mainWindow.webContents.send.mockClear() + getPtyWriteListener()(mainWindowIpcEvent, { id: spawnResult.id, data: 'a' }) + + mockProc.emitData(currentData) + + expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(1, 'pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'interactive-drop', + markerSeq: pendingData.length + }) + expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(2, 'pty:data', { + id: spawnResult.id, + data: stripTerminalSgr(pendingData) + stripTerminalSgr(currentData), + seq: pendingData.length + currentData.length, + rawLength: pendingData.length + currentData.length, + transformed: true + }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingChars: 0, + pendingDroppedChars: pendingData.length - stripTerminalSgr(pendingData).length + }) + } finally { + vi.useRealTimers() + } + }) + + it('does not bypass ordinary pending output after recent input', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const ordinary = 'plain-output'.repeat(4_096) + getPtyWriteListener()(mainWindowIpcEvent, { id: spawnResult.id, data: 'a' }) + mainWindow.webContents.send.mockClear() + + mockProc.emitData(ordinary) + mockProc.emitData('__ECHO__') + + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'pty:modelRestoreNeeded', + expect.objectContaining({ reason: 'interactive-drop' }) + ) + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: `${ordinary}__ECHO__`.slice(0, 16 * 1024) + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps unsaturated dense output byte-identical after input', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + const dense = Array.from( + { length: 64 }, + (_value, index) => `\x1b[38;5;${index % 216}mX\x1b[0m` + ).join('') + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const write = getPtyWriteListener() + + write(mainWindowIpcEvent, { id: spawnResult.id, data: 'a' }) + mainWindow.webContents.send.mockClear() + mockProc.emitData(dense) + vi.advanceTimersByTime(2) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: dense + }) + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'pty:modelRestoreNeeded', + expect.objectContaining({ reason: 'interactive-drop' }) + ) + } finally { + vi.useRealTimers() + } + }) + + it('activates dense protection when the renderer dropped its backlog', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + const dense = Array.from( + { length: 64 }, + (_value, index) => `\x1b[38;5;${index % 216}mX\x1b[0m` + ).join('') + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + + getPtyWriteListener()(mainWindowIpcEvent, { + id: spawnResult.id, + data: 'a', + rendererBacklogDropped: true + }) + mainWindow.webContents.send.mockClear() + mockProc.emitData(dense) + + expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(1, 'pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'interactive-drop' + }) + expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(2, 'pty:data', { + id: spawnResult.id, + data: `\x18\x1b[0m${'X'.repeat(64)}\x18\x1b[0m`, + rawLength: dense.length, + transformed: true + }) + } finally { + vi.useRealTimers() + } + }) + + it('preserves source-tracked SSH projection bytes during recent input', async () => { + vi.useFakeTimers() + const connectionId = 'ssh-dense-source' + const id = `ssh:${connectionId}@@dense-source-pty` + const provider = createAgentClaimProvider({}) + const dense = Array.from( + { length: 4_096 }, + (_value, index) => `\x1b[38;5;${index % 216}mX\x1b[0m` + ).join('') + let sequence = 0 + const runtime = { + setPtyController: vi.fn(), + setRemoteTerminalSourceRangeConsumerHooks: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + getPtyOutputSequence: vi.fn(() => sequence), + acceptPtyDataBounded: vi.fn((_id: string, _data: string, _at: number, rawLength: number) => { + sequence += rawLength + return { sequence, completion: Promise.resolve() } + }) + } + + try { + registerSshPtyProvider(connectionId, provider as never) + setPtyOwnership(id, connectionId) + registerPtyHandlers(mainWindow as never, runtime as never) + getPtyWriteListener()(mainWindowIpcEvent, { id, data: 'a' }) + mainWindow.webContents.send.mockClear() + + await acceptSshPtyOutputData({ + id, + data: dense, + providerGeneration: 71, + ptyIncarnation: 'dense-source-incarnation', + rawLength: dense.length, + transformed: false, + source: { + relayPtyId: 'relay-dense-source-pty', + spanId: `dense-source:0:${dense.length}`, + clientGeneration: 2, + ownerGeneration: 3, + deliveryToken: 'dense-source', + sourceStartSu: 0, + sourceEndSu: dense.length + } + }) + getPtyWriteListener()(mainWindowIpcEvent, { id, data: 'b' }) + vi.runAllTimers() + + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'pty:modelRestoreNeeded', + expect.objectContaining({ reason: 'interactive-drop' }) + ) + const payloads = mainWindow.webContents.send.mock.calls + .filter(([channel]) => channel === 'pty:data') + .map(([, payload]) => payload as { data: string; rawLength?: number; transformed?: true }) + const delivered = payloads.map(({ data }) => data).join('') + expect(delivered.length).toBe(dense.length) + expect(delivered.slice(0, 512)).toBe(dense.slice(0, 512)) + expect(delivered.slice(-512)).toBe(dense.slice(-512)) + expect(payloads.reduce((sum, payload) => sum + (payload.rawLength ?? 0), 0)).toBe( + dense.length + ) + expect(payloads.every((payload) => payload.transformed !== true)).toBe(true) + } finally { + closeSshPtyOutputGeneration(71, 'test-cleanup') + deletePtyOwnership(id) + unregisterSshPtyProvider(connectionId) + vi.useRealTimers() + } + }) + posixOnlyIt('falls back to a system shell when SHELL points to a missing binary', async () => { const originalShell = process.env.SHELL const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -16346,11 +16593,20 @@ describe('registerPtyHandlers', () => { write(mainWindowIpcEvent, null) write(mainWindowIpcEvent, { id: '', data: 'x' }) write(mainWindowIpcEvent, { id: result.id, data: 1 }) + write(mainWindowIpcEvent, { id: result.id, data: 'x', rendererBacklogDropped: false }) + write(mainWindowIpcEvent, { id: result.id, data: 'x', rendererBacklogDropped: 'yes' }) write(foreignWindowIpcEvent, { id: result.id, data: 'x' }) expect(writeAccepted(mainWindowIpcEvent, null)).toBe(false) expect(writeAccepted(mainWindowIpcEvent, { id: '', data: 'x' })).toBe(false) expect(writeAccepted(mainWindowIpcEvent, { id: result.id, data: 1 })).toBe(false) + expect( + writeAccepted(mainWindowIpcEvent, { + id: result.id, + data: 'x', + rendererBacklogDropped: false + }) + ).toBe(false) expect(writeAccepted(foreignWindowIpcEvent, { id: result.id, data: 'x' })).toBe(false) expect(mockProc.proc.write).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 0fc5d6e9d40..57c8dfe101c 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -19,6 +19,7 @@ import type { GlobalSettings, TuiAgent } from '../../shared/types' import { toSshExecutionHostId } from '../../shared/execution-host' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { terminalOutputBacklogCapChars } from '../../shared/terminal-scrollback-policy' +import { isDenseTerminalSgr, stripTerminalSgr } from '../../shared/terminal-sgr-load' import type { PtyDeliveryWriteOff, PtyRendererDeliveryHealthReply, @@ -259,6 +260,8 @@ const ptySizes = new Map() // Why: the "recent user input" signal is PTY-scoped and must be cleared by every teardown path, incl. SSH/daemon shutdowns that skip the local exit listener. const lastInputAtByPty = new Map() const interactiveOutputCharsByPty = new Map() +const interactiveDenseProtectionPtys = new Set() +const interactiveDenseRestoreMarkedPtys = new Set() const activeRendererPtys = new Set() const visibleRendererPtys = new Set() const rendererVisibilityKnownPtys = new Set() @@ -1892,6 +1895,8 @@ export function clearProviderPtyState( ptyIncarnationById.delete(id) lastInputAtByPty.delete(id) interactiveOutputCharsByPty.delete(id) + interactiveDenseProtectionPtys.delete(id) + interactiveDenseRestoreMarkedPtys.delete(id) const activeChanged = activeRendererPtys.delete(id) visibleRendererPtys.delete(id) rendererVisibilityKnownPtys.delete(id) @@ -2436,6 +2441,8 @@ export function registerPtyHandlers( const INTERACTIVE_OUTPUT_MAX_CHARS = 1024 const INTERACTIVE_REDRAW_MAX_CHARS = PTY_BATCH_FLUSH_CHUNK_CHARS const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024 + const INTERACTIVE_DENSE_BACKLOG_DROP_CHARS = 32 * 1024 + const INTERACTIVE_DROP_RESTORE_QUIET_MS = 500 let peakPendingChars = 0 let peakMaxPendingCharsByPty = 0 let peakRendererInFlightChars = 0 @@ -2720,6 +2727,8 @@ export function registerPtyHandlers( clearPendingPtyData() pendingOverflowMarkedPtys.clear() rendererDeliveryRestoreNeededPtys.clear() + interactiveDenseProtectionPtys.clear() + interactiveDenseRestoreMarkedPtys.clear() // Why hold sends: the reloading page's pty:data listener is gone until it re-registers/handshakes, so bytes would drop into a listener-less page and re-pin the gate. rendererPtyDispatcherReady = false // Why: arm the self-heal watchdog so a never-arriving handshake can't hold the gate forever; the real handshake cancels it. @@ -2736,14 +2745,33 @@ export function registerPtyHandlers( return data.length <= INTERACTIVE_REDRAW_MAX_CHARS && data.includes('\x1b[') } - function shouldSendInteractiveOutputNow(id: string, data: string, now: number): boolean { + function hasRecentPtyInput(id: string, now: number): boolean { const lastInputAt = lastInputAtByPty.get(id) - if (lastInputAt === undefined || now - lastInputAt > INTERACTIVE_OUTPUT_WINDOW_MS) { + return lastInputAt !== undefined && now - lastInputAt <= INTERACTIVE_OUTPUT_WINDOW_MS + } + + function notePtyInput(id: string, now: number): void { + const lastInputAt = lastInputAtByPty.get(id) + if (lastInputAt === undefined || now - lastInputAt > INTERACTIVE_DROP_RESTORE_QUIET_MS) { + interactiveDenseProtectionPtys.delete(id) + interactiveDenseRestoreMarkedPtys.delete(id) + } + lastInputAtByPty.set(id, now) + interactiveOutputCharsByPty.set(id, 0) + if (getRendererInFlightCharsForPty(id) >= PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS) { + interactiveDenseProtectionPtys.add(id) + } + } + + function shouldSendInteractiveOutputNow(id: string, data: string, now: number): boolean { + if (!hasRecentPtyInput(id, now)) { interactiveOutputCharsByPty.delete(id) return false } if (!isLikelyInteractiveRedraw(data)) { - interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS) + if (!isDenseTerminalSgr(data)) { + interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS) + } return false } const usedChars = interactiveOutputCharsByPty.get(id) ?? 0 @@ -3027,7 +3055,8 @@ export function registerPtyHandlers( function sendModelRestoreNeededMarker( id: string, reason: PtyModelRestoreReason, - markerSeq: number | undefined + markerSeq: number | undefined, + discardedQueryData?: string ): void { if (mainWindow.isDestroyed()) { return @@ -3035,7 +3064,8 @@ export function registerPtyHandlers( mainWindow.webContents.send('pty:modelRestoreNeeded', { id, reason, - ...(typeof markerSeq === 'number' ? { markerSeq } : {}) + ...(typeof markerSeq === 'number' ? { markerSeq } : {}), + ...(discardedQueryData ? { discardedQueryData } : {}) }) } @@ -3053,6 +3083,66 @@ export function registerPtyHandlers( return extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData } + function getDiscardedPendingQueryData(pending: PendingPtyData): string { + if (pending.droppedOutput === true) { + return (pending.data + getDroppedMode2031RendererData(pending)).slice( + 0, + DROPPED_QUERY_SALVAGE_MAX_CHARS + ) + } + const mode2031 = scanDroppedMode2031Data(pending.data, INITIAL_MODE_2031_REPLY_SCAN_STATE) + return (extractDroppedPtyQueryBytes(pending.data) + mode2031.data).slice( + 0, + DROPPED_QUERY_SALVAGE_MAX_CHARS + ) + } + + function protectDensePendingForInteractiveOutput( + id: string, + pending: PendingPtyData | undefined, + markerSeq: number | undefined + ): boolean { + if ( + !pending || + (pending.droppedOutput === true + ? pending.denseSgrDrop !== true + : pending.data.length <= INTERACTIVE_DENSE_BACKLOG_DROP_CHARS || + !isDenseTerminalSgr(pending.data) || + pending.projectionAdmissionIds !== undefined) + ) { + return false + } + let discardedQueryData: string | undefined + if (pending.droppedOutput === true) { + discardedQueryData = getDiscardedPendingQueryData(pending) + deletePendingPtyData(id) + pendingOverflowMarkedPtys.delete(id) + clearFlushTimerIfIdle() + } else { + const protectedData = stripTerminalSgr(pending.data) + pendingDroppedChars += Math.max(0, pending.data.length - protectedData.length) + setPendingPtyData(id, { + ...pending, + data: protectedData, + rawLength: pending.rawLength ?? pending.data.length, + transformed: true + }) + } + updateProducerFlowControl(id) + sendModelRestoreNeededMarker(id, 'interactive-drop', markerSeq, discardedQueryData) + interactiveDenseProtectionPtys.add(id) + interactiveDenseRestoreMarkedPtys.add(id) + return true + } + + function prioritizePendingPtyDataForInput(id: string): void { + protectDensePendingForInteractiveOutput( + id, + pendingData.get(id), + runtime?.getPtyOutputSequence?.(id) + ) + } + function scanDroppedMode2031Data( data: string, previous: Mode2031ReplyScanState @@ -3099,11 +3189,16 @@ export function registerPtyHandlers( if (pending.projectionAdmissionIds) { sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'pending-cap') } + const denseSgrDrop = + activeRendererPtys.has(id) && + hasRecentPtyInput(id, performance.now()) && + isDenseTerminalSgr(pending.data) const mode2031 = scanDroppedMode2031Data(pending.data, INITIAL_MODE_2031_REPLY_SCAN_STATE) // Why no trimmed content tail: a mid-stream gap would corrupt the pane; the droppedOutput sentinel repaints from the snapshot and realigns by sequence (only query bytes ride along). return { data: extractDroppedPtyQueryBytes(pending.data).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS), droppedOutput: true, + ...(denseSgrDrop ? { denseSgrDrop: true } : {}), droppedMode2031Data: mode2031.data, droppedMode2031ScanState: mode2031.state } @@ -3501,6 +3596,8 @@ export function registerPtyHandlers( rendererDeliveryRestoreNeededPtys.delete(payload.id) lastInputAtByPty.delete(payload.id) interactiveOutputCharsByPty.delete(payload.id) + interactiveDenseProtectionPtys.delete(payload.id) + interactiveDenseRestoreMarkedPtys.delete(payload.id) const releasedRendererCredit = getRendererInFlightCharsForPty(payload.id) rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - releasedRendererCredit) // Why: the renderer also drops its cumulative total on pty:exit, so a reused id restarts aligned at zero on both sides. @@ -3550,7 +3647,18 @@ export function registerPtyHandlers( projection?: LegacySshProjectionSemantics ): void { const rawLength = payload.sequenceChars ?? payload.data.length - const preservesSeq = !payload.transformed && rawLength === payload.data.length + const now = performance.now() + const recentInput = hasRecentPtyInput(payload.id, now) + // Source-tracked SSH spans retain their reserved display/accounting mapping. + const denseSgrOutput = + recentInput && + interactiveDenseProtectionPtys.has(payload.id) && + !projection?.desktopSpan && + isDenseTerminalSgr(payload.data) + const collapseDenseStyling = denseSgrOutput + const rendererData = collapseDenseStyling ? stripTerminalSgr(payload.data) : payload.data + const rendererTransformed = payload.transformed === true || collapseDenseStyling + const preservesSeq = !rendererTransformed && rawLength === rendererData.length const startSeq = typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined const projectionId = projection?.identity.projectionSemanticsId if (mainWindow.isDestroyed()) { @@ -3600,35 +3708,44 @@ export function registerPtyHandlers( markHiddenRendererResizeOutputDelivered(payload.id) } const overflowMarkedBeforeAppend = pendingOverflowMarkedPtys.has(payload.id) + const existingPending = pendingData.get(payload.id) + if ( + recentInput && + isLikelyInteractiveRedraw(rendererData) && + existingPending?.denseSgrDrop !== true + ) { + protectDensePendingForInteractiveOutput(payload.id, existingPending, outputSeq) + } if (projection?.desktopSpan) { sourceCreditPendingPtys.add(payload.id) } const pending = appendPendingPtyData( payload.id, pendingData.get(payload.id), - payload.data, + rendererData, startSeq, preservesSeq, containsBackgroundOutput, rawLength, - payload.transformed === true, + rendererTransformed, projectionId ) const shouldEmitPendingCapRestoreMarker = pending.droppedOutput === true && !overflowMarkedBeforeAppend && pendingOverflowMarkedPtys.has(payload.id) + const pendingCapRestoreReason = pending.denseSgrDrop ? 'interactive-drop' : 'pending-cap' const nextData = pending.data + getDroppedMode2031RendererData(pending) - const isInteractiveOutput = shouldSendInteractiveOutputNow( - payload.id, - nextData, - performance.now() - ) + const isInteractiveOutput = shouldSendInteractiveOutputNow(payload.id, nextData, now) + if (collapseDenseStyling && !interactiveDenseRestoreMarkedPtys.has(payload.id)) { + interactiveDenseRestoreMarkedPtys.add(payload.id) + sendModelRestoreNeededMarker(payload.id, 'interactive-drop', outputSeq) + } if (isInteractiveOutput && rendererPtyDispatcherReady) { if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) { setPendingPtyData(payload.id, pending) if (shouldEmitPendingCapRestoreMarker) { - sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq) + sendModelRestoreNeededMarker(payload.id, pendingCapRestoreReason, outputSeq) } updateProducerFlowControl(payload.id) requestDeliveryResyncForGatedPty() @@ -3637,27 +3754,26 @@ export function registerPtyHandlers( deletePendingPtyData(payload.id) clearFlushTimerIfIdle() if (shouldEmitPendingCapRestoreMarker) { - sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq) + sendModelRestoreNeededMarker(payload.id, pendingCapRestoreReason, outputSeq) } pendingOverflowMarkedPtys.delete(payload.id) try { - sendPtyDataToRenderer( - payload.id, - { - id: payload.id, - data: nextData, - ...(typeof pending.startSeq === 'number' - ? { - seq: pending.startSeq + (pending.rawLength ?? nextData.length), - rawLength: pending.rawLength ?? nextData.length - } - : {}), - ...(pending.transformed ? { transformed: true } : {}), - ...(pending.containsBackgroundOutput === true ? { background: true } : {}), - ...(pending.droppedOutput === true ? { droppedOutput: true } : {}) - }, - pending.projectionAdmissionIds - ) + const immediatePayload = + pending.droppedOutput === true + ? { + id: payload.id, + data: nextData, + droppedOutput: true as const + } + : makePtyDataPayload( + payload.id, + nextData, + pending.startSeq, + pending.containsBackgroundOutput, + pending.rawLength, + pending.transformed + ) + sendPtyDataToRenderer(payload.id, immediatePayload, pending.projectionAdmissionIds) } finally { updateProducerFlowControl(payload.id) } @@ -3665,7 +3781,7 @@ export function registerPtyHandlers( } setPendingPtyData(payload.id, pending) if (shouldEmitPendingCapRestoreMarker) { - sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq) + sendModelRestoreNeededMarker(payload.id, pendingCapRestoreReason, outputSeq) } updateProducerFlowControl(payload.id) if ( @@ -6616,7 +6732,7 @@ export function registerPtyHandlers( } } - type PtyWritePayload = { id: string; data: string } + type PtyWritePayload = { id: string; data: string; rendererBacklogDropped?: true } type PtyViewportClaimPayload = { id: string; cols: number; rows: number } const isPtyWritePayload = (value: unknown): value is PtyWritePayload => @@ -6624,7 +6740,9 @@ export function registerPtyHandlers( value !== null && typeof (value as { id?: unknown }).id === 'string' && (value as { id: string }).id.length > 0 && - typeof (value as { data?: unknown }).data === 'string' + typeof (value as { data?: unknown }).data === 'string' && + ((value as { rendererBacklogDropped?: unknown }).rendererBacklogDropped === undefined || + (value as { rendererBacklogDropped?: unknown }).rendererBacklogDropped === true) const isPtyViewportClaimPayload = (value: unknown): value is PtyViewportClaimPayload => typeof value === 'object' && @@ -6657,8 +6775,11 @@ export function registerPtyHandlers( } try { const now = performance.now() - lastInputAtByPty.set(args.id, now) - interactiveOutputCharsByPty.set(args.id, 0) + notePtyInput(args.id, now) + if (args.rendererBacklogDropped) { + interactiveDenseProtectionPtys.add(args.id) + } + prioritizePendingPtyDataForInput(args.id) if (visibleRendererPtys.has(args.id)) { clearHiddenRendererResizeOutput(args.id) } @@ -6682,8 +6803,11 @@ export function registerPtyHandlers( } try { const now = performance.now() - lastInputAtByPty.set(args.id, now) - interactiveOutputCharsByPty.set(args.id, 0) + notePtyInput(args.id, now) + if (args.rendererBacklogDropped) { + interactiveDenseProtectionPtys.add(args.id) + } + prioritizePendingPtyDataForInput(args.id) if (visibleRendererPtys.has(args.id)) { clearHiddenRendererResizeOutput(args.id) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 5208f3ffa24..c1988f0e7b9 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1536,8 +1536,8 @@ export type PreloadApi = { startupCwdFallback?: { kind: 'worktree'; cwd: string } agentResumeUnavailable?: true }> - write: (id: string, data: string) => void - writeAccepted: (id: string, data: string) => Promise + write: (id: string, data: string, rendererBacklogDropped?: true) => void + writeAccepted: (id: string, data: string, rendererBacklogDropped?: true) => Promise onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void resize: (id: string, cols: number, rows: number) => void claimViewport: (id: string, cols: number, rows: number) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index f1614048bad..d8de7901469 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -952,11 +952,19 @@ const api = { agentResumeUnavailable?: true }> => ipcRenderer.invoke('pty:spawn', opts), - write: (id: string, data: string): void => { - ipcRenderer.send('pty:write', { id, data }) + write: (id: string, data: string, rendererBacklogDropped?: true): void => { + ipcRenderer.send('pty:write', { + id, + data, + ...(rendererBacklogDropped ? { rendererBacklogDropped } : {}) + }) }, - writeAccepted: (id: string, data: string): Promise => - ipcRenderer.invoke('pty:writeAccepted', { id, data }), + writeAccepted: (id: string, data: string, rendererBacklogDropped?: true): Promise => + ipcRenderer.invoke('pty:writeAccepted', { + id, + data, + ...(rendererBacklogDropped ? { rendererBacklogDropped } : {}) + }), onWriteUnavailable: (callback: (payload: { id: string }) => void): (() => void) => { const handler = (_event: Electron.IpcRendererEvent, payload: { id: string }): void => callback(payload) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index cfed6571b0a..a970f4d5622 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -11469,7 +11469,10 @@ describe('connectPanePty', () => { return window.api.pty.setHiddenRendererPty as unknown as ReturnType } - async function connectHiddenPane(deps: ReturnType): Promise<{ + async function connectHiddenPane( + deps: ReturnType, + terminalElement?: ReturnType['target'] + ): Promise<{ transport: MockTransport pane: ReturnType dataCallback: ( @@ -11496,6 +11499,9 @@ describe('connectPanePty', () => { ) transportFactoryQueue.push(transport) const pane = createPane(1) + if (terminalElement) { + pane.terminal.element = terminalElement + } const manager = createManager(1) const binding = connectPanePty(pane as never, manager as never, deps as never) as { syncProcessTracking: () => void @@ -11926,6 +11932,119 @@ describe('connectPanePty', () => { expect(getMainBufferSnapshot).not.toHaveBeenCalled() }) + it('restores an interactive drop after typing quiets and answers salvaged queries', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + const terminalTarget = createKeyboardEventTarget() + const { pane, transport } = await connectHiddenPane(deps, terminalTarget.target) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'interactive recovery\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + + vi.useFakeTimers() + try { + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'interactive-drop', + markerSeq: 48, + discardedQueryData: '\x1b[c' + }) + expect(transport.sendInputImmediate).toHaveBeenCalledWith(DEFAULT_DA1_RESPONSE) + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + + vi.advanceTimersByTime(400) + terminalTarget.dispatch(keyEvent({ key: 'a' })) + sendTerminalInputThroughPane(pane, 'a') + vi.advanceTimersByTime(499) + await flushAsyncTicks(4) + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('cancels a deferred interactive restore when the pane is disposed', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + const { binding } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + + vi.useFakeTimers() + try { + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'interactive-drop' }) + binding.dispose() + vi.advanceTimersByTime(500) + await flushAsyncTicks(4) + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('defers recovery when a real keydown drops the dense renderer backlog', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + const terminalTarget = createKeyboardEventTarget() + const { pane, transport } = await connectHiddenPane(deps, terminalTarget.target) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'renderer backlog recovery\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + const dense = Array.from( + { length: 4_096 }, + (_value, index) => `\x1b[38;5;${index % 216}mX\x1b[0m` + ).join('') + const { writeTerminalOutput } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + + vi.useFakeTimers() + try { + writeTerminalOutput(pane.terminal as never, dense, { + foreground: true, + latencySensitive: false + }) + terminalTarget.dispatch(keyEvent({ key: 'a' })) + sendTerminalInputThroughPane(pane, 'a') + expect(transport.sendInput).toHaveBeenCalledWith('a', true) + vi.advanceTimersByTime(499) + await flushAsyncTicks(4) + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + it('gates marker re-arms during an in-flight foreground restore and repaints once after', async () => { enableMainAuthority() const deps = createDeps({ isVisibleRef: { current: true } }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index f66ff91b7a5..c1021c5b894 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -150,6 +150,7 @@ import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-str import { discardTerminalOutput, flushTerminalOutput, + prioritizeTerminalInput, registerTerminalBacklogRecovery, waitForTerminalOutputParsed, writeTerminalOutput @@ -239,6 +240,7 @@ import { sendTerminalOscColorQueryReplies } from './terminal-capability-replies' import { registerPtyModelRestoreNeededHandler } from './pty-model-restore-channel' +import type { PtyModelRestoreNeededEvent } from '../../../../shared/pty-model-restore-marker' import { acquireHiddenRendererPtyDeliveryClaim, declareRendererPtyDeliveryVisible, @@ -1072,6 +1074,8 @@ export function connectPanePty( let cleanupHiddenOutputRestoreDeferredRetry = (): void => {} let cleanupHiddenOutputRestoreForegroundDeadline = (): void => {} let cleanupHiddenOutputRestoreFloodRepaint = (): void => {} + let cleanupInteractiveDropRestore = (): void => {} + let deferInteractiveDropRestore = (_restoreNeeded = false): void => {} let resetRendererOrderedSeqForPtyExit: (exitedPtyId: string) => void = () => {} let cleanupStartupDraftPasteTimers = (): void => {} let unregisterE2ePtyDataInjection = (): void => {} @@ -4076,6 +4080,8 @@ export function connectPanePty( clearPendingTerminalInputIntent() return } + const rendererBacklogDropped = prioritizeTerminalInput(pane.terminal) + deferInteractiveDropRestore(rendererBacklogDropped) const intent = pendingTerminalInputIntent // Why: real xterm can deliver the terminal byte even when our DOM keydown // listener missed the press. Exact Ctrl+C/Escape bytes are still safe to @@ -4097,8 +4103,11 @@ export function connectPanePty( cancelSuspendedShellCommandInference() } clearPendingTerminalInputIntent() - const writePromise = transport - .sendInputAccepted(data) + const writePromise = ( + rendererBacklogDropped + ? transport.sendInputAccepted(data, true) + : transport.sendInputAccepted(data) + ) .then((accepted) => { if (accepted) { // Why: rejected writes use transport recovery and must not arm a parser probe. @@ -4125,7 +4134,10 @@ export function connectPanePty( } if (intent) { claimViewportForUserActivity() - if (transport.sendInput(data)) { + const inputSent = rendererBacklogDropped + ? transport.sendInput(data, true) + : transport.sendInput(data) + if (inputSent) { markAcceptedTerminalInputSent() observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data, intent) @@ -4136,7 +4148,10 @@ export function connectPanePty( return } claimViewportForUserActivity() - if (transport.sendInput(data)) { + const inputSent = rendererBacklogDropped + ? transport.sendInput(data, true) + : transport.sendInput(data) + if (inputSent) { markAcceptedTerminalInputSent() observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data) @@ -5845,6 +5860,8 @@ export function connectPanePty( pendingDeliveryStartSeq?: number } | null = null let hiddenOutputRestoreFloodRepaintTimer: ReturnType | null = null + let interactiveDropRestoreTimer: ReturnType | null = null + let interactiveDropRestorePtyId: string | null = null // Why: after a snapshot restore, main can still drain ACK-backlog chunks // whose bytes the snapshot already covers — writing them unguarded // duplicates visible output. Track the restored baseline seq (per PTY) @@ -6016,7 +6033,39 @@ export function connectPanePty( } // Why: main reports dropped renderer-bound bytes out-of-band, routed per PTY by pty-model-restore-channel.ts. - function handleModelRestoreNeededMarker(): void { + function clearInteractiveDropRestoreTimer(): void { + if (interactiveDropRestoreTimer !== null) { + clearTimeout(interactiveDropRestoreTimer) + interactiveDropRestoreTimer = null + } + interactiveDropRestorePtyId = null + } + cleanupInteractiveDropRestore = clearInteractiveDropRestoreTimer + + function scheduleInteractiveDropRestore(markedPtyId: string): void { + clearInteractiveDropRestoreTimer() + interactiveDropRestorePtyId = markedPtyId + interactiveDropRestoreTimer = setTimeout(() => { + interactiveDropRestoreTimer = null + interactiveDropRestorePtyId = null + if (!disposed && transport.getPtyId() === markedPtyId) { + markHiddenOutputRestoreNeeded() + } + }, 500) + } + + deferInteractiveDropRestore = (restoreNeeded = false): void => { + if (interactiveDropRestorePtyId !== null) { + scheduleInteractiveDropRestore(interactiveDropRestorePtyId) + } else if (restoreNeeded) { + const ptyId = transport.getPtyId() + if (ptyId !== null) { + scheduleInteractiveDropRestore(ptyId) + } + } + } + + function handleModelRestoreNeededMarker(event: PtyModelRestoreNeededEvent): void { if (disposed) { return } @@ -6025,6 +6074,14 @@ export function connectPanePty( }) // Why: dropped bytes invalidate cross-chunk carry — a partial OSC-9999 prefix spanning the gap would corrupt the next live chunk. transport.resetCrossChunkParserState?.() + if (event.discardedQueryData) { + salvageRendererQueriesFromDiscardedRestoreData(event.discardedQueryData) + } + if (event.reason === 'interactive-drop') { + scheduleInteractiveDropRestore(event.id) + return + } + clearInteractiveDropRestoreTimer() // Why gated (rc.7.perf loop): on a visible pane these markers come from our own restore starving ACKs; re-arming per marker kept the fetch loop alive all flood, so defer to one post-flood repaint. if (isForegroundRestoreBackpressureContext()) { noteHiddenOutputRestoreFloodBackpressure() @@ -9167,6 +9224,7 @@ export function connectPanePty( cleanupHiddenOutputRestoreDeferredRetry() cleanupHiddenOutputRestoreForegroundDeadline() cleanupHiddenOutputRestoreFloodRepaint() + cleanupInteractiveDropRestore() unregisterBacklogRecovery?.() unregisterBacklogRecovery = null unregisterDocumentVisibilityRecovery?.() diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts index ddf856f05ae..f6024f0cff9 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts @@ -10,7 +10,7 @@ import { const WHEEL_UP_REPORT = '\x1b[<64;60;20M' -type WriteRecord = { id: string; data: string } +type WriteRecord = { id: string; data: string; rendererBacklogDropped?: true } function createRecordingQueue(options: { writable?: () => boolean } = {}): { writes: WriteRecord[] @@ -19,7 +19,8 @@ function createRecordingQueue(options: { writable?: () => boolean } = {}): { const writes: WriteRecord[] = [] const queue = createPtyInputWriteQueue({ isWritable: () => options.writable?.() ?? true, - write: (id, data) => writes.push({ id, data }) + write: (id, data, rendererBacklogDropped) => + writes.push({ id, data, ...(rendererBacklogDropped ? { rendererBacklogDropped } : {}) }) }) return { writes, queue } } @@ -55,6 +56,20 @@ describe('pty input write queue', () => { expect(writes.map((write) => write.data).join('')).toBe(inputs.join('')) }) + it('preserves a renderer backlog-drop signal when coalescing input', async () => { + const { writes, queue } = createRecordingQueue() + + queue.enqueue('pty-1', 'a') + queue.enqueue('pty-1', 'b') + queue.enqueue('pty-1', 'c', true) + await queue.waitForDrain() + + expect(writes).toEqual([ + { id: 'pty-1', data: 'a' }, + { id: 'pty-1', data: 'bc', rendererBacklogDropped: true } + ]) + }) + it('does not coalesce across different PTY ids', async () => { const writes: WriteRecord[] = [] const queue = createPtyInputWriteQueue({ @@ -108,6 +123,17 @@ describe('pty input write queue', () => { } }) + it('preserves a renderer backlog-drop signal across oversized input chunks', async () => { + const { writes, queue } = createRecordingQueue() + const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 100) + + queue.enqueue('pty-1', large, true) + await queue.waitForDrain() + + expect(writes.map((write) => write.data).join('')).toBe(large) + expect(writes.every((write) => write.rendererBacklogDropped === true)).toBe(true) + }) + it('rejects input over the terminal input byte limit without writing', async () => { const { writes, queue } = createRecordingQueue() diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts index 11764c3b224..8464e416063 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts @@ -12,20 +12,21 @@ export const TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS = 4096 type PendingPtyInputWrite = { id: string text: string + rendererBacklogDropped: boolean tooLarge: boolean | Promise chunks?: Iterator nextChunk?: string } export type PtyInputWriteQueue = { - enqueue: (id: string, data: string) => boolean + enqueue: (id: string, data: string, rendererBacklogDropped?: boolean) => boolean waitForDrain: () => Promise clear: () => void } export type PtyInputWriteQueueDeps = { isWritable: (id: string) => boolean - write: (id: string, data: string) => void + write: (id: string, data: string, rendererBacklogDropped?: true) => void yieldBetweenWrites?: () => Promise } @@ -69,6 +70,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput // the PTY byte stream identical while draining the backlog in one turn. if (next.chunks === undefined && isCoalescibleText(next.text)) { let payload = next.text + let rendererBacklogDropped = next.rendererBacklogDropped pending.shift() while (pending.length > 0) { const peek = pending[0] @@ -83,9 +85,14 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput break } payload += peek.text + rendererBacklogDropped ||= peek.rendererBacklogDropped pending.shift() } - deps.write(next.id, payload) + if (rendererBacklogDropped) { + deps.write(next.id, payload, true) + } else { + deps.write(next.id, payload) + } if (pending.length > 0) { await yieldBetweenWrites() } @@ -99,7 +106,11 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput pending.shift() continue } - deps.write(next.id, chunk.value) + if (next.rendererBacklogDropped) { + deps.write(next.id, chunk.value, true) + } else { + deps.write(next.id, chunk.value) + } const following = next.chunks.next() if (following.done) { pending.shift() @@ -125,13 +136,13 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput } return { - enqueue(id: string, data: string): boolean { + enqueue(id: string, data: string, rendererBacklogDropped = false): boolean { try { const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) if (tooLarge === true) { return false } - pending.push({ id, text: data, tooLarge }) + pending.push({ id, text: data, rendererBacklogDropped, tooLarge }) scheduleDrain() return true } catch { diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index b0dd959416d..d1afe109636 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -134,7 +134,7 @@ export type PtyTransport = { callbacks: PtyCallbacks }) => void disconnect: () => void - sendInput: (data: string) => boolean + sendInput: (data: string, rendererBacklogDropped?: boolean) => boolean // Why: latency-critical terminal query replies (CPR/DSR/DA/OSC color/pixel // size) must skip input coalescing — a querying program reads them in raw // mode with a short timeout, so a debounced reply lands on the shell prompt @@ -142,7 +142,7 @@ export type PtyTransport = { // this is `sendInput` for them; the remote transport flushes pending input // (preserving order) and sends the reply immediately. sendInputImmediate: (data: string) => boolean - sendInputAccepted?: (data: string) => Promise + sendInputAccepted?: (data: string, rendererBacklogDropped?: boolean) => Promise claimViewport?: (cols: number, rows: number) => boolean /** Capability-negotiated paired-runtime delivery gate; false preserves legacy delivery. */ setOutputPaused?: (paused: boolean) => boolean diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index c9973b82311..fb2c06ccbce 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -1072,6 +1072,18 @@ describe('createIpcPtyTransport', () => { expect(sshTransport.sendInputAccepted).toBeUndefined() }) + it('propagates renderer backlog drops through local IPC writes', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const transport = createIpcPtyTransport({}) + + await transport.connect({ url: '', callbacks: {} }) + expect(transport.sendInput('a', true)).toBe(true) + expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'a', true) + + await expect(transport.sendInputAccepted?.('\x03', true)).resolves.toBe(true) + expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', '\x03', true) + }) + it('chunks large local IPC terminal input before renderer-to-main writes', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index d612b13cc8f..b340fcf92b0 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -547,7 +547,13 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra let suppressAttentionEvents = false const inputWriteQueue = createPtyInputWriteQueue({ isWritable: (id) => connected && ptyId === id, - write: (id, data) => window.api.pty.write(id, data) + write: (id, data, rendererBacklogDropped) => { + if (rendererBacklogDropped) { + window.api.pty.write(id, data, true) + } else { + window.api.pty.write(id, data) + } + } }) const outputProcessor = createPtyOutputProcessor({ onTitleChange, @@ -666,7 +672,11 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return new Promise((resolve) => setTimeout(resolve, 0)) } - async function writeAcceptedPtyInput(id: string, data: string): Promise { + async function writeAcceptedPtyInput( + id: string, + data: string, + rendererBacklogDropped = false + ): Promise { try { const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) if (typeof tooLarge === 'boolean' ? tooLarge : await tooLarge) { @@ -678,7 +688,9 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra if (!connected || ptyId !== id) { return false } - const accepted = await window.api.pty.writeAccepted(id, chunk.value) + const accepted = rendererBacklogDropped + ? await window.api.pty.writeAccepted(id, chunk.value, true) + : await window.api.pty.writeAccepted(id, chunk.value) if (!accepted) { return false } @@ -1004,11 +1016,11 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra storedCallbacks = {} }, - sendInput(data: string): boolean { + sendInput(data: string, rendererBacklogDropped = false): boolean { if (!connected || !ptyId) { return false } - return inputWriteQueue.enqueue(ptyId, data) + return inputWriteQueue.enqueue(ptyId, data, rendererBacklogDropped) }, // Why: kept distinct from sendInput so the remote transport can override with flush-then-send (#7329); local queue drains same-turn. @@ -1022,7 +1034,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra ...(connectionId ? {} : { - async sendInputAccepted(data: string): Promise { + async sendInputAccepted(data: string, rendererBacklogDropped = false): Promise { if (!connected || !ptyId) { return false } @@ -1031,7 +1043,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra if (!connected || ptyId !== id) { return false } - return writeAcceptedPtyInput(id, data) + return writeAcceptedPtyInput(id, data, rendererBacklogDropped) } }), diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index aaefe89f872..d7957c9c41f 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -1439,6 +1439,121 @@ describe('pane terminal output scheduler', () => { expect(output).not.toContain('x'.repeat(1024)) }) + it('drops only dense backlog before input and preserves queued echoes', async () => { + vi.useFakeTimers() + const { prioritizeTerminalInput, writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const denseChunk = Array.from( + { length: 2_048 }, + (_value, index) => `\x1b[38;5;${index % 216}mX\x1b[0m` + ).join('') + const firstCredit = vi.fn() + const secondCredit = vi.fn() + const echoCredit = vi.fn() + + writeTerminalOutput(terminal, denseChunk, { + foreground: true, + latencySensitive: false, + ackCredit: firstCredit + }) + writeTerminalOutput(terminal, denseChunk, { + foreground: true, + latencySensitive: false, + ackCredit: secondCredit + }) + writeTerminalOutput(terminal, '__PRIOR_ECHO__', { + foreground: true, + latencySensitive: false, + ackCredit: echoCredit + }) + expect(prioritizeTerminalInput(terminal)).toBe(true) + writeTerminalOutput(terminal, '__ECHO__', { + foreground: true, + latencySensitive: true + }) + vi.runAllTimers() + + const output = terminal.write.mock.calls.map(([data]) => data).join('') + expect(output).toBe('__PRIOR_ECHO____ECHO__') + expect(output).not.toContain(denseChunk) + expect(firstCredit).toHaveBeenCalledTimes(1) + expect(secondCredit).toHaveBeenCalledTimes(1) + expect(echoCredit).toHaveBeenCalledTimes(1) + }) + + it('admits only one dense SGR slice until xterm reports parse completion', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const parsedCallbacks: (() => void)[] = [] + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + if (callback) { + parsedCallbacks.push(callback) + } + }) + const dense = Array.from( + { length: 4_096 }, + (_value, index) => `\x1b[38;5;${index % 216}mX\x1b[0m` + ).join('') + + writeTerminalOutput(terminal, dense, { foreground: true, latencySensitive: false }) + vi.advanceTimersByTime(100) + expect(terminal.write).toHaveBeenCalledTimes(1) + expect(terminal.write.mock.calls[0]?.[0]).toHaveLength(4 * 1024) + + parsedCallbacks.shift()?.() + vi.advanceTimersByTime(0) + expect(terminal.write).toHaveBeenCalledTimes(2) + }) + + it('releases dense parse pacing when terminal output is discarded', async () => { + vi.useFakeTimers() + const { discardTerminalOutput, writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + terminal.write.mockImplementation(() => undefined) + const dense = Array.from( + { length: 4_096 }, + (_value, index) => `\x1b[38;5;${index % 216}mX\x1b[0m` + ).join('') + + writeTerminalOutput(terminal, dense, { foreground: true, latencySensitive: false }) + vi.advanceTimersByTime(100) + expect(terminal.write).toHaveBeenCalledTimes(1) + + discardTerminalOutput(terminal) + writeTerminalOutput(terminal, '__AFTER_DISCARD__', { + foreground: true, + latencySensitive: true + }) + vi.runAllTimers() + + expect(terminal.write.mock.calls.length).toBeGreaterThanOrEqual(2) + expect(terminal.write.mock.calls.some(([data]) => data === '__AFTER_DISCARD__')).toBe(true) + }) + + it('keeps token-level SGR output ordered ahead of a latency-sensitive echo', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const styledTokens = Array.from( + { length: 2_048 }, + (_value, index) => `\x1b[38;5;${index % 216}mordinary-token\x1b[0m` + ).join('') + + writeTerminalOutput(terminal, styledTokens, { + foreground: true, + latencySensitive: false + }) + writeTerminalOutput(terminal, '__ECHO__', { + foreground: true, + latencySensitive: true + }) + vi.runAllTimers() + + const output = terminal.write.mock.calls.map(([data]) => data).join('') + expect(output).toBe(`${styledTokens}__ECHO__`) + }) + it('records a drop breadcrumb with sizes when the cap replaces a backlog', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index 4272d4c0913..b92253a99c6 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -23,6 +23,7 @@ import { TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS, terminalOutputBacklogCapChars } from '../../../../shared/terminal-scrollback-policy' +import { isDenseTerminalSgr } from '../../../../shared/terminal-sgr-load' type TerminalOutputTarget = ForegroundTerminalOutputTarget @@ -49,6 +50,7 @@ type WriteTerminalOutputOptions = { type QueueChunk = { data: string + denseSgr: boolean foreground: boolean forceForegroundRefresh: boolean followupForegroundRefresh: boolean @@ -78,6 +80,7 @@ type QueueEntry = { queuedChars: number onBackgroundBacklogDropped?: () => void backgroundBacklogDropped: boolean + denseSgrQueuedChars: number highPriority: boolean foregroundHold: boolean foregroundHoldSafetyDelayMs: number @@ -91,12 +94,14 @@ const BACKGROUND_FLUSH_DELAY_MS = 50 const BACKGROUND_DRAIN_INTERVAL_MS = 16 const HIGH_PRIORITY_DRAIN_INTERVAL_MS = 4 const BACKGROUND_CHUNK_CHARS = 16 * 1024 +const DENSE_SGR_CHUNK_CHARS = 4 * 1024 const MAX_WRITES_PER_DRAIN = 2 // Why 8: per-tick volume (8 x 16KB = 128KB ≈ 1.3ms parse) sets the sustained ceiling (~30MB/s) within DRAIN_TIME_BUDGET_MS; at 2 it was only 8MB/s against a ~100MB/s parser (see throughput bench). const HIGH_PRIORITY_MAX_WRITES_PER_DRAIN = 8 const DRAIN_TIME_BUDGET_MS = 8 const LARGE_BACKLOG_CHARS = 512 * 1024 const SYNC_FOREGROUND_FLUSH_CHARS = 256 * 1024 +const INPUT_ECHO_DENSE_BACKLOG_CHARS = 32 * 1024 // Why mutable: the cap scales with the user's scrollback setting (terminalOutputBacklogCapChars), configured when settings apply; the chunk-count cap stays fixed. let maxQueueChars = TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS const MAX_BACKGROUND_QUEUE_CHUNKS = 4096 @@ -122,6 +127,7 @@ const FOREGROUND_BACKLOG_WARNING = const ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY = (): boolean => true const queuedByTerminal = new Map() +const denseParseBlockedTerminals = new WeakSet() const backlogRecoveryByTerminal = new WeakMap< TerminalOutputTarget, TerminalBacklogRecoveryRequest @@ -317,6 +323,7 @@ function createQueueEntry( queuedChars: 0, onBackgroundBacklogDropped: options.onBackgroundBacklogDropped, backgroundBacklogDropped: false, + denseSgrQueuedChars: 0, highPriority: true, foregroundHold: false, foregroundHoldSafetyDelayMs: FOREGROUND_HOLD_SAFETY_DELAY_MS, @@ -380,7 +387,11 @@ function scheduleForegroundCoalesceRelease( } function isEntryDrainable(entry: QueueEntry): boolean { - return !entry.foregroundHold && !entry.foregroundCoalesce + return ( + !entry.foregroundHold && + !entry.foregroundCoalesce && + !denseParseBlockedTerminals.has(entry.terminal) + ) } function findCursorPositionSequenceEnd( @@ -591,6 +602,9 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { data += chunk.data remaining -= chunk.data.length entry.queuedChars -= chunk.data.length + if (chunk.denseSgr) { + entry.denseSgrQueuedChars -= chunk.data.length + } entry.chunkIndex += 1 if (chunk.onParsed) { parsedCallbacks.push(chunk.onParsed) @@ -607,6 +621,9 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { data: chunk.data.slice(remaining) } entry.queuedChars -= remaining + if (chunk.denseSgr) { + entry.denseSgrQueuedChars -= remaining + } remaining = 0 } @@ -614,6 +631,7 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { if (entry.queuedChars < 0) { entry.queuedChars = 0 } + entry.denseSgrQueuedChars = Math.max(0, entry.denseSgrQueuedChars) recordQueueDebugPressure() return data ? { @@ -679,8 +697,10 @@ function enqueueChunk( ackCredit?: () => void } ): void { + const denseSgr = isDenseTerminalSgr(data) entry.chunks.push({ data, + denseSgr, foreground: options?.foreground === true, forceForegroundRefresh: options?.forceForegroundRefresh === true, followupForegroundRefresh: options?.followupForegroundRefresh === true, @@ -692,6 +712,9 @@ function enqueueChunk( ackCredit: options?.ackCredit }) entry.queuedChars += data.length + if (denseSgr) { + entry.denseSgrQueuedChars += data.length + } recordQueueDebugPressure() } @@ -707,6 +730,7 @@ function discardDetachedQueueEntry(entry: QueueEntry): void { entry.chunks.length = 0 entry.chunkIndex = 0 entry.queuedChars = 0 + entry.denseSgrQueuedChars = 0 entry.highPriority = false clearForegroundHoldSafety(entry) clearForegroundCoalesce(entry) @@ -744,6 +768,7 @@ function replaceBacklogWithWarning( entry.chunks = [ { data: warning, + denseSgr: false, foreground: false, forceForegroundRefresh: false, followupForegroundRefresh: false, @@ -755,6 +780,7 @@ function replaceBacklogWithWarning( entry.chunkIndex = 0 entry.queuedChars = warning.length entry.backgroundBacklogDropped = true + entry.denseSgrQueuedChars = 0 entry.highPriority = true entry.foregroundHold = false if (debugEnabled && shouldNotify) { @@ -767,6 +793,36 @@ function replaceBacklogWithWarning( } } +function dropDenseSgrChunksForInput(entry: QueueEntry): void { + const retained: QueueChunk[] = [] + let droppedChars = 0 + for (let index = entry.chunkIndex; index < entry.chunks.length; index += 1) { + const chunk = entry.chunks[index] + if (chunk.denseSgr) { + droppedChars += chunk.data.length + chunk.ackCredit?.() + } else { + retained.push(chunk) + } + } + recordRendererCrashBreadcrumb('terminal_input_dense_backlog_dropped', { + droppedChars, + retainedChars: entry.queuedChars - droppedChars + }) + clearForegroundHoldSafety(entry) + clearForegroundCoalesce(entry) + entry.chunks = retained + entry.chunkIndex = 0 + entry.queuedChars = Math.max(0, entry.queuedChars - droppedChars) + entry.denseSgrQueuedChars = 0 + entry.highPriority = retained.length > 0 + entry.foregroundHold = false + if (debugEnabled) { + debugState.droppedBacklogCount++ + } + recordQueueDebugPressure() +} + function hasQueuedChunks(entry: QueueEntry): boolean { return entry.chunkIndex < entry.chunks.length } @@ -850,9 +906,15 @@ function takeNextDrainableEntry(): QueueEntry | null { } // Why: re-arm a zero-delay drain once xterm confirms the previous high-priority batch parsed; the fixed 4/16ms cadence otherwise drips far below xterm's ~100 MB/s parse. Only visible panes are pacer-clocked; background keeps the fixed cadence to protect the focused terminal. -function makeParseClockPacer(): () => void { +function makeParseClockPacer( + terminal: TerminalOutputTarget, + releaseDenseParseBlock: boolean +): () => void { return () => { try { + if (releaseDenseParseBlock) { + denseParseBlockedTerminals.delete(terminal) + } if (queuedByTerminal.size > 0 && hasHighPriorityBacklog()) { scheduleDrain(0) } @@ -882,13 +944,15 @@ function composeParsedCallback( function composeWriteFailureCallback( terminal: TerminalOutputTarget, - ackCreditsParsed: (() => void) | undefined + ackCreditsParsed: (() => void) | undefined, + pacer?: () => void ): () => void { return () => { try { // A rejected write still consumed the main-owned delivery window. ackCreditsParsed?.() } finally { + pacer?.() // Why: a synchronous rejection proves undeliverability but nothing about parse progress; recover without extending replay guards. failTerminalWriteStallWatch(terminal) } @@ -902,11 +966,18 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null discardTerminalOutput(entry.terminal) return null } - const queuedWrite = takeQueuedChunk(entry, BACKGROUND_CHUNK_CHARS) + const paceByParse = entry.denseSgrQueuedChars > 0 + const queuedWrite = takeQueuedChunk( + entry, + paceByParse ? DENSE_SGR_CHUNK_CHARS : BACKGROUND_CHUNK_CHARS + ) if (!queuedWrite) { return null } - const pacer = entry.highPriority ? makeParseClockPacer() : undefined + const pacer = entry.highPriority ? makeParseClockPacer(entry.terminal, paceByParse) : undefined + if (paceByParse) { + denseParseBlockedTerminals.add(entry.terminal) + } const ackCreditsParsed = registerTerminalOutputAckCredits(entry.terminal, queuedWrite.ackCredits) // Why armed BEFORE the write: a wedged WriteBuffer (issue #2836) or disposed xterm (6.1.0-beta.287) never runs the parsed callback, so the watch must be live first to catch it. armTerminalWriteStallWatch(entry.terminal, { @@ -930,14 +1001,14 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null ackCreditsParsed, pacer ), - onWriteFailure: composeWriteFailureCallback(entry.terminal, ackCreditsParsed) + onWriteFailure: composeWriteFailureCallback(entry.terminal, ackCreditsParsed, pacer) } ) : writeBackgroundTerminalChunk( entry.terminal, queuedWrite.data, composeParsedCallback(entry.terminal, queuedWrite.onParsed, ackCreditsParsed, pacer), - composeWriteFailureCallback(entry.terminal, ackCreditsParsed) + composeWriteFailureCallback(entry.terminal, ackCreditsParsed, pacer) ) if (!writeAccepted) { // Why: the failure callback credited the submitted chunk; credit and abandon the detached tail so the drain can't retry a certified-dead xterm. @@ -945,6 +1016,7 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null entry.chunks.length = 0 entry.chunkIndex = 0 entry.queuedChars = 0 + entry.denseSgrQueuedChars = 0 clearForegroundHoldSafety(entry) clearForegroundCoalesce(entry) recordQueueDebugPressure() @@ -953,11 +1025,13 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null } catch { // Why: beforeWrite or write setup can fail before xterm owns the bytes; cancel the armed watch without claiming parser failure. cancelTerminalWriteStallWatch(entry.terminal) + denseParseBlockedTerminals.delete(entry.terminal) ackCreditsParsed?.() fireQueuedAckCredits(entry) entry.chunks.length = 0 entry.chunkIndex = 0 entry.queuedChars = 0 + entry.denseSgrQueuedChars = 0 clearForegroundHoldSafety(entry) clearForegroundCoalesce(entry) recordQueueDebugPressure() @@ -1047,7 +1121,12 @@ export function writeTerminalOutput( if (options.foreground) { const entry = queuedByTerminal.get(terminal) - if (entry?.highPriority || options.coalesceForeground || options.holdForeground) { + if ( + entry?.highPriority || + denseParseBlockedTerminals.has(terminal) || + options.coalesceForeground || + options.holdForeground + ) { const queued = entry ?? createQueueEntry(terminal, options) queued.onBackgroundBacklogDropped = options.onBackgroundBacklogDropped queued.highPriority = true @@ -1231,6 +1310,16 @@ export function writeTerminalOutput( ) } +export function prioritizeTerminalInput(terminal: TerminalOutputTarget): boolean { + const entry = queuedByTerminal.get(terminal) + if (!entry || entry.denseSgrQueuedChars <= INPUT_ECHO_DENSE_BACKLOG_CHARS) { + return false + } + dropDenseSgrChunksForInput(entry) + scheduleDrain(0) + return true +} + export function flushTerminalOutput( terminal: TerminalOutputTarget, options?: { maxChars?: number } @@ -1255,6 +1344,7 @@ export function flushTerminalOutput( entry.chunks.length = 0 entry.chunkIndex = 0 entry.queuedChars = 0 + entry.denseSgrQueuedChars = 0 entry.highPriority = false clearForegroundHoldSafety(entry) clearForegroundCoalesce(entry) @@ -1403,6 +1493,7 @@ export function discardTerminalOutput(terminal: TerminalOutputTarget): void { fireQueuedAckCredits(entry) } discardInFlightTerminalOutputAckCredits(terminal) + denseParseBlockedTerminals.delete(terminal) queuedByTerminal.delete(terminal) discardForegroundRenderSettle(terminal) // Why: cancel the watch without masquerading as parse progress; replay guards use real completions to tell slow from wedged. diff --git a/src/shared/pty-model-restore-marker.ts b/src/shared/pty-model-restore-marker.ts index a182226a0d0..50f8a2f5a0e 100644 --- a/src/shared/pty-model-restore-marker.ts +++ b/src/shared/pty-model-restore-marker.ts @@ -8,7 +8,12 @@ * marker is delivery machinery, not PTY data — remote-runtime transports * never see it. */ -export type PtyModelRestoreReason = 'hidden-drop' | 'unhide' | 'pending-cap' | 'delivery-heal' +export type PtyModelRestoreReason = + | 'hidden-drop' + | 'unhide' + | 'pending-cap' + | 'delivery-heal' + | 'interactive-drop' export type PtyModelRestoreNeededEvent = { id: string @@ -16,4 +21,6 @@ export type PtyModelRestoreNeededEvent = { /** Main's PTY output sequence at emit time — everything at or before this * point is only recoverable from the model snapshot. */ markerSeq?: number + /** Queries salvaged from discarded display output must still receive replies. */ + discardedQueryData?: string } diff --git a/src/shared/terminal-sgr-load.test.ts b/src/shared/terminal-sgr-load.test.ts new file mode 100644 index 00000000000..d3ae4d5ea53 --- /dev/null +++ b/src/shared/terminal-sgr-load.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { isDenseTerminalSgr, stripTerminalSgr } from './terminal-sgr-load' + +describe('dense terminal SGR classification', () => { + it('classifies per-character styling', () => { + const output = Array.from( + { length: 64 }, + (_value, index) => `\x1b[38;5;${index}mX\x1b[0m` + ).join('') + + expect(isDenseTerminalSgr(output)).toBe(true) + }) + + it('keeps token-level styling on the ordinary path', () => { + const output = Array.from( + { length: 64 }, + (_value, index) => `\x1b[38;5;${index}mten-character-token\x1b[0m` + ).join('') + + expect(isDenseTerminalSgr(output)).toBe(false) + }) + + it('requires 32 sequences at one SGR per two text characters', () => { + expect(isDenseTerminalSgr('\x1b[31mXY'.repeat(31))).toBe(false) + expect(isDenseTerminalSgr('\x1b[31mXY'.repeat(32))).toBe(true) + expect(isDenseTerminalSgr(`${'\x1b[31m'.repeat(32)}${'X'.repeat(65)}`)).toBe(false) + }) + + it('ignores non-SGR control sequences', () => { + expect(isDenseTerminalSgr('\x1b[2K\x1b[H'.repeat(64))).toBe(false) + }) + + it('strips SGR while retaining text and non-SGR controls', () => { + expect(stripTerminalSgr('\x1b[31mred\x1b[0m\x1b[6n')).toBe('\x18\x1b[0mred\x1b[6n\x18\x1b[0m') + }) + + it('retains OSC strings and printable echo bytes', () => { + expect(stripTerminalSgr('\x1b[31mX\x1b]0;title\x07__ECHO__\x1b[0m')).toBe( + '\x18\x1b[0mX\x1b]0;title\x07__ECHO__\x18\x1b[0m' + ) + }) + + it('cancels an incomplete CSI tail before retained echo bytes', () => { + const stripped = stripTerminalSgr('\x1b[31mred\x1b[38;5') + + expect(`${stripped}__ECHO__`).toBe('\x18\x1b[0mred\x1b[38;5\x18\x1b[0m__ECHO__') + }) +}) diff --git a/src/shared/terminal-sgr-load.ts b/src/shared/terminal-sgr-load.ts new file mode 100644 index 00000000000..47dcce74841 --- /dev/null +++ b/src/shared/terminal-sgr-load.ts @@ -0,0 +1,53 @@ +export function isDenseTerminalSgr(data: string): boolean { + let sgrSequences = 0 + let textChars = 0 + + for (let index = 0; index < data.length; index += 1) { + if (data[index] !== '\x1b' || data[index + 1] !== '[') { + textChars += 1 + continue + } + + let cursor = index + 2 + while (cursor < data.length) { + const code = data.charCodeAt(cursor) + if (code >= 0x40 && code <= 0x7e) { + if (data[cursor] === 'm') { + sgrSequences += 1 + } + index = cursor + break + } + cursor += 1 + } + } + + return sgrSequences >= 32 && sgrSequences * 2 >= textChars +} + +export function stripTerminalSgr(data: string): string { + let output = '' + let copyFrom = 0 + + for (let index = 0; index < data.length; index += 1) { + if (data[index] !== '\x1b' || data[index + 1] !== '[') { + continue + } + let cursor = index + 2 + while (cursor < data.length) { + const code = data.charCodeAt(cursor) + if (code >= 0x40 && code <= 0x7e) { + if (data[cursor] === 'm') { + output += data.slice(copyFrom, index) + copyFrom = cursor + 1 + } + index = cursor + break + } + cursor += 1 + } + } + + // Boundary CAN/reset prevents carried parser or style state from swallowing retained bytes. + return `\x18\x1b[0m${output}${data.slice(copyFrom)}\x18\x1b[0m` +} diff --git a/tests/e2e/terminal-dense-sgr-echo-latency.spec.ts b/tests/e2e/terminal-dense-sgr-echo-latency.spec.ts new file mode 100644 index 00000000000..c0843471c11 --- /dev/null +++ b/tests/e2e/terminal-dense-sgr-echo-latency.spec.ts @@ -0,0 +1,263 @@ +import type { Page } from '@stablyai/playwright-test' +import { randomUUID } from 'node:crypto' +import { rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + focusActiveTerminalInput, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' +import { nodeTerminalCommand } from './terminal-node-command' + +type OutputMode = 'plain' | 'dense-sgr' + +type EchoSample = { + index: number + latencyMs: number +} + +type EchoProbeReport = { + samples: EchoSample[] + keysObserved: number + parseEvents: number +} + +type EchoProbeWindow = Window & { + __denseSgrEchoProbe?: { + report(): EchoProbeReport + dispose(): void + } + __terminalOutputSchedulerDebug?: { + reset(): void + snapshot(): unknown + } +} + +const TYPED = 'abcdefghijklmnopqrstuvwxyz' +const KEY_INTERVAL_MS = 250 +const ECHO_TIMEOUT_MS = Number(process.env.ORCA_DIAG_TIMEOUT_MS ?? 60_000) +const STYLED_CHARS_PER_TICK = Number(process.env.ORCA_DIAG_STYLED_CHARS ?? 80) + +function outputFloodScript(runId: string, mode: OutputMode): string { + return ` +process.stdin.setEncoding('utf8') +if (process.stdin.isTTY) process.stdin.setRawMode(true) +process.stdin.resume() + +const runId = ${JSON.stringify(runId)} +const mode = ${JSON.stringify(mode)} +const interrupt = String.fromCharCode(3) +let dense = '' +for (let index = 0; index < ${STYLED_CHARS_PER_TICK}; index += 1) { + dense += '\\x1b[38;5;' + (index % 216) + 'mX\\x1b[0m' +} +dense += '\\r\\n' +const chunk = mode === 'dense-sgr' ? dense : 'X'.repeat(dense.length - 2) + '\\r\\n' +let sequence = 0 +let floodTimer + +process.stdout.write('DENSE_SGR_READY_' + runId + '\\r\\n') +setTimeout(() => { + floodTimer = setInterval(() => process.stdout.write(chunk), 1) +}, 2_000) + +process.stdin.on('data', (data) => { + if (data.includes(interrupt)) { + clearInterval(floodTimer) + process.exit(0) + } + for (const char of data) { + if (char === '\\r' || char === '\\n') continue + sequence += 1 + process.stdout.write('\\x1b[0m\\r\\n__ORCA_ECHO_' + runId + '_' + sequence + '__\\r\\n') + } +}) +` +} + +async function installEchoProbe(page: Page, runId: string): Promise { + await page.evaluate( + ({ runId, expectedKeys }) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane || typeof pane.terminal.onWriteParsed !== 'function') { + throw new Error('Dense-SGR echo probe requires an active xterm with onWriteParsed') + } + + const terminal = pane.terminal + const pending: { index: number; startedAt: number; marker: string }[] = [] + const samples: EchoSample[] = [] + const scheduledMarkers = new Set() + let keysObserved = 0 + let parseEvents = 0 + let writeTail = '' + const originalWrite = terminal.write + terminal.write = ((data: string, callback?: () => void) => { + const searchable = writeTail + data + const observed = pending.filter( + ({ marker }) => !scheduledMarkers.has(marker) && searchable.includes(marker) + ) + for (const { marker } of observed) { + scheduledMarkers.add(marker) + } + writeTail = searchable.slice(-256) + originalWrite.call(terminal, data, () => { + const parsedAt = performance.now() + for (const item of observed) { + const pendingIndex = pending.findIndex(({ marker }) => marker === item.marker) + if (pendingIndex < 0) { + continue + } + pending.splice(pendingIndex, 1) + samples.push({ index: item.index, latencyMs: parsedAt - item.startedAt }) + } + callback?.() + }) + }) as typeof terminal.write + + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key.length !== 1 || keysObserved >= expectedKeys) { + return + } + const index = keysObserved + keysObserved += 1 + pending.push({ + index, + startedAt: performance.now(), + marker: `__ORCA_ECHO_${runId}_${index + 1}__` + }) + } + + const parsedDisposable = terminal.onWriteParsed(() => { + parseEvents += 1 + }) + + window.addEventListener('keydown', onKeyDown, { capture: true }) + const target = window as EchoProbeWindow + target.__denseSgrEchoProbe = { + report: () => ({ samples: [...samples], keysObserved, parseEvents }), + dispose: () => { + window.removeEventListener('keydown', onKeyDown, { capture: true }) + parsedDisposable.dispose() + terminal.write = originalWrite + } + } + }, + { runId, expectedKeys: TYPED.length } + ) +} + +function percentile(values: number[], quantile: number): number { + const sorted = [...values].sort((left, right) => left - right) + const index = Math.max(0, Math.min(sorted.length - 1, Math.ceil(sorted.length * quantile) - 1)) + return sorted[index] ?? 0 +} + +for (const mode of ['plain', 'dense-sgr'] as const) { + test(`${mode} flood keydown-to-parse latency @diagnostic`, async ({ + orcaPage, + testRepoPath + }, testInfo) => { + test.skip(process.env.ORCA_DIAG !== '1', 'Set ORCA_DIAG=1 to run the latency diagnostic') + + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const ptyId = await waitForActivePanePtyId(orcaPage) + const runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-dense-sgr-${runId}.mjs`) + writeFileSync(scriptPath, outputFloodScript(runId, mode)) + + try { + await orcaPage.evaluate(async () => { + await window.api.pty.resetRendererDeliveryDebug() + ;(window as EchoProbeWindow).__terminalOutputSchedulerDebug?.reset() + }) + await installEchoProbe(orcaPage, runId) + await sendToTerminal(orcaPage, ptyId, `${nodeTerminalCommand([scriptPath])}\r`) + await waitForTerminalOutput(orcaPage, `DENSE_SGR_READY_${runId}`, 10_000) + await focusActiveTerminalInput(orcaPage) + await orcaPage.waitForTimeout(2_200) + + for (const char of TYPED) { + await orcaPage.keyboard.type(char) + await orcaPage.waitForTimeout(KEY_INTERVAL_MS) + } + await orcaPage.waitForTimeout(250) + await sendToTerminal(orcaPage, ptyId, '\x03') + + let completionError: unknown + try { + await expect + .poll( + () => + orcaPage.evaluate( + () => (window as EchoProbeWindow).__denseSgrEchoProbe?.report().samples.length ?? 0 + ), + { timeout: ECHO_TIMEOUT_MS } + ) + .toBe(TYPED.length) + } catch (error) { + completionError = error + } + + const result = await orcaPage.evaluate(async () => { + const probe = (window as EchoProbeWindow).__denseSgrEchoProbe + if (!probe) { + throw new Error('Dense-SGR echo probe was not installed') + } + const report = probe.report() + probe.dispose() + return { + report, + scheduler: (window as EchoProbeWindow).__terminalOutputSchedulerDebug?.snapshot() ?? null, + main: await window.api.pty.getRendererDeliveryDebugSnapshot() + } + }) + const latencies = result.report.samples.map((sample) => sample.latencyMs) + const observedIndexes = new Set(result.report.samples.map((sample) => sample.index)) + const missingIndexes = [...TYPED].flatMap((_char, index) => + observedIndexes.has(index) ? [] : [index] + ) + const summary = + `mode=${mode} p50=${percentile(latencies, 0.5).toFixed(1)}ms ` + + `p95=${percentile(latencies, 0.95).toFixed(1)}ms max=${Math.max(...latencies).toFixed(1)}ms ` + + `samples=${latencies.length} missing=${missingIndexes.join(',') || 'none'}` + console.log(`[dense-sgr-echo] ${summary}`) + const scheduler = result.scheduler as + | (Record & { drainWrites?: number[] }) + | null + const schedulerSummary = scheduler + ? { + ...scheduler, + drainWrites: undefined, + drainCount: scheduler.drainWrites?.length ?? 0, + maxWritesPerDrain: Math.max(0, ...(scheduler.drainWrites ?? [])) + } + : null + console.log(`[dense-sgr-echo] scheduler=${JSON.stringify(schedulerSummary)}`) + console.log(`[dense-sgr-echo] main=${JSON.stringify(result.main)}`) + testInfo.annotations.push({ type: 'dense-sgr-echo-latency', description: summary }) + if (completionError) { + throw completionError + } + } finally { + await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined) + rmSync(scriptPath, { force: true }) + } + }) +}