From 8019eb770121c59f89f704ed032db8d7df4ede55 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:16:13 -0700 Subject: [PATCH] fix(terminal): require quiet parser before recovery --- .../terminal-pane/pty-connection.test.ts | 24 ++++++++++++ .../terminal-pane/pty-connection.ts | 4 +- .../terminal-write-pipeline-health.test.ts | 26 +++++++++++++ .../terminal-write-pipeline-health.ts | 38 ++++++++++++------- 4 files changed, 76 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index c59a0cbe1da..ab40d22466b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -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') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 76c11c040a5..df1e39e6ebb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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) diff --git a/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts b/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts index b8299c6652a..f88cbe02fde 100644 --- a/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts @@ -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() diff --git a/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.ts b/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.ts index df1e231fda5..5c67e7138ea 100644 --- a/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.ts +++ b/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.ts @@ -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) }