mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user