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
This commit is contained in:
Neil
2026-10-26 16:17:38 -07:00
committed by GitHub
parent ca8eb2904e
commit 95ffa4fcce
8 changed files with 218 additions and 15 deletions
@@ -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)
})
})
+53
View File
@@ -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)
}
}
+36 -1
View File
@@ -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',
+6 -6
View File
@@ -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))
}
/**
+13
View File
@@ -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 () => {
+4 -2
View File
@@ -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)
+31
View File
@@ -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 = {
+22 -6
View File
@@ -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<boolean>
}
/** 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,