fix(terminal): keep input responsive during dense SGR output

Co-authored-by: 0668000615 <wen.xiaosong@xydigit.com>
This commit is contained in:
Neil
2026-08-04 21:08:21 -07:00
co-authored by 0668000615
parent 9507cbce0c
commit f0c927f03d
18 changed files with 1282 additions and 78 deletions
@@ -7,6 +7,7 @@ export type PendingPtyData = {
transformed?: true
containsBackgroundOutput?: boolean
droppedOutput?: true
denseSgrDrop?: true
droppedMode2031Data?: string
droppedMode2031ScanState?: Mode2031ReplyScanState
projectionAdmissionIds?: readonly string[]
+257 -1
View File
@@ -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()
})
+163 -39
View File
@@ -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<string, { cols: number; rows: number }>()
// 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<string, number>()
const interactiveOutputCharsByPty = new Map<string, number>()
const interactiveDenseProtectionPtys = new Set<string>()
const interactiveDenseRestoreMarkedPtys = new Set<string>()
const activeRendererPtys = new Set<string>()
const visibleRendererPtys = new Set<string>()
const rendererVisibilityKnownPtys = new Set<string>()
@@ -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)
}
+2 -2
View File
@@ -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<boolean>
write: (id: string, data: string, rendererBacklogDropped?: true) => void
writeAccepted: (id: string, data: string, rendererBacklogDropped?: true) => Promise<boolean>
onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void
resize: (id: string, cols: number, rows: number) => void
claimViewport: (id: string, cols: number, rows: number) => void
+12 -4
View File
@@ -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<boolean> =>
ipcRenderer.invoke('pty:writeAccepted', { id, data }),
writeAccepted: (id: string, data: string, rendererBacklogDropped?: true): Promise<boolean> =>
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)
@@ -11469,7 +11469,10 @@ describe('connectPanePty', () => {
return window.api.pty.setHiddenRendererPty as unknown as ReturnType<typeof vi.fn>
}
async function connectHiddenPane(deps: ReturnType<typeof createDeps>): Promise<{
async function connectHiddenPane(
deps: ReturnType<typeof createDeps>,
terminalElement?: ReturnType<typeof createKeyboardEventTarget>['target']
): Promise<{
transport: MockTransport
pane: ReturnType<typeof createPane>
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 } })
@@ -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<typeof setTimeout> | null = null
let interactiveDropRestoreTimer: ReturnType<typeof setTimeout> | 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?.()
@@ -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()
@@ -12,20 +12,21 @@ export const TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS = 4096
type PendingPtyInputWrite = {
id: string
text: string
rendererBacklogDropped: boolean
tooLarge: boolean | Promise<boolean>
chunks?: Iterator<string>
nextChunk?: string
}
export type PtyInputWriteQueue = {
enqueue: (id: string, data: string) => boolean
enqueue: (id: string, data: string, rendererBacklogDropped?: boolean) => boolean
waitForDrain: () => Promise<void>
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<void>
}
@@ -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 {
@@ -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<boolean>
sendInputAccepted?: (data: string, rendererBacklogDropped?: boolean) => Promise<boolean>
claimViewport?: (cols: number, rows: number) => boolean
/** Capability-negotiated paired-runtime delivery gate; false preserves legacy delivery. */
setOutputPaused?: (paused: boolean) => boolean
@@ -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 {
@@ -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<boolean> {
async function writeAcceptedPtyInput(
id: string,
data: string,
rendererBacklogDropped = false
): Promise<boolean> {
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<boolean> {
async sendInputAccepted(data: string, rendererBacklogDropped = false): Promise<boolean> {
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)
}
}),
@@ -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()
@@ -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<TerminalOutputTarget, QueueEntry>()
const denseParseBlockedTerminals = new WeakSet<TerminalOutputTarget>()
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.
+8 -1
View File
@@ -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
}
+48
View File
@@ -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__')
})
})
+53
View File
@@ -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`
}
@@ -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<void> {
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<string>()
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<string, unknown> & { 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 })
}
})
}