Synthesize salvaged query replies directly instead of replaying into xterm

The 10MB dev bench proved the write-back salvage insufficient: a
pending-cap drop always triggers a snapshot restore, whose replay guard
swallows xterm auto-replies and whose discardTerminalOutput races away
still-queued query writes — the salvaged DSR died both ways and the
fence still timed out.

Salvage now answers directly on the input path (immune to both): CPR
(CSI 6n) from the live buffer via transport.sendInput, DA1 with the
renderer's canned response, OSC color probes via the existing direct
responder. Rare queries (DECRQM, DA2) keep the best-effort xterm
replay.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-03 23:06:31 -04:00
co-authored by Orca
parent 8e2faedcec
commit fe2c9b42c3
2 changed files with 56 additions and 13 deletions
@@ -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<typeof createPane>
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')
})
@@ -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 {