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 490e29b73f9..1c9e56fa58d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -346,7 +346,8 @@ function createPane(paneId: number) { type: 'normal' as const, viewportY: 0, baseY: 0, - cursorY: 0 + cursorY: 0, + cursorX: 0 } return { id: paneId, @@ -6693,6 +6694,7 @@ describe('connectPanePty', () => { async function startInFlightRestore(): Promise<{ pane: ReturnType + transport: MockTransport dataCallback: ( data: string, meta?: { seq?: number; rawLength?: number; droppedOutput?: boolean } @@ -6707,7 +6709,7 @@ describe('connectPanePty', () => { }> { enableMainAuthority() const deps = createDeps({ isVisibleRef: { current: true } }) - const { pane, dataCallback } = await connectHiddenPane(deps) + const { pane, transport, dataCallback } = await connectHiddenPane(deps) const transportOptions = createdTransportOptions.at(-1) as { onPtySpawn?: (ptyId: string) => void } @@ -6735,6 +6737,7 @@ describe('connectPanePty', () => { expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) return { pane, + transport, dataCallback, getMainBufferSnapshot, resolveFirstSnapshot: (snapshot) => firstSnapshot.resolve(snapshot) @@ -6779,11 +6782,12 @@ describe('connectPanePty', () => { }) it('salvages stateful queries out of an overflowing restore queue', async () => { - const { pane, dataCallback } = await startInFlightRestore() + const { pane, transport, dataCallback } = await startInFlightRestore() // Queue 400KB, then a chunk that overflows the cap and carries a DSR - // probe. The content is discarded (snapshot owns it) but the query - // must reach xterm so its reply flows. + // probe. The content is discarded (snapshot owns it) but the probe's + // reply is SYNTHESIZED directly — replaying it into xterm would race + // the restore's discard and the replay guard's auto-reply swallow. dataCallback('a'.repeat(400 * 1024), { seq: 400 * 1024, rawLength: 400 * 1024 }) dataCallback(`${'b'.repeat(200 * 1024)}\x1b[6n`, { seq: 600 * 1024 + 4, @@ -6791,8 +6795,10 @@ describe('connectPanePty', () => { }) await flushAsyncTicks(8) + const replies = transport.sendInput.mock.calls.map((call) => String(call[0])) + // oxlint-disable-next-line no-control-regex -- the ESC byte IS the payload: this matches the CPR reply + expect(replies.some((reply) => /^\u001b\[\d+;\d+R$/.test(reply))).toBe(true) const written = writtenFloodData(pane) - expect(written).toContain('\x1b[6n') expect(written).not.toContain('aaaa') expect(written).not.toContain('bbbb') }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 7c7ef31afdb..7eed5f74249 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -144,6 +144,7 @@ import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal- import { scheduleTerminalWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' import { CONPTY_DA1_RESPONSE, + DEFAULT_DA1_RESPONSE, createTerminalPixelSizeQueryResponder, installTerminalCapabilityReplyHandlers, sendTerminalOscColorQueryReplies @@ -4591,10 +4592,13 @@ export function connectPanePty( // Why: discarding queued flood bytes must never swallow terminal queries — // a lost DSR/CPR (or color/DA) reply hangs the querying program (the bench - // DSR timeout). The discarded CONTENT is owned by the snapshot repaint; - // the queries are re-played to xterm immediately so replies still flow. - // Only called for bytes that are being thrown away, so replies cannot - // double-fire against a later queue drain. + // DSR timeout). The discarded CONTENT is owned by the snapshot repaint. + // Replies are SYNTHESIZED directly (transport.sendInput) instead of + // replaying the queries into xterm: a drop always triggers a snapshot + // restore, whose replay guard swallows xterm auto-replies and whose + // discardTerminalOutput races away queued query writes — both killed the + // salvaged reply in practice. Only called for bytes being thrown away, so + // replies cannot double-fire against a later queue drain. function salvageRendererQueriesFromDiscardedRestoreData(data: string): void { if (!data || !data.includes('\x1b')) { return @@ -4605,10 +4609,43 @@ export function connectPanePty( transport.sendInput(reply) ) } - const queryData = extracted.statelessQueryData + extracted.statefulQueryData - if (queryData) { - writePtyOutputToXterm(queryData, true, { hiddenStartupRendererQuery: true }) + let unansweredQueryData = '' + for (const sequence of splitCsiSequences( + extracted.statefulQueryData + extracted.statelessQueryData + )) { + if (sequence === '\x1b[6n') { + // CPR from the live buffer. Position may be mid-repaint stale — in a + // drop scenario positional accuracy is already forfeit; liveness is + // the contract (a blocked reader must unblock). + const buffer = pane.terminal.buffer.active + const row = Math.min(buffer.cursorY + 1, pane.terminal.rows) + const col = Math.min(buffer.cursorX + 1, pane.terminal.cols) + transport.sendInput(`\x1b[${row};${col}R`) + } else if (sequence === '\x1b[c' || sequence === '\x1b[0c') { + transport.sendInput(DEFAULT_DA1_RESPONSE) + } else { + unansweredQueryData += sequence + } } + if (unansweredQueryData) { + // Best-effort for the rarer queries (DECRQM, DA2, XTVERSION): replay + // into xterm and let its handlers answer when no replay is active. + writePtyOutputToXterm(unansweredQueryData, true, { hiddenStartupRendererQuery: true }) + } + } + + function splitCsiSequences(queryData: string): string[] { + const sequences: string[] = [] + let offset = queryData.indexOf('\x1b[') + while (offset !== -1) { + const finalByteIndex = findCsiFinalByteIndex(queryData, offset + 2) + if (finalByteIndex === -1) { + break + } + sequences.push(queryData.slice(offset, finalByteIndex + 1)) + offset = queryData.indexOf('\x1b[', finalByteIndex + 1) + } + return sequences } function queueLiveChunkDuringRestore(data: string, meta?: PtyDataMeta): void {