From ff0b418e315fbeb3ca753527ae753cd19dd4969a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:38:36 -0700 Subject: [PATCH] fix(wsl-host): a pane parked by a stopped host reopens once Start brings the host back (#36784) * fix(wsl-host): a pane parked by a stopped host reopens once Start brings the host back * fix(wsl-host): clear only the returned host's stopped refusal; test the strip and the bridge --- src/main/wsl/wsl-host-add-admission.ts | 4 +- .../TerminalPaneRecoveryPortals.tsx | 23 +++- .../legacy-relay-bridge-transport.test.ts | 18 +++ .../legacy-relay-bridge-transport.ts | 1 + .../terminal-pane/pty-transport-types.ts | 5 + ...-pty-transport-stopped-host-reopen.test.ts | 104 ++++++++++++++++++ .../remote-runtime-pty-transport.ts | 8 ++ .../use-wsl-host-return-retry.test.ts | 35 +++++- .../use-wsl-host-return-retry.ts | 12 +- src/shared/wsl-host-stopped-message.test.ts | 34 ++++++ src/shared/wsl-host-stopped-message.ts | 18 +++ 11 files changed, 251 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stopped-host-reopen.test.ts create mode 100644 src/shared/wsl-host-stopped-message.test.ts create mode 100644 src/shared/wsl-host-stopped-message.ts diff --git a/src/main/wsl/wsl-host-add-admission.ts b/src/main/wsl/wsl-host-add-admission.ts index f330d5c939b..a43ba0f41f0 100644 --- a/src/main/wsl/wsl-host-add-admission.ts +++ b/src/main/wsl/wsl-host-add-admission.ts @@ -30,9 +30,7 @@ export function wslHostNotConnectedMessage(distro: string): string { return `The WSL host ${distro} isn’t connected. If it’s stopped, click Start; otherwise open one of its projects or connect it in Settings → Remote Orca Servers.` } -export function wslHostStoppedMessage(distro: string): string { - return `${distro} is stopped. To start it, click Start or open a new terminal in one of its projects.` -} +export { wslHostStoppedMessage } from '../../shared/wsl-host-stopped-message' /** Shown on a paired phone once a stop is confirmed: only the desktop starts a distro. */ export function wslHostStoppedForPhoneMessage(distro: string): string { diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneRecoveryPortals.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneRecoveryPortals.tsx index c0f0c566bea..0b098a14d7c 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneRecoveryPortals.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneRecoveryPortals.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useCallback, useState } from 'react' import { createPortal } from 'react-dom' import type { PtyTransport } from './pty-transport-types' import { useWslHostUnavailableReason } from './use-wsl-host-unavailable-reason' @@ -18,6 +18,8 @@ import { useRetryPanesWhenWslHostReturns } from './use-wsl-host-return-retry' import { useCalmStatusReveal } from '@/hooks/use-calm-status-reveal' import { useAppStore } from '@/store' import { paneShowsWslHostStoppedCard } from '@/lib/wsl-host-stopped-status' +import { withoutWslHostStoppedLines } from '../../../../shared/wsl-host-stopped-message' +import { mapPaneTerminalErrors } from './terminal-error-accumulation' import type { TerminalPaneController } from './use-terminal-pane-controller' /** Reconnect pairs refused servers, signs in SSH hosts awaiting credentials, or otherwise retries the pane. */ @@ -70,7 +72,24 @@ export function TerminalPaneRecoveryPortals({ ownerEnvironmentId ? state.wslHostStatuses[ownerEnvironmentId] : undefined ) const stoppedWslDistro = ownerWslHost?.state === 'distro-stopped' ? ownerWslHost.distro : null - useRetryPanesWhenWslHostReturns(ownerEnvironmentId, ownerWslHost?.state, paneTransportsRef) + const { setTerminalError, setTerminalErrorsByPaneId } = controller + const ownerWslDistro = ownerWslHost?.distro ?? null + // Why scoped to this host's distro: a pane on another, still-stopped host keeps its line. + const clearStoppedHostErrors = useCallback(() => { + if (!ownerWslDistro) { + return + } + const strip = (error: string): string | null => + withoutWslHostStoppedLines(error, ownerWslDistro) + setTerminalError((current) => (current === null ? null : strip(current))) + setTerminalErrorsByPaneId((current) => mapPaneTerminalErrors(current, strip)) + }, [ownerWslDistro, setTerminalError, setTerminalErrorsByPaneId]) + useRetryPanesWhenWslHostReturns( + ownerEnvironmentId, + ownerWslHost?.state, + paneTransportsRef, + clearStoppedHostErrors + ) const retryingOutageEnvironmentId = useRetryingOutageEnvironmentId(controller.worktreeId) const isolated = controller.isolatedPaneKey !== null // Why: a pane waiting for SSH sign-in says so itself; "reconnecting" over it would be false. diff --git a/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.test.ts b/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.test.ts index 83c69205b4b..2023c05453c 100644 --- a/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.test.ts @@ -219,6 +219,24 @@ describe('a runtime pane bridged to an older relay’s terminal', () => { expect(transport.getPtyId()).toBe('remote:env-1@@t-1') }) + it('leaves a bridged pane’s host-return reopen alone, and forwards it once the pane runs on orcad', async () => { + bridge.attach.mockResolvedValueOnce({ status: 'attached', replay: 'SCREEN', acquisitionId: 1 }) + const bridgedInner = innerTransport() + bridgedInner.reopenAfterHostReturned = vi.fn(() => true) + const bridged = createLegacyRelayBridgeTransport(PTY, bridgedInner) + await bridged.connect({ url: '', sessionId: PTY, callbacks: callbacks() }) + expect(bridged.reopenAfterHostReturned?.()).toBe(false) + expect(bridgedInner.reopenAfterHostReturned).not.toHaveBeenCalled() + + bridge.attach.mockResolvedValueOnce({ status: 'exited' }) + const inner = innerTransport() + inner.reopenAfterHostReturned = vi.fn(() => true) + const forwarded = createLegacyRelayBridgeTransport(PTY, inner) + await forwarded.connect({ url: '', sessionId: PTY, callbacks: callbacks() }) + expect(forwarded.reopenAfterHostReturned?.()).toBe(true) + expect(inner.reopenAfterHostReturned).toHaveBeenCalledOnce() + }) + it('keeps an unreachable terminal bound and reattaches once the relay answers', async () => { bridge.attach.mockResolvedValueOnce({ status: 'unverifiable' }) const transport = createLegacyRelayBridgeTransport(PTY, innerTransport()) diff --git a/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.ts b/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.ts index d723712778c..4d69b5ac3f4 100644 --- a/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.ts +++ b/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.ts @@ -231,6 +231,7 @@ export function createLegacyRelayBridgeTransport(ptyId: string, inner: PtyTransp getConnectionId: () => (bridged() ? null : inner.getConnectionId?.()), ownsRecovery: () => (bridged() ? false : (inner.ownsRecovery?.() ?? false)), retryRecovery: () => (bridged() ? false : (inner.retryRecovery?.() ?? false)), + reopenAfterHostReturned: () => !bridged() && (inner.reopenAfterHostReturned?.() ?? false), setOutputPaused: (paused) => { if (!bridged()) { return inner.setOutputPaused?.(paused) ?? false diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index f33cb3ebeed..7f673623fbe 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -236,6 +236,11 @@ export type PtyTransport = { getRecoveryState?: () => PtyTransportRecoveryState /** Starts a fresh connection epoch while preserving the authoritative remote PTY identity. */ retryRecovery?: () => boolean + /** + * The host the pane met stopped is running again: reopen a pane whose connect or attach was + * refused and left it idle (no retry armed), the way it opened. False when there is none to reopen. + */ + reopenAfterHostReturned?: () => boolean /** True while the transport has a retry armed or parked; pane-level remounts must defer to it. */ ownsRecovery?: () => boolean /** Typed input the host has not applied yet, which a remount would discard. */ diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stopped-host-reopen.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stopped-host-reopen.test.ts new file mode 100644 index 00000000000..906e310bc47 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stopped-host-reopen.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => { + throw new Error('These transport fixtures must not read app-store ownership') + } + } +})) + +import { wslHostStoppedMessage } from '../../../../shared/wsl-host-stopped-message' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + createRemoteRuntimeTransportMocks, + type MultiplexSubscriptionCallbacks +} from './remote-runtime-pty-transport-test-harness' + +const ORIGINAL_ID = 'remote:env-1@@terminal-1' + +let callbacks: MultiplexSubscriptionCallbacks = null +let resolvedHandle = 'terminal-1' +const { runtimeCall, latestSubscribePayload, emitSnapshot, resetRemoteRuntimeTransport } = + createRemoteRuntimeTransportMocks({ + getCallbacks: () => callbacks, + setCallbacks: (value) => { + callbacks = value + }, + getResolvedPaneHandle: () => resolvedHandle, + setResolvedPaneHandle: (value) => { + resolvedHandle = value + } + }) + +beforeEach(resetRemoteRuntimeTransport) + +async function completeAttach(): Promise { + await vi.waitFor(() => expect(latestSubscribePayload()).toBeDefined()) + const { streamId } = latestSubscribePayload() + callbacks?.onResponse({ ok: true, result: { type: 'subscribed', streamId } }) + emitSnapshot(streamId, 'prompt$ ') +} + +/** Every runtime call is refused the way main refuses one for a stopped distro, until `start()`. */ +function stoppedHost(): { start: () => void } { + let stopped = true + const call = runtimeCall.getMockImplementation() + runtimeCall.mockImplementation(async (request: { method: string }) => { + if (stopped) { + throw new Error( + `Error invoking remote method 'runtimeEnvironments:call': Error: ${wslHostStoppedMessage('Ubuntu')}` + ) + } + return call?.(request) + }) + return { + start: () => { + stopped = false + } + } +} + +describe('a restored remote pane whose host was stopped', () => { + it.each(['connect', 'attach'] as const)( + 'reopens by %s once the host is running again, though its own retry has nothing armed', + async (entry) => { + const host = stoppedHost() + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + const onError = vi.fn() + if (entry === 'connect') { + void transport.connect({ url: '', sessionId: ORIGINAL_ID, callbacks: { onError } }) + } else { + transport.attach({ existingPtyId: ORIGINAL_ID, cols: 80, rows: 24, callbacks: { onError } }) + } + await vi.waitFor(() => expect(onError).toHaveBeenCalled()) + await flushAsyncTicks() + expect(transport.getPtyId()).toBeNull() + + host.start() + // The refusal armed nothing, so the pane's own retry cannot bring it back. + expect(transport.retryRecovery?.()).toBe(false) + expect(transport.reopenAfterHostReturned?.()).toBe(true) + await completeAttach() + + await vi.waitFor(() => expect(transport.getPtyId()).toBe(ORIGINAL_ID)) + // Connected now: a second host return does not open it again. + expect(transport.reopenAfterHostReturned?.()).toBe(false) + transport.destroy?.() + } + ) + + it('leaves a pane that is still connecting to its own path', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' }) + expect(transport.reopenAfterHostReturned?.()).toBe(false) + void transport.connect({ url: '', sessionId: ORIGINAL_ID, callbacks: {} }) + expect(transport.reopenAfterHostReturned?.()).toBe(false) + transport.destroy?.() + }) +}) 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 6bef5f1a4b0..28738420174 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 @@ -3930,6 +3930,14 @@ export function createRemoteRuntimePtyTransport( surfacedErrorMessages.clear() }, + reopenAfterHostReturned() { + // Why only an idle pane: one already retrying or connected owns its own path back. + if (connecting || recovery.currentPhase !== 'idle') { + return false + } + return replayLastTransportEntryPoint() + }, + retryRecovery() { if (recovery.hasOwnerRefresh || surfacedErrorMessages.has(ownerChanged)) { return recovery.retryNow() diff --git a/src/renderer/src/components/terminal-pane/use-wsl-host-return-retry.test.ts b/src/renderer/src/components/terminal-pane/use-wsl-host-return-retry.test.ts index fbad20742e1..11c557ead49 100644 --- a/src/renderer/src/components/terminal-pane/use-wsl-host-return-retry.test.ts +++ b/src/renderer/src/components/terminal-pane/use-wsl-host-return-retry.test.ts @@ -6,11 +6,12 @@ import type { WslHostConnectionState } from '../../../../shared/wsl-host-types' import type { PtyTransport } from './pty-transport-types' import { useRetryPanesWhenWslHostReturns } from './use-wsl-host-return-retry' -function pane(environmentId: string | null): PtyTransport { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook reads only these two members. +function pane(environmentId: string | null, retrying = true): PtyTransport { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook reads only these three members. return { getRuntimeEnvironmentId: () => environmentId, - retryRecovery: vi.fn(() => true) + retryRecovery: vi.fn(() => retrying), + reopenAfterHostReturned: vi.fn(() => true) } as unknown as PtyTransport } @@ -70,4 +71,32 @@ describe('a stopped WSL host coming back on its own', () => { rerender() expect(hostPane.retryRecovery).toHaveBeenCalledOnce() }) + + // Why: after a relaunch the restored pane's connect was refused, so no retry is armed to resume. + it('reopens a pane the stop left idle, and clears the stop’s error, after Start', () => { + hostStatus('distro-stopped') + const idlePane = pane('wsl:ubuntu', false) + const retryingPane = pane('wsl:ubuntu', true) + const onHostReturned = vi.fn() + const { rerender } = renderHook(() => + useRetryPanesWhenWslHostReturns( + 'wsl:ubuntu', + status, + { + current: new Map([ + [1, idlePane], + [2, retryingPane] + ]) + }, + onHostReturned + ) + ) + expect(onHostReturned).not.toHaveBeenCalled() + hostStatus('ready') + rerender() + expect(onHostReturned).toHaveBeenCalledOnce() + expect(idlePane.reopenAfterHostReturned).toHaveBeenCalledOnce() + // A pane whose own retry took over is not opened a second time. + expect(retryingPane.reopenAfterHostReturned).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/terminal-pane/use-wsl-host-return-retry.ts b/src/renderer/src/components/terminal-pane/use-wsl-host-return-retry.ts index 5161507b420..c02cb1ebbad 100644 --- a/src/renderer/src/components/terminal-pane/use-wsl-host-return-retry.ts +++ b/src/renderer/src/components/terminal-pane/use-wsl-host-return-retry.ts @@ -10,7 +10,9 @@ export function useRetryPanesWhenWslHostReturns( ownerEnvironmentId: string | null, /** The owner's WSL host state, from the caller's own subscription (no listener of its own). */ status: WslHostConnectionState | undefined, - paneTransports: { readonly current: ReadonlyMap } + paneTransports: { readonly current: ReadonlyMap }, + /** Clears the stop's own error from the panes; their old refusal is no longer true. */ + onHostReturned?: () => void ): void { const stoppedHost = useRef(null) useEffect(() => { @@ -22,14 +24,18 @@ export function useRetryPanesWhenWslHostReturns( return } stoppedHost.current = null + onHostReturned?.() for (const transport of paneTransports.current.values()) { // The card's rule: a pane with no server yet is still the workspace's; one on another never met this stop. const paneEnvironmentId = transport.getRuntimeEnvironmentId ? transport.getRuntimeEnvironmentId() : ownerEnvironmentId if (paneEnvironmentId == null || paneEnvironmentId === ownerEnvironmentId) { - transport.retryRecovery?.() + // Why both: a refused connect left the pane idle, with no retry armed to resume. + if (!transport.retryRecovery?.()) { + transport.reopenAfterHostReturned?.() + } } } - }, [status, ownerEnvironmentId, paneTransports]) + }, [status, ownerEnvironmentId, paneTransports, onHostReturned]) } diff --git a/src/shared/wsl-host-stopped-message.test.ts b/src/shared/wsl-host-stopped-message.test.ts new file mode 100644 index 00000000000..2f009038154 --- /dev/null +++ b/src/shared/wsl-host-stopped-message.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { + isWslHostStoppedErrorLine, + withoutWslHostStoppedLines, + wslHostStoppedMessage +} from './wsl-host-stopped-message' + +const IPC_WRAPPER = "Error invoking remote method 'runtimeEnvironments:call': Error: " + +describe('the stopped WSL host refusal', () => { + it('matches the line its own message produces, with or without the IPC wrapper', () => { + const line = wslHostStoppedMessage('Ubuntu') + expect(isWslHostStoppedErrorLine(line, 'Ubuntu')).toBe(true) + expect(isWslHostStoppedErrorLine(`${IPC_WRAPPER}${line}`, 'Ubuntu')).toBe(true) + }) + + it.each(['', IPC_WRAPPER])( + 'keeps an unrelated line of a mixed error (wrapper: %j)', + (wrapper) => { + const error = `${wrapper}${wslHostStoppedMessage('Ubuntu')}\nPaste failed.` + expect(withoutWslHostStoppedLines(error, 'Ubuntu')).toBe('Paste failed.') + } + ) + + it('clears an error that was only the refusal', () => { + expect(withoutWslHostStoppedLines(wslHostStoppedMessage('Ubuntu'), 'Ubuntu')).toBeNull() + }) + + it('keeps another, still-stopped distro’s refusal', () => { + const other = wslHostStoppedMessage('Debian') + expect(isWslHostStoppedErrorLine(other, 'Ubuntu')).toBe(false) + expect(withoutWslHostStoppedLines(other, 'Ubuntu')).toBe(other) + }) +}) diff --git a/src/shared/wsl-host-stopped-message.ts b/src/shared/wsl-host-stopped-message.ts new file mode 100644 index 00000000000..335f3b9f566 --- /dev/null +++ b/src/shared/wsl-host-stopped-message.ts @@ -0,0 +1,18 @@ +// The refusal a runtime call meets when its WSL host's distro was stopped; the renderer clears it once the host returns. +export function wslHostStoppedMessage(distro: string): string { + return `${distro} is stopped. To start it, click Start or open a new terminal in one of its projects.` +} + +/** True for an error line carrying this distro's refusal, with or without an IPC wrapper around it. */ +export function isWslHostStoppedErrorLine(line: string, distro: string): boolean { + return line.includes(wslHostStoppedMessage(distro)) +} + +/** The error without the distro's refusal lines; null when nothing else was on it. */ +export function withoutWslHostStoppedLines(error: string, distro: string): string | null { + const kept = error + .split('\n') + .filter((line) => !isWslHostStoppedErrorLine(line, distro)) + .join('\n') + return kept.length > 0 ? kept : null +}