mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
fix(terminal): keep input responsive during dense SGR output
Co-authored-by: 0668000615 <wen.xiaosong@xydigit.com>
This commit is contained in:
@@ -7,6 +7,7 @@ export type PendingPtyData = {
|
||||
transformed?: true
|
||||
containsBackgroundOutput?: boolean
|
||||
droppedOutput?: true
|
||||
denseSgrDrop?: true
|
||||
droppedMode2031Data?: string
|
||||
droppedMode2031ScanState?: Mode2031ReplyScanState
|
||||
projectionAdmissionIds?: readonly string[]
|
||||
|
||||
+257
-1
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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__')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user