From 95ffa4fcce93035249a38fd0a2a35065fcab06a7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:17:38 -0700 Subject: [PATCH] fix(wsl-host): a cold wake that loses orcad recovers by itself instead of leaving a dead pane (#33147) * fix(wsl-host): a cold wake that loses orcad recovers by itself; stopped-distro refusals stay final * fix(wsl-host): identity and pairing refusals stay final; no second wake after a stop; settling is retryable --- src/main/wsl/wsl-host-connect-failure.test.ts | 53 +++++++++++++++++++ src/main/wsl/wsl-host-connect-failure.ts | 53 +++++++++++++++++++ src/main/wsl/wsl-host-service.test.ts | 37 ++++++++++++- src/main/wsl/wsl-host-service.ts | 12 ++--- src/main/wsl/wsl-orcad-connection.test.ts | 13 +++++ src/main/wsl/wsl-orcad-connection.ts | 6 ++- src/main/wsl/wsl-orcad-deploy.test.ts | 31 +++++++++++ src/main/wsl/wsl-orcad-deploy.ts | 28 +++++++--- 8 files changed, 218 insertions(+), 15 deletions(-) create mode 100644 src/main/wsl/wsl-host-connect-failure.test.ts create mode 100644 src/main/wsl/wsl-host-connect-failure.ts diff --git a/src/main/wsl/wsl-host-connect-failure.test.ts b/src/main/wsl/wsl-host-connect-failure.test.ts new file mode 100644 index 00000000000..dc87fd5fb22 --- /dev/null +++ b/src/main/wsl/wsl-host-connect-failure.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' +import { isRecoverableRemoteRuntimeConnectionError } from '../../shared/remote-runtime/remote-runtime-client-error-classification' +import { OrcadHostUnsupportedError } from '../orcad/orcad-host-unavailable' +import { + OrcadManagedIdentityError, + OrcadManagedPairingRejectedError +} from '../managed-server/lifecycle/orcad-managed-tunnel-identity' +import { OrcadManagedPairingRepairFailedError } from '../managed-server/lifecycle/orcad-managed-pairing-repair-failure' +import { retryableConnectFailure, settlingConnectFailure } from './wsl-host-connect-failure' +import { + WslDistroNotRunningError, + WslOrcadLaunchDeferredError +} from './wsl-orcad-connection-errors' + +describe('retryableConnectFailure', () => { + it('turns a connect failure on a running host into a retryable one the renderer recognises', () => { + const failure = retryableConnectFailure('Ubuntu', new Error('orcad exited')) + expect(failure).toBeInstanceOf(RemoteRuntimeClientError) + expect(failure).toMatchObject({ code: 'remote_runtime_unavailable' }) + expect(isRecoverableRemoteRuntimeConnectionError({ message: String(failure) })).toBe(true) + }) + + // Why final: each waits on the user; retrying would run a full deploy per pane until the deadline. + it.each([ + [ + 'a deferred update', + () => + new WslOrcadLaunchDeferredError({ + outcome: 'deferred', + distro: 'Ubuntu', + candidateVersion: '2.0.0', + code: 'live-terminals', + reason: 'Terminals are still running.', + forceable: true + }) + ], + ['an unsupported distro', () => new OrcadHostUnsupportedError('WSL 1')], + ['a stopped distro (no-boot refusal)', () => new WslDistroNotRunningError('Ubuntu')], + ['a foreign server', () => new OrcadManagedIdentityError(6790, 'another key')], + ['an unpaired server', () => new OrcadManagedPairingRejectedError('devices were reset')], + ['a failed re-pair', () => new OrcadManagedPairingRepairFailedError('refused again')] + ])('keeps %s final', (_label, make) => { + const refusal = make() + expect(retryableConnectFailure('Ubuntu', refusal)).toBe(refusal) + }) + + it('reports a host still settling after an exit as retryable', () => { + const failure = settlingConnectFailure('Ubuntu') + expect(failure).toMatchObject({ code: 'remote_runtime_unavailable' }) + expect(isRecoverableRemoteRuntimeConnectionError({ message: String(failure) })).toBe(true) + }) +}) diff --git a/src/main/wsl/wsl-host-connect-failure.ts b/src/main/wsl/wsl-host-connect-failure.ts new file mode 100644 index 00000000000..e427b571c9c --- /dev/null +++ b/src/main/wsl/wsl-host-connect-failure.ts @@ -0,0 +1,53 @@ +import { errorMessage } from '../../shared/error-message' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' +import { OrcadHostUnsupportedError } from '../orcad/orcad-host-unavailable' +import { + WslDistroNotRunningError, + WslOrcadLaunchDeferredError +} from './wsl-orcad-connection-errors' +import { + OrcadManagedIdentityError, + OrcadManagedPairingRejectedError +} from '../managed-server/lifecycle/orcad-managed-tunnel-identity' +import { OrcadManagedPairingRepairFailedError } from '../managed-server/lifecycle/orcad-managed-pairing-repair-failure' + +/** + * Liveness keeps reconnecting a running host whose connect failed, so a call that met the failure + * reads as "not reachable yet" and retries instead of dead-ending its pane. Final: a deferred update, + * unsupported distro or identity/pairing refusal (they wait on the user) and a no-boot refusal (E9). + */ +export function retryableConnectFailure(distro: string, error: unknown): unknown { + if ( + error instanceof WslOrcadLaunchDeferredError || + error instanceof OrcadHostUnsupportedError || + error instanceof WslDistroNotRunningError || + // A foreign or unpaired server, or a failed re-pair, needs the user (as on the SSH tunnel path); + // retrying would run a full deploy per pane until the deadline. + error instanceof OrcadManagedIdentityError || + error instanceof OrcadManagedPairingRejectedError || + error instanceof OrcadManagedPairingRepairFailedError + ) { + return error + } + return notReachableYet(distro, errorMessage(error)) +} + +/** Liveness is still deciding whether the distro stopped: a pane attaching now retries, never boots. */ +export function settlingConnectFailure(distro: string): RemoteRuntimeClientError { + return notReachableYet(distro, 'it is still settling after its server exited') +} + +function notReachableYet(distro: string, reason: string): RemoteRuntimeClientError { + return new RemoteRuntimeClientError( + 'remote_runtime_unavailable', + // Why this wording: subscriptions cross IPC as a message, and renderers match this phrase. + `Could not connect to the remote Orca runtime in ${distro} yet: ${reason}` + ) +} + +/** A `.catch` handler that rethrows a connect failure in its retryable-or-final form. */ +export function rethrowRetryable(distro: string): (error: unknown) => never { + return (error) => { + throw retryableConnectFailure(distro, error) + } +} diff --git a/src/main/wsl/wsl-host-service.test.ts b/src/main/wsl/wsl-host-service.test.ts index 8fca42f1070..67aafc1894d 100644 --- a/src/main/wsl/wsl-host-service.test.ts +++ b/src/main/wsl/wsl-host-service.test.ts @@ -16,6 +16,8 @@ import type { WslHostDistroCandidate } from './wsl-host-distro-list' import { WslHostService, type WslHostConnector } from './wsl-host-service' import { wakeRememberedWslHosts } from './wsl-host-early-wake' import { WslOrcadLaunchDeferredError } from './wsl-orcad-connection-errors' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' +import { isRecoverableRemoteRuntimeConnectionError } from '../../shared/remote-runtime/remote-runtime-client-error-classification' import type { WslOrcadConnection } from './wsl-orcad-connection' const KEY = Buffer.alloc(32, 3).toString('base64') @@ -512,6 +514,34 @@ describe('WslHostService', () => { expect(connector.connect).not.toHaveBeenCalled() }) + it('passes a failed reconnect for open work through the retryable mapping', async () => { + const connector = makeConnector([UBUNTU]) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + service.setVisibleWork(['wsl:ubuntu']) + await connector.disconnect('Ubuntu') + connector.connect.mockRejectedValueOnce(new Error('orcad exited')) + await expect(service.ensureForRuntimeCall('wsl:ubuntu')).rejects.toBeInstanceOf( + RemoteRuntimeClientError + ) + }) + + it('keeps a parked host’s refusal final, never a retryable failure', async () => { + const connector = makeConnector([UBUNTU]) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + service.setVisibleWork(['wsl:ubuntu']) + await connector.disconnect('Ubuntu') + connector.distro.state = 'stopped' + + const refusal = await service + .ensureForRuntimeCall('wsl:ubuntu') + .catch((error: unknown) => error) + expect(refusal).not.toBeInstanceOf(RemoteRuntimeClientError) + expect(String(refusal)).toContain('Ubuntu was stopped') + expect(isRecoverableRemoteRuntimeConnectionError({ message: String(refusal) })).toBe(false) + }) + it('parks a host the moment a poll finds its distro confirmed stopped, never booting it', async () => { const connector = makeConnector([UBUNTU]) const service = makeService(connector) @@ -537,7 +567,12 @@ describe('WslHostService', () => { // The watch just exited and WSL still lists the stopping distro as running. connector.distro.state = 'running' connector.isSettling.mockReturnValue(true) - await expect(service.ensureForRuntimeCall('wsl:ubuntu')).rejects.toThrow('isn’t connected') + // Retryable, so a pane attaching in this window recovers instead of dead-ending; still no boot. + const settling = await service + .ensureForRuntimeCall('wsl:ubuntu') + .catch((error: unknown) => error) + expect(settling).toMatchObject({ code: 'remote_runtime_unavailable' }) + expect(isRecoverableRemoteRuntimeConnectionError({ message: String(settling) })).toBe(true) // Liveness then confirms the stop: the host parks, and polls keep refusing. service.handleConnectionChange('Ubuntu', null, { state: 'distro-stopped', diff --git a/src/main/wsl/wsl-host-service.ts b/src/main/wsl/wsl-host-service.ts index 083d53eb6cd..beb181a932e 100644 --- a/src/main/wsl/wsl-host-service.ts +++ b/src/main/wsl/wsl-host-service.ts @@ -5,6 +5,7 @@ */ import { errorMessage } from '../../shared/error-message' import { focusReachability } from './wsl-host-focus-reachability' +import { rethrowRetryable, settlingConnectFailure } from './wsl-host-connect-failure' import { removeWslHostEnvironment } from '../../shared/runtime-environment-wsl-host-store' import type { WslHostDeploymentRecord } from '../../shared/wsl-host-deployment-store' import type { @@ -246,14 +247,13 @@ export class WslHostService { } // Unconfirmed (running list unreadable, or possibly stale right after an exit) or not // visible: refuse rather than risk a boot; liveness publishes the verdict. - if ( - reachability !== 'unreachable' || - !this.visible.has(environmentId) || - connector.isSettling(record.distro) - ) { + if (reachability !== 'unreachable' || !this.visible.has(environmentId)) { throw new Error(wslHostNotConnectedMessage(record.distro)) } - await connector.connect(record.distro, { noBoot: true }) + if (connector.isSettling(record.distro)) { + throw settlingConnectFailure(record.distro) + } + await connector.connect(record.distro, { noBoot: true }).catch(rethrowRetryable(record.distro)) } /** diff --git a/src/main/wsl/wsl-orcad-connection.test.ts b/src/main/wsl/wsl-orcad-connection.test.ts index 7127a30f964..5aa95548778 100644 --- a/src/main/wsl/wsl-orcad-connection.test.ts +++ b/src/main/wsl/wsl-orcad-connection.test.ts @@ -194,6 +194,19 @@ describe('WslOrcadConnectionManager', () => { requireRunning.mockResolvedValue(true) await expect(manager.ensure('Ubuntu', { requireRunning })).resolves.toBeTruthy() expect(deployAndLaunch).toHaveBeenCalledTimes(1) + // The deploy gets the same check, so its own second wake can refuse too. + const calls: readonly unknown[][] = deployAndLaunch.mock.calls + const deployOptions = calls[0]?.[1] + const recheck = + deployOptions && typeof deployOptions === 'object' && 'requireRunning' in deployOptions + ? deployOptions.requireRunning + : undefined + expect(typeof recheck).toBe('function') + requireRunning.mockClear() + if (typeof recheck === 'function') { + await recheck() + } + expect(requireRunning).toHaveBeenCalledWith('Ubuntu') }) it('a connect that may boot does not ride on an in-flight no-boot one (E9)', async () => { diff --git a/src/main/wsl/wsl-orcad-connection.ts b/src/main/wsl/wsl-orcad-connection.ts index 885939c90e4..526c087595d 100644 --- a/src/main/wsl/wsl-orcad-connection.ts +++ b/src/main/wsl/wsl-orcad-connection.ts @@ -177,7 +177,8 @@ export class WslOrcadConnectionManager { // Taken before any await, so a close during the running-list read still supersedes this. const generation = this.generation(key) const stillCurrent = (): boolean => this.generation(key) === generation - await assertWslDistroMayStart(distro, options.requireRunning, stillCurrent) + const requireRunning = options.requireRunning + await assertWslDistroMayStart(distro, requireRunning, stillCurrent) const environmentId = options.environmentId ?? defaultWslOrcadEnvironmentId(distro) const label = options.label ?? distro const target = { kind: 'wsl', id: environmentId, distro, label } as const @@ -190,7 +191,8 @@ export class WslOrcadConnectionManager { pairedOffer: this.enrolledOffer(key, environmentId), signal: options.signal, holder: this.dependencies.holder, - createTransport: () => transport + createTransport: () => transport, + ...(requireRunning ? { requireRunning: () => requireRunning(distro) } : {}) }) if (launch.outcome === 'deferred') { throw new WslOrcadLaunchDeferredError(launch) diff --git a/src/main/wsl/wsl-orcad-deploy.test.ts b/src/main/wsl/wsl-orcad-deploy.test.ts index bf5506952ea..8f67c8ac3f8 100644 --- a/src/main/wsl/wsl-orcad-deploy.test.ts +++ b/src/main/wsl/wsl-orcad-deploy.test.ts @@ -7,6 +7,8 @@ import { type OrcadActivationRecord } from '../managed-server/activation/orcad-activation-record' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { OrcadActiveReadinessError } from '../ssh/orcad-active-readiness' +import { WslDistroNotRunningError } from './wsl-orcad-connection-errors' import type { WslDistroHolder } from './wsl-distro-holder' import type { WslOrcadHostTarget } from './wsl-orcad-host-target' @@ -230,6 +232,35 @@ describe('deployAndLaunchWslOrcad', () => { expect(mocks.wake).toHaveBeenCalledTimes(1) }) + it('does not wake again when a no-boot connect finds the distro stopped meanwhile', async () => { + mocks.deploy.mockResolvedValueOnce({ outcome: 'already-active', fullVersion: '1.5.0+ab' }) + mocks.wake.mockResolvedValueOnce({ outcome: 'serving' }) + mocks.probe.mockRejectedValueOnce(new OrcadActiveReadinessError('exited', 'process has exited')) + const requireRunning = vi.fn(async () => false) + await expect(launch(undefined, { requireRunning })).rejects.toBeInstanceOf( + WslDistroNotRunningError + ) + expect(requireRunning).toHaveBeenCalledOnce() + expect(mocks.wake).toHaveBeenCalledTimes(1) + }) + + // Real host (win-lowspec, cold systemd distro): the orcad the wake saw serving exited before the probe. + it('starts the slot again when the orcad the wake saw serving exits before the probe', async () => { + mocks.deploy.mockResolvedValueOnce({ outcome: 'already-active', fullVersion: '1.5.0+ab' }) + mocks.wake.mockResolvedValueOnce({ outcome: 'serving' }) + mocks.wake.mockResolvedValueOnce({ outcome: 'started', readiness: READINESS }) + mocks.probe.mockRejectedValueOnce(new OrcadActiveReadinessError('exited', 'process has exited')) + await expect(launch()).resolves.toMatchObject({ outcome: 'ready', port: 6790 }) + expect(mocks.wake).toHaveBeenCalledTimes(2) + + mocks.deploy.mockResolvedValueOnce({ outcome: 'already-active', fullVersion: '1.5.0+ab' }) + mocks.wake.mockResolvedValueOnce({ outcome: 'serving' }) + const refused = new OrcadActiveReadinessError('rejected', 'wrong build') + mocks.probe.mockRejectedValueOnce(refused) + await expect(launch()).rejects.toBe(refused) + expect(mocks.wake).toHaveBeenCalledTimes(3) + }) + // Reproduce failed Remove leaving serving orcad absent from the activation record. it('deploys over an adopted unrecorded orcad under the SSH terminal gate, never a fresh launch', async () => { const adoptedRecord = { diff --git a/src/main/wsl/wsl-orcad-deploy.ts b/src/main/wsl/wsl-orcad-deploy.ts index cc4fe50ff94..70fa1cd9232 100644 --- a/src/main/wsl/wsl-orcad-deploy.ts +++ b/src/main/wsl/wsl-orcad-deploy.ts @@ -26,6 +26,8 @@ import type { OrcadActivationRecord } from '../managed-server/activation/orcad-a import { errorMessage } from '../../shared/error-message' import { pruneManagedOrcadVersions } from '../managed-server/lifecycle/orcad-managed-version-gc' import { wakeAlreadyActiveOrcad } from '../ssh/orcad-already-active-wake' +import { OrcadActiveReadinessError } from '../ssh/orcad-active-readiness' +import { WslDistroNotRunningError } from './wsl-orcad-connection-errors' import { resolveOrcadRemoteContext } from '../managed-server/install/orcad-remote-context' import { deployOrcad } from '../ssh/orcad-remote-deploy' import { ORCAD_PID_FILENAME } from '../managed-server/install/orcad-remote-host-support' @@ -85,6 +87,8 @@ export type WslOrcadDeployOptions = { pairedOffer?: PairingOffer | null /** The orcad template to deploy from; defaults to the packaged one (the C10 lane swaps it). */ templateDir?: string + /** A no-boot connect's running check (E9), re-read before any further wake. */ + requireRunning?: () => Promise } /** Until the persisted deployment record (C7) assigns one, the environment is keyed per distro. */ @@ -243,16 +247,28 @@ async function launchOverTransport( return { outcome: 'deferred', distro: name, ...deferredUpdate } } clearOrcadUpdateFailedThisLaunch(failureHost) + const probe = () => + probeManagedOrcadReadiness(context, localOrcadDir, deployed.fullVersion, signal) + let readiness: ServeReadiness if (deployed.outcome === 'already-active') { // A stopped distro ended the active slot with it. await wakeAlreadyActiveOrcad(slot) + // Why once more: on a distro still booting, the orcad the wake saw serving can exit before + // the probe; that is a stopped slot to start, not a failed connect. + readiness = await probe().catch(async (error: unknown) => { + if (!(error instanceof OrcadActiveReadinessError) || error.verdict !== 'exited') { + throw error + } + // A `wsl --shutdown` during a no-boot connect must not be undone by this second wake. + if (options.requireRunning && !(await options.requireRunning())) { + throw new WslDistroNotRunningError(name) + } + await wakeAlreadyActiveOrcad(slot) + return probe() + }) + } else { + readiness = await probe() } - const readiness = await probeManagedOrcadReadiness( - context, - localOrcadDir, - deployed.fullVersion, - signal - ) await pruneManagedOrcadVersions({ slot, serverTarget: context.serverTarget,