fix(terminal): require quiet parser before recovery

This commit is contained in:
Jinjing
2026-07-25 16:16:13 -07:00
parent 2cfb7ac411
commit 8019eb7701
4 changed files with 76 additions and 16 deletions
@@ -4803,6 +4803,30 @@ describe('connectPanePty', () => {
binding.dispose()
})
it('does not arm the wedge probe when acknowledged input is rejected', async () => {
vi.useFakeTimers()
const { connectPanePty } = await import('./pty-connection')
const { WRITE_PIPELINE_STALL_CHECK_MS } =
await import('@/lib/pane-manager/terminal-write-pipeline-health')
const transport = createMockTransport('pty-rejected')
transport.sendInputAccepted = vi.fn().mockResolvedValue(false)
transportFactoryQueue.push(transport)
const pane = createPane(1)
const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
await flushAsyncTicks()
pane.terminal.write.mockClear()
pane.terminal.write.mockImplementation(() => {})
sendTerminalInputThroughPane(pane, '\x03')
await flushAsyncTicks()
vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 2)
await flushAsyncTicks()
expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03')
expect(pane.terminal.write).not.toHaveBeenCalledWith('', expect.any(Function))
binding.dispose()
})
it('blocks input when tab-level ptyId is stale even if panePtyId is null', async () => {
const { connectPanePty } = await import('./pty-connection')
@@ -3731,12 +3731,12 @@ export function connectPanePty(
cancelSuspendedShellCommandInference()
}
clearPendingTerminalInputIntent()
markTerminalInputSent()
const writePromise = transport
.sendInputAccepted(data)
.then((accepted) => {
if (accepted) {
recordTerminalInputForHibernationFallback()
// Why: rejected writes use transport recovery and must not arm a parser probe.
markAcceptedTerminalInputSent()
observeAcceptedShellCommandInput(data)
observeAcceptedTerminalInput(data, acknowledgedIntent)
interruptInference.observeInputIntent(acknowledgedIntent)
@@ -236,6 +236,32 @@ describe('terminal write pipeline health', () => {
_resetWritePipelineHealthForTests(terminal)
})
it('keeps a requested probe alive while the pipeline makes parse progress', () => {
vi.useFakeTimers()
const terminal = makeTerminal()
terminal.dropCallbacks = true
const handler = vi.fn()
registerUndeliverableWriteHandler(terminal, handler)
requestTerminalWritePipelineProbe(terminal)
vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS)
vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS / 2)
settleTerminalWriteStallWatch(terminal)
vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS / 2)
vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS / 2)
settleTerminalWriteStallWatch(terminal)
vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS / 2)
expect(handler).not.toHaveBeenCalled()
expect(isTerminalWritePipelineCertifiedDead(terminal)).toBe(false)
vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS)
expect(handler).toHaveBeenCalledWith('write-stalled')
expect(isTerminalWritePipelineCertifiedDead(terminal)).toBe(true)
_resetWritePipelineHealthForTests(terminal)
})
it('notifies at most once per terminal instance', () => {
const terminal = makeTerminal()
const handler = vi.fn()
@@ -6,10 +6,11 @@
// delivery ack credits leak, and the pane becomes a fossil the user can only
// cure by reloading the window. Detection here is probe-certified (mirroring
// replay-guard.ts): a stalled completion triggers an empty probe write; xterm
// parses in FIFO order, so a probe that also never completes proves the
// pipeline is dead rather than slow. Certification notifies a per-terminal
// handler (registered by the pane's PTY connection) that requests pane
// recovery — a remount that rebuilds the xterm and reattaches the live PTY.
// parses in FIFO order, so a completing probe proves preceding work drained. A
// silent probe is only certified dead after a full interval without any parse
// progress. Certification notifies a per-terminal handler (registered by the
// pane's PTY connection) that requests pane recovery — a remount that rebuilds
// the xterm and reattaches the live PTY.
type WriteTarget = {
write(data: string, callback?: () => void): void
@@ -109,9 +110,9 @@ export function isTerminalWritePipelineCertifiedDead(terminal: object): boolean
* Arm (or keep armed) the stall watch for a terminal that just had a write
* issued. Cleared by settleTerminalWriteStallWatch from the write-completion
* callback. If the completion never arrives, an empty probe write certifies
* dead-vs-slow exactly like replay-guard.ts: probe completes → pipeline is
* alive (slow parse), re-arm and keep waiting; probe silent for another
* interval → dead, notify.
* dead-vs-slow conservatively like replay-guard.ts: probe completes → pipeline
* is alive (slow parse), re-arm and keep waiting; probe silent for another
* interval without any parse progress → dead, notify.
*/
function armTerminalWritePipelineWatch(
terminal: WriteTarget,
@@ -138,14 +139,25 @@ function armTerminalWritePipelineWatch(
timer: setTimeout(probeForStall, stallCheckMs)
}
const certifyDead = (): void => certifyTerminalWritePipelineDead(terminal, watch)
function armProbeQuietWindow(quietSinceGeneration: number): void {
watch.timer = setTimeout(() => {
if (stallWatchByTerminal.get(terminal) !== watch) {
return
}
if (hasTerminalParseProgressSince(terminal, quietSinceGeneration)) {
armProbeQuietWindow(captureTerminalParseProgressGeneration(terminal))
return
}
certifyDead()
}, stallCheckMs)
}
function probeForStall(): void {
if (stallWatchByTerminal.get(terminal) !== watch) {
return
}
let probeParsed = false
const probeQueuedAtGeneration = captureTerminalParseProgressGeneration(terminal)
try {
terminal.write('', () => {
probeParsed = true
// Why: replay guards share this terminal-scoped generation; even an
// auxiliary FIFO probe proves the parser is alive and making progress.
recordTerminalParseProgress(terminal)
@@ -161,11 +173,9 @@ function armTerminalWritePipelineWatch(
certifyDead()
return
}
watch.timer = setTimeout(() => {
if (!probeParsed) {
certifyDead()
}
}, stallCheckMs)
if (stallWatchByTerminal.get(terminal) === watch) {
armProbeQuietWindow(probeQueuedAtGeneration)
}
}
stallWatchByTerminal.set(terminal, watch)
}