fix(terminal): make pane recovery strictly best-effort in timer contexts

Recovery fires from stall-watch timers, replay-guard releases, and onData —
contexts where a throw becomes an unhandled error (CI verify caught this:
pty-connection.test.ts mocks a partial store, other tests advance fake
timers past the stall window, and the certification path hit a missing
remountTerminalTabForRecovery). Guard the store action call and the
ptyIdsByTabId reads so a partial surface yields a false return, never a
throw, and pin it with a regression test.
This commit is contained in:
Brennan Benson
2026-07-13 17:53:02 -07:00
parent b04493b8d2
commit c2564ba7a2
3 changed files with 24 additions and 3 deletions
@@ -3337,7 +3337,7 @@ export function connectPanePty(
if (transport.isConnected() && transport.getPtyId() !== null) {
return
}
const storePtyId = useAppStore.getState().ptyIdsByTabId[deps.tabId]?.[0] ?? null
const storePtyId = useAppStore.getState().ptyIdsByTabId?.[deps.tabId]?.[0] ?? null
void requestTerminalPaneRecovery({
tabId: deps.tabId,
ptyId: transport.getPtyId() ?? storePtyId,
@@ -3351,7 +3351,7 @@ export function connectPanePty(
const unregisterUndeliverableWriteHandler = registerUndeliverableWriteHandler(
pane.terminal,
(reason) => {
const storePtyId = useAppStore.getState().ptyIdsByTabId[deps.tabId]?.[0] ?? null
const storePtyId = useAppStore.getState().ptyIdsByTabId?.[deps.tabId]?.[0] ?? null
void requestTerminalPaneRecovery({
tabId: deps.tabId,
ptyId: transport.getPtyId() ?? storePtyId,
@@ -149,6 +149,19 @@ describe('requestTerminalPaneRecovery', () => {
expect(mocks.remountTerminalTabForRecovery).not.toHaveBeenCalled()
})
it('never throws when the store surface is partial (timer/callback contexts)', async () => {
// Regression: recovery fires from stall-watch timers and write callbacks;
// an environment with a partial store (mocked suites, teardown races) must
// get a false return, not an unhandled TypeError.
mocks.remountTerminalTabForRecovery.mockImplementation(() => {
throw new TypeError('remountTerminalTabForRecovery is not a function')
})
await expect(
requestTerminalPaneRecovery({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'write-stalled' })
).resolves.toBe(false)
})
it('does not consume budget when the tab no longer exists', async () => {
mocks.remountTerminalTabForRecovery.mockReturnValue(false)
@@ -78,7 +78,15 @@ export async function requestTerminalPaneRecovery(request: RecoveryRequest): Pro
return false
}
}
const remounted = useAppStore.getState().remountTerminalTabForRecovery(request.tabId)
let remounted = false
try {
remounted = useAppStore.getState().remountTerminalTabForRecovery(request.tabId)
} catch {
// Why: recovery fires from timer and write-callback contexts (stall watch,
// replay guard, onData) — it is best-effort by contract and must never
// surface a throw there (partial store surfaces in tests, teardown races).
return false
}
if (!remounted) {
return false
}