From 1f13ec6f70a52c1a6951c2f9fc4bef4e7691c968 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:04:12 -0700 Subject: [PATCH] =?UTF-8?q?fix(wsl-host):=20stopped-host=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20no=20stuck=20Start=20card,=20card-rule=20retry=20an?= =?UTF-8?q?d=20dot,=20SSH=20pin=20(#35822)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(wsl-host): a stop published after the host came back says ready again; retry the card's panes - e9-reviewer: a runtime call's stop could land after liveness had already reconnected and said ready, leaving Start on a working host. Once the distro runs, the deferred un-park re-reads the host and says ready from the live connection, or reconnects open work as before. - fix-reviewer-2: the return retry picks panes by the Start card's rule, so a pane whose create failed before it reached the host is retried too. - Pins that the refusal stays a plain, final Error with the same words for the move and automation callers. Co-Authored-By: Claude * fix(wsl-host): the stopped dot keeps panes on another, running server live e9-reviewer: match the Start card's rule; only a pane with no server or on the stopped host ended. Co-Authored-By: Claude * test(terminal): pin that the host-refusal unwrap leaves SSH words and other channels as they were Co-Authored-By: Claude --------- Co-authored-by: Claude --- src/main/wsl/wsl-host-runtime-call.ts | 24 +++++++++++- ...wsl-host-service-runtime-call-stop.test.ts | 28 ++++++++++++++ src/main/wsl/wsl-host-service.ts | 2 + .../use-worktree-activity-status.test.tsx | 10 +++++ .../sidebar/use-worktree-activity-status.ts | 38 +++++++++++++++---- .../terminal-pane/TerminalErrorToast.test.ts | 11 ++++++ .../use-wsl-host-return-retry.test.ts | 6 ++- .../use-wsl-host-return-retry.ts | 7 +++- 8 files changed, 113 insertions(+), 13 deletions(-) diff --git a/src/main/wsl/wsl-host-runtime-call.ts b/src/main/wsl/wsl-host-runtime-call.ts index 36910478211..2c97ee3a62d 100644 --- a/src/main/wsl/wsl-host-runtime-call.ts +++ b/src/main/wsl/wsl-host-runtime-call.ts @@ -10,6 +10,7 @@ import { wslHostRunRefusal } from './wsl-host-add-admission' import { admitConnectToPendingHost } from './wsl-host-removal' +import type { WslOrcadConnection } from './wsl-orcad-connection' export type WslHostRuntimeCallPort = { record: WslHostDeploymentRecord | null @@ -21,6 +22,7 @@ export type WslHostRuntimeCallPort = { isVisible: () => boolean /** Shown as liveness shows a stop, so the host's panes offer Start. */ publishStopped: (record: WslHostDeploymentRecord) => void + publishReady: (record: WslHostDeploymentRecord, connection: WslOrcadConnection) => void /** The same call again, once the distro is confirmed running. */ retry: () => Promise } @@ -73,8 +75,26 @@ function parkStopped(record: WslHostDeploymentRecord, port: WslHostRuntimeCallPo } const deferUntilRunning = port.deps.deferUntilRunning ?? deferUntilWslDistroRunning deferUntilRunning(distro, `wsl-host-parked:${environmentId}`, () => { - if (port.parked.delete(environmentId) && port.isVisible()) { - void port.retry().catch(() => {}) + if (port.parked.delete(environmentId)) { + void resumeAfterStop(record, port).catch(() => {}) } }) } + +async function resumeAfterStop( + record: WslHostDeploymentRecord, + port: WslHostRuntimeCallPort +): Promise { + // A stop published after liveness had already reconnected would leave Start on a working host. + const connection = port.connector?.get(record.distro) ?? null + if ( + connection && + (await port.connector?.reachability(record.distro, record.environmentId)) === 'available' + ) { + port.publishReady(record, connection) + return + } + if (port.isVisible()) { + await port.retry() + } +} diff --git a/src/main/wsl/wsl-host-service-runtime-call-stop.test.ts b/src/main/wsl/wsl-host-service-runtime-call-stop.test.ts index e2a63ad3538..139a0acb7e9 100644 --- a/src/main/wsl/wsl-host-service-runtime-call-stop.test.ts +++ b/src/main/wsl/wsl-host-service-runtime-call-stop.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime/remote-runtime-client-error' import type { WslHostStatus } from '../../shared/wsl-host-types' import { WslHostService, type WslHostConnector } from './wsl-host-service' import { UBUNTU, makeConnector } from './wsl-host-service-test-connector' @@ -84,4 +85,31 @@ describe('a runtime call that finds the WSL host stopped', () => { await Promise.resolve() expect(connector.connect).not.toHaveBeenCalled() }) + + // e9-reviewer: the stop can land after liveness already reconnected and said ready. + it('says ready again when the host was already back by the time the distro ran', async () => { + const { connector, service } = await stoppedUnderVisibleWork() + await expect(service.ensureForRuntimeCall('wsl:ubuntu')).rejects.toThrow('Ubuntu was stopped') + connector.distro.state = 'running' + // Liveness reconnected on its own. + await connector.connect('Ubuntu') + connector.connect.mockClear() + deferred.get('Ubuntu:wsl-host-parked:wsl:ubuntu')?.() + await vi.waitFor(() => + expect(published.at(-1)).toMatchObject({ environmentId: 'wsl:ubuntu', state: 'ready' }) + ) + expect(connector.connect).not.toHaveBeenCalled() + }) + + // What wsl-move-host-connect and wsl-host-automation-enable read: unchanged by this fix. + it('keeps the refusal a plain, final Error with the same words', async () => { + const { service } = await stoppedUnderVisibleWork() + const refusal = await service.ensureForRuntimeCall('wsl:ubuntu').catch((error) => error) + expect(refusal).toBeInstanceOf(Error) + expect(refusal).not.toBeInstanceOf(RemoteRuntimeClientError) + expect(refusal).toMatchObject({ + message: + 'Ubuntu was stopped. Switch to one of its projects, or reconnect it in Settings → Remote Orca Servers, to start it again.' + }) + }) }) diff --git a/src/main/wsl/wsl-host-service.ts b/src/main/wsl/wsl-host-service.ts index fb147b304c7..3ef0b9d30cc 100644 --- a/src/main/wsl/wsl-host-service.ts +++ b/src/main/wsl/wsl-host-service.ts @@ -219,6 +219,8 @@ export class WslHostService { isVisible: () => this.visible.has(environmentId), publishStopped: (record) => this.setStatus(record, { state: 'distro-stopped', terminals: 'exited', recovering: false }), + publishReady: (record, connection) => + this.setStatus(record, { state: 'ready', census: null }, connection), retry: () => this.ensureForRuntimeCall(environmentId) }) } diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx b/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx index 9a474697122..4cd0226daf4 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx @@ -5,6 +5,7 @@ import { structuredAgentSessionPaneKey } from '../../../../shared/structured-age import type { Tab } from '../../../../shared/tab-types' import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id' import { useWorktreeActivityStatus } from './use-worktree-activity-status' const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -182,6 +183,15 @@ describe('useWorktreeActivityStatus', () => { expect(renderToStaticMarkup()).toBe( 'inactive' ) + + // e9-reviewer: a pane bound to another, running server is the card's exception too. + mockState = { + ...mockState, + ptyIdsByTabId: { 'tab-1': [toRemoteRuntimePtyId('h1', 'ssh:desk')] } + } + expect(renderToStaticMarkup()).toBe( + 'active' + ) }) it('keeps a restored offscreen working agent yellow from the hook snapshot', () => { diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts index b60a2b9984f..787252e919e 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts @@ -12,8 +12,26 @@ import { import { selectWorktreeAgentActivitySummary } from './worktree-agent-activity-summary' import { selectStoppedWslDistro } from '@/lib/wsl-host-stopped-status' import { selectWorkspaceRuntimeEnvironmentId } from '../terminal-pane/remote-server-outage' +import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id' -const NO_LIVE_PTYS: Record = {} +/** The Start card's rule: a pane with no server yet, or on the stopped one, ended with its distro. */ +function withoutStoppedHostPtys( + ptyIdsByTabId: Record, + stoppedEnvironmentId: string +): Record { + const live: Record = {} + for (const [tabId, ptyIds] of Object.entries(ptyIdsByTabId)) { + const kept = ptyIds.filter((ptyId) => { + const environmentId = parseRemoteRuntimePtyId(ptyId)?.environmentId + return environmentId != null && environmentId !== stoppedEnvironmentId + }) + // Same array when nothing dropped, so useShallow keeps the card from re-rendering. + if (kept.length > 0) { + live[tabId] = kept.length === ptyIds.length ? ptyIds : kept + } + } + return live +} // Why the cheap check first: every card's selector runs on each store update, and few hosts stop. function hasStoppedWslHost(state: { @@ -38,13 +56,17 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { ) // Why one selector: a second subscription per card costs a store listener on every workspace. const ptyIdsForWorktree = useAppStore( - useShallow((s) => - // A stopped distro ended every terminal in it, whatever bindings the panes keep for its Start. - hasStoppedWslHost(s) && - selectStoppedWslDistro(s, selectWorkspaceRuntimeEnvironmentId(s, worktreeId)) !== null - ? NO_LIVE_PTYS - : selectLivePtyIdsForWorktree(s, worktreeId) - ) + useShallow((s) => { + const ptyIds = selectLivePtyIdsForWorktree(s, worktreeId) + if (!hasStoppedWslHost(s)) { + return ptyIds + } + // A stopped distro ended its terminals, whatever bindings the panes keep for its Start. + const owner = selectWorkspaceRuntimeEnvironmentId(s, worktreeId) + return owner && selectStoppedWslDistro(s, owner) !== null + ? withoutStoppedHostPtys(ptyIds, owner) + : ptyIds + }) ) const terminalLayoutRootsByTabId = useAppStore( useShallow((s) => selectTerminalLayoutRootsForWorktree(s, worktreeId)) diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts index 42ae2405add..b35807fc113 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts @@ -219,6 +219,17 @@ describe('humanizeTerminalError', () => { ) }) + // SSH pin: only the wrapper goes; the SSH host's own words, and other channels' wrappers, stay. + it("leaves an SSH host's refusal words and other IPC channels' wrappers as they were", () => { + expect( + humanizeTerminalError( + "Error invoking remote method 'runtimeEnvironments:call': Error: Remote Orca runtime is not connected: ssh desk" + ) + ).toBe('Remote Orca runtime is not connected: ssh desk') + const otherChannel = "Error invoking remote method 'ssh:connect': Error: Permission denied" + expect(humanizeTerminalError(otherChannel)).toBe(otherChannel) + }) + it('replaces the terminal-host-gone code with copy that explains the loss', () => { const humanized = humanizeTerminalError('terminal_host_gone') expect(humanized).not.toContain('terminal_host_gone') 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 8d4d4edff23..fbad20742e1 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 @@ -29,11 +29,14 @@ describe('a stopped WSL host coming back on its own', () => { it("retries the host's panes once it is ready again, and only those", () => { hostStatus('ready') const hostPane = pane('wsl:ubuntu') + // A pane whose create failed before it reached the host has no server yet: still the host's. + const unboundPane = pane(null) const otherPane = pane('ssh:desk') const transports = { current: new Map([ [1, hostPane], - [2, otherPane] + [2, unboundPane], + [3, otherPane] ]) } const { rerender } = renderHook(() => @@ -46,6 +49,7 @@ describe('a stopped WSL host coming back on its own', () => { rerender() } expect(hostPane.retryRecovery).toHaveBeenCalledOnce() + expect(unboundPane.retryRecovery).toHaveBeenCalledOnce() expect(otherPane.retryRecovery).not.toHaveBeenCalled() // Ready again with no stop between leaves healthy panes alone. 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 0a5be555e60..5161507b420 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 @@ -23,8 +23,11 @@ export function useRetryPanesWhenWslHostReturns( } stoppedHost.current = null for (const transport of paneTransports.current.values()) { - // Why only the host's panes: one on another server never met this stop. - if (transport.getRuntimeEnvironmentId?.() === ownerEnvironmentId) { + // 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?.() } }