From 033a2a64e17f97d7a69bcaba7f1e21cf5fb82346 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:14:40 -0700 Subject: [PATCH] fix(remote-runtime): make every advertised recovery attempt reachable, and stop two recovery latches (#17822) * fix(remote-runtime): derive the recovery budget and stop faking a spent window #11305: RECOVERY_DELAYS_MS summed to 60,750ms against a hand-written REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS of 60,000ms, so the ladder's tail was unreachable. Derive the deadline from the schedule plus one RPC timeout per step so a half-open link can actually reach every backoff step, and pin the relation with a test that fails if the sum ever outgrows the budget. #12683: markDisconnected() is a UI latch, not proof the auto-recovery window ran out. Track deadline expiry on the recovery state and only let that license the same-handle reattach that bypasses require-replacement fencing. #12684: a recoverable connect() failure latched 'disconnected' with no armed retry, no parked retry and a Reconnect button that returned false. Schedule a bounded retry (which the deadline parks for online/resume) and let the button fire a parked retry. * fix(remote-runtime): stop a post-latch connect failure from re-arming the recovery window The last attempt's RPC budget expires at the same instant as the deadline, so a silently dropped link rejects after phase latched to 'disconnected'. begin() then started a fresh full-length window, so the budget never actually expired. Park the retry under the latched epoch instead, which keeps online/resume/Reconnect armed even when the deadline lands mid-attempt with nothing scheduled. Also fences the same-handle end-reuse window on its own 60s constant so the derived recovery budget no longer silently triples an unrelated stale-handle check. * fix(i18n): restore the activity-options key the rebase dropped * fix(i18n): union en.json with main so the rebase cannot drop keys --- ...minalRemoteRuntimeReconnectBanner.test.tsx | 2 +- .../TerminalRemoteRuntimeReconnectBanner.tsx | 2 +- ...e-runtime-connect-failure-recovery.test.ts | 160 ++++++++++++++++++ ...ge-toast-flood-and-stuck-reconnect.test.ts | 5 +- ...mote-runtime-pty-deadline-reattach.test.ts | 67 +++++++- ...runtime-pty-latched-pane-retention.test.ts | 15 +- .../remote-runtime-pty-recovery-state.test.ts | 70 +++++++- .../remote-runtime-pty-recovery-state.ts | 47 ++++- ...-transport-create-outcome-recovery.test.ts | 11 +- ...ty-transport-stale-handle-recovery.test.ts | 5 +- ...ransport-sticky-replacement-policy.test.ts | 3 +- ...ime-pty-transport-stream-reconnect.test.ts | 3 +- ...-pty-transport-web-mirror-recovery.test.ts | 15 +- .../remote-runtime-pty-transport.ts | 57 ++++++- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- 18 files changed, 426 insertions(+), 44 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-connect-failure-recovery.test.ts diff --git a/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx b/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx index 70341d7a4f6..824a6a80744 100644 --- a/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx @@ -17,7 +17,7 @@ describe('TerminalRemoteRuntimeReconnectBanner', () => { render() expect(screen.getByText('Reconnecting to remote runtime')).toBeInTheDocument() - expect(screen.getByText(/retry for up to one minute/)).toBeInTheDocument() + expect(screen.getByText(/retrying automatically/)).toBeInTheDocument() expect(screen.queryByRole('button')).not.toBeInTheDocument() }) diff --git a/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.tsx b/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.tsx index c629fa71a36..1cf0fee0903 100644 --- a/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.tsx @@ -50,7 +50,7 @@ export function TerminalRemoteRuntimeReconnectBanner({ {retrying ? translate( 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.retryingBody', - 'Orca will retry for up to one minute. This terminal will resume if the connection returns.' + 'Orca is retrying automatically. This terminal will resume if the connection returns.' ) : translate( 'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.disconnectedBody', diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-connect-failure-recovery.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-connect-failure-recovery.test.ts new file mode 100644 index 00000000000..6c12277f2c0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-connect-failure-recovery.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createRemoteRuntimeTransportMocks, + type MultiplexSubscriptionCallbacks +} from './remote-runtime-pty-transport-test-harness' +import { + REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS, + REMOTE_RUNTIME_RECOVERY_ATTEMPT_BUDGET_MS +} from './remote-runtime-pty-recovery-state' + +let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null +let resolvedPaneHandle = 'terminal-1' + +const { runtimeCall, resetRemoteRuntimeTransport } = createRemoteRuntimeTransportMocks({ + getCallbacks: () => subscriptionCallbacks, + setCallbacks: (callbacks) => { + subscriptionCallbacks = callbacks + }, + getResolvedPaneHandle: () => resolvedPaneHandle, + setResolvedPaneHandle: (handle) => { + resolvedPaneHandle = handle + } +}) + +// #12684: connect() classified these failures as recoverable and then latched 'disconnected' with +// nothing armed — no backoff timer, no parked retry, and a Reconnect button that returned false. +describe('recoverable connect failures on a remote runtime pane', () => { + let resolvePaneCalls = 0 + + function installUnreachableRuntime(): void { + resolvePaneCalls = 0 + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (args.method === 'terminal.resolvePane') { + resolvePaneCalls += 1 + } + throw Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + }) + } + + // Why: installUnreachableRuntime() rejects synchronously, so every failure lands during a backoff + // wait. A silently dropped link instead burns the whole RPC budget, so the rejection arrives while + // the attempt is still in flight — including after the auto-recovery deadline has already latched. + function installSilentlyDroppedRuntime(): void { + resolvePaneCalls = 0 + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (args.method === 'terminal.resolvePane') { + resolvePaneCalls += 1 + } + await new Promise((resolve) => { + setTimeout(resolve, REMOTE_RUNTIME_RECOVERY_ATTEMPT_BUDGET_MS) + }) + throw Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + }) + } + + beforeEach(() => { + resetRemoteRuntimeTransport() + }) + + it('keeps retrying a recoverable connect failure instead of latching immediately', async () => { + vi.useFakeTimers() + try { + installUnreachableRuntime() + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + await transport.connect({ + url: '', + sessionId: 'remote:env-1@@', + callbacks: { onError } + }) + + // Loss of contact is unverifiable, not a dead terminal: automatic recovery must still be running. + expect(resolvePaneCalls).toBe(1) + expect(transport.getRecoveryState?.().phase).toBe('backoff') + expect(onError).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1_000) + expect(resolvePaneCalls).toBeGreaterThan(1) + + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('leaves both revival paths armed once the recovery window is spent', async () => { + vi.useFakeTimers() + try { + installUnreachableRuntime() + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + // Why dynamic: resetRemoteRuntimeTransport() re-registers the module graph, and the retry + // registry only sees panes from the same instance the transport was loaded from. + const { retryAllRemoteRuntimePtyRecoveriesNow } = + await import('./remote-runtime-pty-recovery-state') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + await transport.connect({ url: '', sessionId: 'remote:env-1@@', callbacks: {} }) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 1_000) + + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + const callsAtCutoff = resolvePaneCalls + + // The cutoff stops self-initiated retries only; online/resume must still find a parked retry. + expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(1) + await vi.advanceTimersByTimeAsync(1_000) + expect(resolvePaneCalls).toBeGreaterThan(callsAtCutoff) + + // ...and so must the Reconnect button, which returned false before #12684. + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 1_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + expect(transport.retryRecovery?.()).toBe(true) + + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + it('keeps the window bounded when a silent drop fails after the deadline latched', async () => { + vi.useFakeTimers() + try { + installSilentlyDroppedRuntime() + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + void transport.connect({ url: '', sessionId: 'remote:env-1@@', callbacks: {} }) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS * 2) + + // The in-flight rejection must not begin a new epoch; that re-arms a full-length window forever. + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + const callsAtCutoff = resolvePaneCalls + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS * 2) + expect(resolvePaneCalls).toBe(callsAtCutoff) + + // A deadline that lands mid-attempt parks nothing, so the latch must still stay revivable. + expect(transport.retryRecovery?.()).toBe(true) + + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-outage-toast-flood-and-stuck-reconnect.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-outage-toast-flood-and-stuck-reconnect.test.ts index b797a0f5877..9ac752b2b31 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-outage-toast-flood-and-stuck-reconnect.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-outage-toast-flood-and-stuck-reconnect.test.ts @@ -26,6 +26,7 @@ import { import { RuntimeRpcCallQueueOverloadError } from '../../../../shared/runtime-rpc-call-queue' import { withRemoteRuntimeTailscaleHint } from '../../../../shared/remote-runtime-tailscale-hint' import type { PtyTransportRecoveryState } from './pty-transport-types' +import { REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS } from './remote-runtime-pty-recovery-state' const ELECTRON_IPC_PREFIX = "Error invoking remote method 'runtimeEnvironments:call': " @@ -409,7 +410,7 @@ describe('remote runtime outage: toast flood and stuck reconnect (issue3)', () = await vi.advanceTimersByTimeAsync(16_000) // Auto-recovery deadline latches the pane 'disconnected'. - await vi.advanceTimersByTimeAsync(60_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) expect(transport.getRecoveryState?.().phase).toBe('disconnected') // Connectivity restored; 'online'/system-resume trigger fires. @@ -417,7 +418,7 @@ describe('remote runtime outage: toast flood and stuck reconnect (issue3)', () = await vi.advanceTimersByTimeAsync(16_000) // Latch again, then the user clicks the Reconnect banner. - await vi.advanceTimersByTimeAsync(60_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) transport.retryRecovery?.() await vi.advanceTimersByTimeAsync(16_000) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts index 886516414ca..61758efb299 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts @@ -8,6 +8,7 @@ import { encodeTerminalStreamText } from '../../../../shared/terminal-stream-protocol' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS } from './remote-runtime-pty-recovery-state' describe('remote runtime pty reattach after the bounded recovery window', () => { const runtimeCall = vi.fn() @@ -198,7 +199,7 @@ describe('remote runtime pty reattach after the bounded recovery window', () => expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1) expect(transport.getRecoveryState?.().phase).not.toBe('disconnected') - await vi.advanceTimersByTimeAsync(50_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) expect(transport.getRecoveryState?.().phase).toBe('disconnected') // The cutoff must not tear down the accepted-snapshot listener; it is the only path back. expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1) @@ -227,7 +228,7 @@ describe('remote runtime pty reattach after the bounded recovery window', () => const { transport, onError } = await attachStalePane() const handleEvents = await import('../../runtime/web-session-terminal-handle-events') - await vi.advanceTimersByTimeAsync(66_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 6_000) expect(transport.getRecoveryState?.().phase).toBe('disconnected') const listCallsAtCutoff = hostListCalls @@ -277,7 +278,7 @@ describe('remote runtime pty reattach after the bounded recovery window', () => const { transport, onError } = await attachStalePane() const handleEvents = await import('../../runtime/web-session-terminal-handle-events') - await vi.advanceTimersByTimeAsync(66_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 6_000) expect(transport.getRecoveryState?.().phase).toBe('disconnected') expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1) const listCallsAtCutoff = hostListCalls @@ -310,7 +311,7 @@ describe('remote runtime pty reattach after the bounded recovery window', () => const { retryAllRemoteRuntimePtyRecoveriesNow } = await import('./remote-runtime-pty-recovery-state') - await vi.advanceTimersByTimeAsync(66_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 6_000) expect(transport.getRecoveryState?.().phase).toBe('disconnected') const listCallsAtCutoff = hostListCalls @@ -365,7 +366,7 @@ describe('remote runtime pty reattach after the bounded recovery window', () => callbacks: { onError: vi.fn() } }) - await vi.advanceTimersByTimeAsync(66_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 6_000) expect(transport.getRecoveryState?.().phase).toBe('disconnected') const callsBeforeRetry = runtimeCall.mock.calls.length @@ -378,4 +379,60 @@ describe('remote runtime pty reattach after the bounded recovery window', () => vi.useRealTimers() } }) + + // #12683: a fatal resubscribe latches the banner via markDisconnected(), but that latch is not + // evidence the auto-recovery window ran out, so it must not license reattaching a fenced handle. + it('does not reattach a fenced same handle when only a UI latch closed the window', async () => { + vi.useFakeTimers() + try { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-tab-1', + leafId: 'pane:1', + onPtyExit: vi.fn(), + onPtyRebind: vi.fn() + }) + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-stale', + cols: 80, + rows: 24, + callbacks: { onError: vi.fn() } + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + emitSnapshot(latestSubscribePayload().streamId, 'live before the drop') + + // The host keeps publishing the same handle, so no replacement can ever arrive. + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (args.method !== 'session.tabs.list') { + return { ok: true, result: {} } + } + hostListCalls += 1 + return { ok: true, result: hostSnapshot('terminal-stale', hostListCalls + 1, 'epoch-1') } + }) + // The stream drops and the resubscribe fails fatally with a stale handle: markDisconnected() + // latches the banner, then stale routing fences the handle with require-replacement. + // Only that one attempt fails, so a later reattach would succeed and be observable. + runtimeSubscribe.mockImplementationOnce(async () => { + throw new Error('terminal_handle_stale') + }) + subscriptionCallbacks?.onClose?.() + await vi.advanceTimersByTimeAsync(16_000) + expect(transport.getRecoveryState?.().phase).not.toBe('idle') + + const subscribesBeforeRepublish = subscribedTerminalHandles().length + handleEvents.queueAcceptedWebSessionTerminalSnapshot( + hostSnapshot('terminal-stale', 9, 'epoch-2'), + 'env-1' + ) + await vi.advanceTimersByTimeAsync(1_000) + + // The recovery deadline never fired, so the fenced handle stays fenced. + expect(subscribedTerminalHandles()).toHaveLength(subscribesBeforeRepublish) + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts index 5892ee2c19d..3c1ec0421a9 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts @@ -5,6 +5,7 @@ import { decodeTerminalStreamJson } from '../../../../shared/terminal-stream-protocol' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS } from './remote-runtime-pty-recovery-state' // Why: the recovery cutoff no longer tears down the retry registry entry or the accepted-snapshot // listener, so those two module-global collections are the only places a latched pane can accumulate. @@ -184,7 +185,7 @@ describe('remote runtime pty latched-pane retention', () => { for (let cycle = 0; cycle < 20; cycle += 1) { const transport = await attachStalePane(cycle) - await vi.advanceTimersByTimeAsync(66_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 6_000) expect(transport.getRecoveryState?.().phase).toBe('disconnected') latched.push(await registries()) transport.destroy?.() @@ -208,7 +209,7 @@ describe('remote runtime pty latched-pane retention', () => { const settled: { subscribers: number; scheduled: number }[] = [] for (let cycle = 0; cycle < 20; cycle += 1) { const transport = await attachStalePane(cycle) - await vi.advanceTimersByTimeAsync(66_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 6_000) expect(transport.getRecoveryState?.().phase).toBe('disconnected') transport.detach?.() await vi.advanceTimersByTimeAsync(1_000) @@ -227,7 +228,7 @@ describe('remote runtime pty latched-pane retention', () => { const transports: Awaited>[] = [] for (let pane = 0; pane < 8; pane += 1) { transports.push(await attachStalePane(pane)) - await vi.advanceTimersByTimeAsync(66_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 6_000) } // Retention is per live pane, not per timeout: eight latched panes hold eight of each. expect(await registries()).toEqual({ subscribers: 8, scheduled: 8 }) @@ -247,7 +248,7 @@ describe('remote runtime pty latched-pane retention', () => { vi.useFakeTimers() try { const transport = await attachStalePane(0) - await vi.advanceTimersByTimeAsync(66_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 6_000) expect(transport.getRecoveryState?.().phase).toBe('disconnected') const baseline = await registries() @@ -277,7 +278,7 @@ describe('remote runtime pty latched-pane retention', () => { const { retryAllRemoteRuntimePtyRecoveriesNow } = await import('./remote-runtime-pty-recovery-state') const transport = await attachStalePane(0) - await vi.advanceTimersByTimeAsync(66_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 6_000) expect(transport.getRecoveryState?.().phase).toBe('disconnected') const baseline = await registries() @@ -295,7 +296,7 @@ describe('remote runtime pty latched-pane retention', () => { // A second trigger in the same window must find nothing to advance, so an online/resume // storm cannot stack fresh recovery epochs on one pane. expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(0) - await vi.advanceTimersByTimeAsync(66_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 6_000) expect(transport.getRecoveryState?.().phase).toBe('disconnected') observed.push({ ...(await registries()), timers: vi.getTimerCount(), revived }) } @@ -325,7 +326,7 @@ describe('remote runtime pty latched-pane retention', () => { try { const handleEvents = await import('../../runtime/web-session-terminal-handle-events') const transport = await attachStalePane(0) - await vi.advanceTimersByTimeAsync(66_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 6_000) expect(transport.getRecoveryState?.().phase).toBe('disconnected') const baseline = await registries() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts index d71af0b9afb..c245c184436 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS, + REMOTE_RUNTIME_RECOVERY_ATTEMPT_BUDGET_MS, + REMOTE_RUNTIME_RECOVERY_DELAYS_MS, RemoteRuntimePtyRecoveryState, retryAllRemoteRuntimePtyRecoveriesNow } from './remote-runtime-pty-recovery-state' @@ -63,7 +65,7 @@ describe('RemoteRuntimePtyRecoveryState', () => { const epoch = state.begin() state.schedule(epoch, retry) - await vi.advanceTimersByTimeAsync(60_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) expect(state.currentPhase).toBe('disconnected') expect(state.isActive).toBe(false) @@ -112,7 +114,7 @@ describe('RemoteRuntimePtyRecoveryState', () => { const state = new RemoteRuntimePtyRecoveryState() const firstEpoch = state.begin() - await vi.advanceTimersByTimeAsync(60_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) const manualEpoch = state.begin() expect(manualEpoch).toBe(firstEpoch + 1) @@ -227,4 +229,68 @@ describe('RemoteRuntimePtyRecoveryState', () => { expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(0) state.dispose() }) + + // #11305: the schedule and the deadline lived as two independent literals and drifted apart. + it('keeps the backoff schedule inside the auto-recovery budget it arms', () => { + const scheduleSumMs = REMOTE_RUNTIME_RECOVERY_DELAYS_MS.reduce( + (total, delayMs) => total + delayMs, + 0 + ) + + expect(scheduleSumMs).toBeLessThanOrEqual(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) + // Every step also needs room for the attempt it leads into, or the tail is dead code + // whenever a half-open link makes each attempt burn its full RPC timeout. + expect( + scheduleSumMs + + REMOTE_RUNTIME_RECOVERY_DELAYS_MS.length * REMOTE_RUNTIME_RECOVERY_ATTEMPT_BUDGET_MS + ).toBeLessThanOrEqual(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) + }) + + it('reaches every backoff step when each attempt burns a full RPC timeout', async () => { + vi.useFakeTimers() + const state = new RemoteRuntimePtyRecoveryState() + const attemptStartsMs: number[] = [] + const epoch = state.begin() + const startedAt = Date.now() + + const failSlowly = (currentEpoch: number): void => { + attemptStartsMs.push(Date.now() - startedAt) + // Silent-drop reconnects do not fail instantly; they time out. + setTimeout(() => { + if (state.isCurrent(currentEpoch)) { + state.schedule(currentEpoch, failSlowly) + } + }, REMOTE_RUNTIME_RECOVERY_ATTEMPT_BUDGET_MS) + } + state.schedule(epoch, failSlowly) + + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) + + expect(attemptStartsMs.length).toBeGreaterThanOrEqual(REMOTE_RUNTIME_RECOVERY_DELAYS_MS.length) + expect(state.currentPhase).toBe('disconnected') + state.dispose() + }) + + // #12683: markDisconnected() is a UI latch, not proof the window ran out. + it('only reports the auto-recovery window spent when the deadline actually fired', async () => { + vi.useFakeTimers() + const state = new RemoteRuntimePtyRecoveryState() + const epoch = state.begin() + state.schedule(epoch, vi.fn()) + + state.markDisconnected() + expect(state.currentPhase).toBe('disconnected') + expect(state.autoRecoveryDeadlineExpired).toBe(false) + + const secondEpoch = state.begin() + state.schedule(secondEpoch, vi.fn()) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) + + expect(state.currentPhase).toBe('disconnected') + expect(state.autoRecoveryDeadlineExpired).toBe(true) + + state.markHealthy() + expect(state.autoRecoveryDeadlineExpired).toBe(false) + state.dispose() + }) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.ts index fbdd50e443f..002e1320398 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.ts @@ -1,5 +1,17 @@ -const RECOVERY_DELAYS_MS = [250, 500, 1000, 2000, 4000, 8000, 15_000, 30_000] as const -export const REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS = 60_000 +export const REMOTE_RUNTIME_RECOVERY_DELAYS_MS = [ + 250, 500, 1000, 2000, 4000, 8000, 15_000, 30_000 +] as const + +// Why: mirrors DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS in the main-process runtime router; a silently +// dropped link burns the whole RPC timeout on the attempt each backoff step leads into. +export const REMOTE_RUNTIME_RECOVERY_ATTEMPT_BUDGET_MS = 15_000 + +// Why derived, not hand-tuned: a literal deadline drifted below the ladder it arms, making the last +// backoff steps unreachable dead code (#11305). Loss of contact is never evidence of exit, so the +// window must outlast the schedule it advertises rather than the schedule being trimmed to fit. +export const REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS = + REMOTE_RUNTIME_RECOVERY_DELAYS_MS.reduce((total, delayMs) => total + delayMs, 0) + + REMOTE_RUNTIME_RECOVERY_DELAYS_MS.length * REMOTE_RUNTIME_RECOVERY_ATTEMPT_BUDGET_MS export type RemoteRuntimePtyRecoveryPhase = | 'idle' @@ -34,6 +46,9 @@ export class RemoteRuntimePtyRecoveryState { private deadlineTimer: ReturnType | null = null private pendingRetry: ((epoch: number) => void) | null = null private pendingEpoch: number | null = null + // Why: only the wall-clock deadline proves the auto-recovery window was actually spent; a UI latch + // via markDisconnected() must not forge that evidence (#12683). + private deadlineExpired = false constructor(private readonly onChange?: () => void) {} @@ -53,6 +68,10 @@ export class RemoteRuntimePtyRecoveryState { return this.attempt } + get autoRecoveryDeadlineExpired(): boolean { + return this.deadlineExpired + } + begin(): number { if (this.phase === 'disposed') { return this.epoch @@ -82,7 +101,10 @@ export class RemoteRuntimePtyRecoveryState { } this.clearRetryTimer() this.phase = 'backoff' - const delayMs = RECOVERY_DELAYS_MS[Math.min(this.attempt, RECOVERY_DELAYS_MS.length - 1)] + const delayMs = + REMOTE_RUNTIME_RECOVERY_DELAYS_MS[ + Math.min(this.attempt, REMOTE_RUNTIME_RECOVERY_DELAYS_MS.length - 1) + ] this.attempt += 1 this.pendingRetry = retry this.pendingEpoch = epoch @@ -107,11 +129,22 @@ export class RemoteRuntimePtyRecoveryState { // Why: a wait that ends with no liveness evidence arms no timer, so park a retry or online/resume/reconnect find nothing to revive. parkRetryForExternalTrigger(epoch: number, retry: (epoch: number) => void): boolean { - if (!this.isCurrent(epoch) || this.pendingRetry !== null) { + return this.isCurrent(epoch) && this.parkRetry(retry) + } + + // Why: the deadline can latch while an attempt is still in flight, before schedule() parked anything, + // so the late failure has no live epoch to join and must not begin a new one — that would re-arm a + // full-length window and the budget would never actually expire. + parkRetryAfterDeadline(retry: (epoch: number) => void): boolean { + return this.phase === 'disconnected' && this.parkRetry(retry) + } + + private parkRetry(retry: (epoch: number) => void): boolean { + if (this.pendingRetry !== null) { return false } this.pendingRetry = retry - this.pendingEpoch = epoch + this.pendingEpoch = this.epoch scheduledRecoveries.add(this) return true } @@ -152,6 +185,7 @@ export class RemoteRuntimePtyRecoveryState { if (this.phase === 'disposed') { return } + this.deadlineExpired = false this.clearTimers() this.phase = 'idle' this.attempt = 0 @@ -175,6 +209,7 @@ export class RemoteRuntimePtyRecoveryState { if (this.phase === 'disposed') { return } + this.deadlineExpired = false this.epoch += 1 this.clearTimers() this.phase = 'idle' @@ -191,11 +226,13 @@ export class RemoteRuntimePtyRecoveryState { private armDeadline(epoch: number): void { this.clearDeadlineTimer() + this.deadlineExpired = false const timer = setTimeout(() => { if (this.deadlineTimer !== timer || !this.isCurrent(epoch)) { return } this.deadlineTimer = null + this.deadlineExpired = true // Why: the cutoff stops self-initiated retries but must keep the pane revivable by online/resume/reconnect. this.stopRetryTimer() this.phase = 'disconnected' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts index b34ec4f3fea..59dc3612ed1 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts @@ -4,6 +4,7 @@ import { createRemoteRuntimeTransportMocks, type MultiplexSubscriptionCallbacks } from './remote-runtime-pty-transport-test-harness' +import { REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS } from './remote-runtime-pty-recovery-state' let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' @@ -81,7 +82,7 @@ describe('createRemoteRuntimePtyTransport', () => { let createCalls = 0 runtimeCall.mockImplementation(async (args: { method: string }) => { if (args.method === 'status.get') { - vi.setSystemTime(startedAt + 59_000) + vi.setSystemTime(startedAt + REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS - 1_000) return { ok: true, result: { capabilities: [TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY] } @@ -163,7 +164,7 @@ describe('createRemoteRuntimePtyTransport', () => { transport.destroy?.() }) - it('stops unknown terminal-create recovery after one minute and remains manually retryable', async () => { + it('stops unknown terminal-create recovery at the cutoff and remains manually retryable', async () => { vi.useFakeTimers() try { let reachable = false @@ -205,7 +206,7 @@ describe('createRemoteRuntimePtyTransport', () => { onRecoveryStateChange: (state) => recoveryStates.push(state.phase) } }) - await vi.advanceTimersByTimeAsync(60_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) await connect const callsAtCutoff = runtimeCall.mock.calls.length @@ -218,7 +219,7 @@ describe('createRemoteRuntimePtyTransport', () => { statusTimesOut = true expect(transport.retryRecovery?.()).toBe(true) - await vi.advanceTimersByTimeAsync(60_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) const callsAtManualCutoff = runtimeCall.mock.calls.length expect(transport.getRecoveryState?.().phase).toBe('disconnected') await vi.advanceTimersByTimeAsync(5 * 60_000) @@ -278,7 +279,7 @@ describe('createRemoteRuntimePtyTransport', () => { }) const connect = transport.connect({ url: '', callbacks: {} }) - await vi.advanceTimersByTimeAsync(60_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) await connect expect(transport.getRecoveryState?.().phase).toBe('disconnected') diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts index 8817d5c23e4..53c1921fe74 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts @@ -4,6 +4,7 @@ import { readyHostSessionInventoryResponse, type MultiplexSubscriptionCallbacks } from './remote-runtime-pty-transport-test-harness' +import { REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS } from './remote-runtime-pty-recovery-state' let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' @@ -72,7 +73,7 @@ describe('createRemoteRuntimePtyTransport', () => { expect(hostListCalls).toBe(callsAfterTwoWindows) expect(transport.getRecoveryState?.().phase).toBe('recovering') - await vi.advanceTimersByTimeAsync(9_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) expect(transport.getRecoveryState?.().phase).toBe('disconnected') expect(subscribedTerminalHandles()).toEqual(['terminal-stable']) transport.destroy?.() @@ -180,7 +181,7 @@ describe('createRemoteRuntimePtyTransport', () => { 'terminal-flapping' ]) expect(transport.isConnected()).toBe(false) - await vi.advanceTimersByTimeAsync(45_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) expect(transport.getRecoveryState?.().phase).toBe('disconnected') transport.destroy?.() } finally { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts index 48a24a2c084..5a61a5587e6 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts @@ -9,6 +9,7 @@ import { readyHostSessionInventoryResponse, type MultiplexSubscriptionCallbacks } from './remote-runtime-pty-transport-test-harness' +import { REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS } from './remote-runtime-pty-recovery-state' let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' @@ -160,7 +161,7 @@ describe('createRemoteRuntimePtyTransport', () => { expect(transport.isConnected()).toBe(false) expect(onPtyExit).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(44_001) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) expect(transport.getRecoveryState?.().phase).toBe('disconnected') expect(subscribedTerminalHandles()).toHaveLength(3) } finally { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts index 4ef1075e3f7..e0b85414e3f 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts @@ -9,6 +9,7 @@ import { createRemoteRuntimeTransportMocks, type MultiplexSubscriptionCallbacks } from './remote-runtime-pty-transport-test-harness' +import { REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS } from './remote-runtime-pty-recovery-state' let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' @@ -534,7 +535,7 @@ describe('createRemoteRuntimePtyTransport', () => { partitioned = true callbacksByConnection[0].onClose?.() - await vi.advanceTimersByTimeAsync(60_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) const disconnectedState = transport.getRecoveryState?.() const callsAtCutoff = runtimeSubscribe.mock.calls.length diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-web-mirror-recovery.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-web-mirror-recovery.test.ts index f8c07ea045d..784ed9d8768 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-web-mirror-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-web-mirror-recovery.test.ts @@ -3,6 +3,10 @@ import { createRemoteRuntimeTransportMocks, type MultiplexSubscriptionCallbacks } from './remote-runtime-pty-transport-test-harness' +import { + REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS, + REMOTE_RUNTIME_RECOVERY_DELAYS_MS +} from './remote-runtime-pty-recovery-state' let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' @@ -140,10 +144,11 @@ describe('createRemoteRuntimePtyTransport', () => { existingPtyId: 'remote:env-1@@stale-client-handle', callbacks: {} }) - await vi.advanceTimersByTimeAsync(60_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) const attemptsAtCutoff = runtimeSubscribe.mock.calls.length - expect(attemptsAtCutoff).toBe(8) + // Why: every backoff step must be reachable inside the window it arms (#11305). + expect(attemptsAtCutoff).toBeGreaterThanOrEqual(REMOTE_RUNTIME_RECOVERY_DELAYS_MS.length) expect(transport.getRecoveryState?.().phase).toBe('disconnected') await vi.advanceTimersByTimeAsync(5 * 60_000) @@ -235,7 +240,7 @@ describe('createRemoteRuntimePtyTransport', () => { await vi.advanceTimersByTimeAsync(250) expect(activateAttempts).toBe(2) - await vi.advanceTimersByTimeAsync(60_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) expect(transport.getRecoveryState?.().phase).toBe('disconnected') rejectInFlight( @@ -292,7 +297,7 @@ describe('createRemoteRuntimePtyTransport', () => { await vi.advanceTimersByTimeAsync(250) expect(runtimeSubscribe).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(60_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) expect(transport.getRecoveryState?.().phase).toBe('disconnected') rejectSubscription( @@ -349,7 +354,7 @@ describe('createRemoteRuntimePtyTransport', () => { expect.objectContaining({ method: 'terminal.resolvePane' }) ) - await vi.advanceTimersByTimeAsync(60_000) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) expect(transport.getRecoveryState?.().phase).toBe('disconnected') resolveMetadata({ diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 1e5782fe496..b1bd7336c86 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -90,6 +90,10 @@ const HOST_SESSION_POLL_MAX_MS = 1_000 const HOST_SESSION_ATTACH_TIMEOUT_MS = 15_000 const HOST_SESSION_INVENTORY_MAX_WINDOWS_PER_RECOVERY = 2 const HOST_SESSION_SAME_HANDLE_END_REUSE_LIMIT = 2 +// Why its own constant: this fences how long an end-then-reattach on the same handle still counts as +// one recovery, which is unrelated to how long auto-recovery keeps retrying. It read the recovery +// budget before that budget became a derived value, and must not drift with it. +const HOST_SESSION_SAME_HANDLE_END_REUSE_WINDOW_MS = 60_000 const MAX_SURFACED_TERMINAL_ERRORS = 8 const TERMINAL_CREATE_RETRY_DELAYS_MS = [250, 500, 1000, 2000, 4000, 8000, 15_000, 30_000] as const @@ -247,7 +251,9 @@ export function createRemoteRuntimePtyTransport( clearPublishedHandleWait() } if (recovery.currentPhase === 'disconnected') { - autoRecoveryWindowSpent = true + // Why: only the wall-clock deadline is evidence the window was spent; a UI latch from a fatal + // resubscribe must not license reattaching a fenced same handle (#12683). + autoRecoveryWindowSpent ||= recovery.autoRecoveryDeadlineExpired // Why: cached pixels may remain, but no stream from the exhausted epoch may keep delivering or accepting terminal traffic. subscriptionGeneration += 1 closeMultiplexedStream() @@ -326,7 +332,7 @@ export function createRemoteRuntimePtyTransport( if ( sameHandleEndReuseHandle !== targetHandle || sameHandleEndReuseAttachedAt === null || - Date.now() - sameHandleEndReuseAttachedAt >= REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + Date.now() - sameHandleEndReuseAttachedAt >= HOST_SESSION_SAME_HANDLE_END_REUSE_WINDOW_MS ) { resetSameHandleEndReuse() return 'prefer-replacement' @@ -870,6 +876,43 @@ export function createRemoteRuntimePtyTransport( return false } + // Why: a recoverable connect failure is unverifiable contact loss, not a dead terminal, so retry + // whichever path can still reach the pane instead of latching with nothing armed (#12684). + function retryAfterRecoverableConnectFailure(nextEpoch: number): void { + if (destroyed || terminalEnded) { + return + } + if (connected && handle) { + scheduleResubscribeAfterTransportClose(getRecoveryReplacementPolicy(handle), nextEpoch) + return + } + replayLastTransportEntryPoint() + } + + // Why: schedule() both auto-retries inside the window and leaves the retry parked when the deadline + // latches, so online/resume and the Reconnect button always find something to fire. + function scheduleConnectRetryAfterRecoverableFailure(): void { + if (destroyed) { + return + } + // Why: an ambiguous create already owns a reconciliation-gated retry that only Reconnect may + // re-enter; auto-replaying here would just re-probe a runtime that cannot reconcile. + if (terminalCreateNeedsReconciliation || agentSessionRequiresHostAuthorityReplay) { + recovery.markDisconnected() + return + } + // Why: the last attempt's RPC budget expires at the same instant as the deadline, so a silent drop + // rejects after the latch. Beginning a new epoch there re-arms the whole window, so park instead. + if (recovery.currentPhase === 'disconnected') { + recovery.parkRetryAfterDeadline(retryAfterRecoverableConnectFailure) + return + } + const recoveryEpoch = recovery.isActive ? recovery.currentEpoch : recovery.begin() + if (!recovery.schedule(recoveryEpoch, retryAfterRecoverableConnectFailure)) { + recovery.markDisconnected() + } + } + async function attachHostSessionMirror( options: { cols?: number; rows?: number }, notifySpawn = true, @@ -1033,6 +1076,8 @@ export function createRemoteRuntimePtyTransport( kind === 'agent-session' ? agentSessionRequiresHostAuthorityReplay : terminalCreateNeedsReconciliation + // Why the same budget: this loop calls recovery.begin(), so a shorter local deadline would abandon + // the create while the recovery state still reports 'recovering' with nothing in flight. let recoveryDeadlineAt: number | null = recovery.isActive ? Date.now() + REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS : null @@ -2314,7 +2359,7 @@ export function createRemoteRuntimePtyTransport( } else if ( isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error)) ) { - recovery.markDisconnected() + scheduleConnectRetryAfterRecoverableFailure() } else { recovery.cancel() emitRecoveryState() @@ -2616,6 +2661,12 @@ export function createRemoteRuntimePtyTransport( void transport.connect(lastConnectOptions) return true } + // Why: online/resume fires a parked retry; the button must not be weaker than an event (#12684). + if (!destroyed && !terminalEnded && recovery.currentPhase === 'disconnected') { + if (recovery.retryNow()) { + return true + } + } if ( destroyed || terminalEnded || diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f4a4fd931de..0ac5d624ede 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2881,7 +2881,7 @@ "TerminalRemoteRuntimeReconnectBanner": { "retryingTitle": "Reconnecting to remote runtime", "disconnectedTitle": "Remote runtime disconnected", - "retryingBody": "Orca will retry for up to one minute. This terminal will resume if the connection returns.", + "retryingBody": "Orca is retrying automatically. This terminal will resume if the connection returns.", "disconnectedBody": "Automatic retries stopped. Reconnect to resume this terminal session.", "reconnectButton": "Reconnect" } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index ee6e4440f79..d3af25ad697 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2881,7 +2881,7 @@ "TerminalRemoteRuntimeReconnectBanner": { "retryingTitle": "リモートランタイムに再接続中", "disconnectedTitle": "リモートランタイムが切断されました", - "retryingBody": "Orca は最大1分間再試行します。接続が復元されると、このターミナルが再開されます。", + "retryingBody": "Orca は自動的に再試行します。接続が復元されると、このターミナルが再開されます。", "disconnectedBody": "自動再試行が停止しました。このターミナルセッションを再開するには再接続してください。", "reconnectButton": "再接続" } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 76ed151ca2c..47d9e48fd26 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2886,7 +2886,7 @@ "TerminalRemoteRuntimeReconnectBanner": { "retryingTitle": "원격 런타임에 다시 연결 중", "disconnectedTitle": "원격 런타임 연결 끊김", - "retryingBody": "Orca는 최대 1분간 재시도합니다. 연결이 복원되면 이 터미널이 재개됩니다.", + "retryingBody": "Orca가 자동으로 재시도합니다. 연결이 복원되면 이 터미널이 재개됩니다.", "disconnectedBody": "자동 재시도가 중지되었습니다. 이 터미널 세션을 재개하려면 다시 연결하세요.", "reconnectButton": "다시 연결" } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5c1324afa12..b097b789571 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2896,7 +2896,7 @@ "TerminalRemoteRuntimeReconnectBanner": { "retryingTitle": "正在重新连接到远程运行时", "disconnectedTitle": "远程运行时已断开连接", - "retryingBody": "Orca 将重试最多一分钟。如果连接恢复,此终端将恢复。", + "retryingBody": "Orca 正在自动重试。如果连接恢复,此终端将恢复。", "disconnectedBody": "自动重试已停止。重新连接以恢复此终端会话。", "reconnectButton": "重新连接" }