fix(orcad): one update decision for SSH and WSL, with connect, Update and Force intents (#31682)

* fix(wsl-host): apply the SSH update rules to a WSL connect

A WSL connect deployed this app's orcad whenever the active build differed,
so a distro another, newer Orca had activated ended in an unconnectable
deferral, and a build the host had rolled back from was re-activated on
every connect. It now runs the same planManagedOrcadAutoUpdate decision SSH
uses and serves the active build when that plan skips.

* fix(wsl-host): serve the skipped-update incumbent only if it is still active

* fix(orcad): one update decision for SSH and WSL, applied again under the fence

The connect's update plan moves to its own module, and the fenced admission
every first deploy and WSL connect uses now applies it, so a transient
readiness failure or a race cannot reactivate a rolled-back build.

* refactor(wsl-host): adopt the serving build through the SSH first-deploy adoption

* fix(wsl-host): do not retry a candidate rejected earlier this launch

A rejected candidate is restored without recording rolledBackFrom, so WSL
retried it on every connect. SSH's launch-scoped failure memory moves to a
shared helper keyed per host, and WSL feeds it to the shared update decision.
A null adoption now re-reads the record before the deploy, as SSH does.

* fix(wsl-host): keep Force offered when a connect skips this app's build

The WSL connect's update admission moves to its own module, which returns a
forceable hint when it serves the incumbent over a rolled-back or failed build.

* fix(orcad): connect, Update and Force are the three update intents on SSH and WSL

Only a connect applies the skips; an explicit Update retries a skipped or failed
build (clearing the failure memory) behind the terminal census; only Force passes
the census. WSL's Update button now sends the update intent instead of a plain
connect. A connect that records a rejection serves the restored incumbent at once.

* fix(orcad): an explicit Update keeps the newer-host skip on SSH and WSL

Update lifts only a host's own skips (rolled back, failed before); Force lifts
all. SSH's explicit update now applies the same fenced admission, so stable
cannot replace a host a newer Orca activated without Force. A rejection retries
adoption only once the incumbent is back and no journal awaits recovery.

* fix(wsl-host): keep Update and Force offered on a deferred cold connect

A connect the host deferred with nothing serving now publishes its deferral, so
a host stopped after a rollback can still be retried from the UI. SSH's explicit
Update forgets a remembered failure only once it lands, and a restore that could
not be read keeps the rejection as reported.

* fix(wsl-host): offer only Force over a host a newer Orca runs, and keep failure memory until an Update lands

A host-newer refusal gets its own deferral code so the WSL row hides Update,
which can never pass it. An explicit Update no longer clears the launch failure
memory up front; only a landed deploy does, as on SSH.

* refactor(wsl-host): merge the intent type into the existing shared imports
This commit is contained in:
Neil
2026-10-21 16:26:35 -07:00
committed by GitHub
parent 10d410455e
commit a57e027cb3
29 changed files with 1017 additions and 120 deletions
+5 -2
View File
@@ -200,8 +200,11 @@ export function registerWslHostHandlers(args: WslHostHandlerArgs): void {
})
ipcMain.handle(
'wslHosts:connect',
(_event, request: { environmentId: string; force?: boolean }) =>
wsl.connect(request.environmentId, { force: request.force === true })
(_event, request: { environmentId: string; intent?: unknown }) =>
wsl.connect(
request.environmentId,
request.intent === 'update' || request.intent === 'force' ? { intent: request.intent } : {}
)
)
ipcMain.handle('wslHosts:getCapability', (): WslHostCapability => ({
serverFiles: wsl.hasServerFiles()
@@ -45,7 +45,7 @@ import { deployOrcad } from '../../ssh/orcad-remote-deploy'
import { rollbackOrcad } from '../install/orcad-remote-rollback'
import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery'
import { wakeStoppedManagedOrcad } from '../lifecycle/orcad-managed-wake'
import { planManagedOrcadAutoUpdate } from '../lifecycle/orcad-managed-auto-update'
import { planManagedOrcadAutoUpdate } from '../lifecycle/orcad-managed-update-decision'
import { parseOrcadActivationRecord } from './orcad-activation-record'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
import type { OrcadHostTransport } from '../../ssh/orcad-host-transport'
@@ -41,9 +41,9 @@ vi.mock('../install/remote-versioned-install', () => ({
import {
autoUpdateManagedOrcadEnvironment,
planManagedOrcadAutoUpdate,
resetBundledOrcadVersionsForTests
} from './orcad-managed-auto-update'
import { planManagedOrcadAutoUpdate } from './orcad-managed-update-decision'
function record(overrides: Partial<OrcadActivationRecord>): OrcadActivationRecord {
return { ...emptyOrcadActivationRecord(), active: '0.1.0+old', ...overrides }
@@ -5,11 +5,10 @@
* terminals, and a rejected candidate is restored through the activation journal, so the old
* version keeps serving. A host a newer Orca activated is never downgraded.
*/
import type { OrcadActivationRecord } from '../activation/orcad-activation-record'
import {
refuseOrcadHostDowngrade,
type OrcadHostVersionRefusal
} from '../../ssh/orcad-host-version-admission'
planManagedOrcadAutoUpdate,
type ManagedOrcadAutoUpdateSkip
} from './orcad-managed-update-decision'
import { materializeOrcadArtifact } from '../../orcad/orcad-artifact-materializer'
import {
OrcadArtifactsUnavailableError,
@@ -25,18 +24,6 @@ import {
import type { OrcadUpdateDeferCode } from '../../ssh/orcad-update-plan'
import { readLocalFullVersion } from '../install/remote-versioned-install'
export type ManagedOrcadAutoUpdateSkip =
| 'current'
| 'no-template'
| 'rolled-back'
| 'failed-before'
| 'migrating'
| OrcadHostVersionRefusal
export type ManagedOrcadAutoUpdatePlan =
| { action: 'update' }
| { action: 'skip'; reason: ManagedOrcadAutoUpdateSkip }
export type ManagedOrcadAutoUpdateOutcome =
| { outcome: 'skipped'; reason: ManagedOrcadAutoUpdateSkip }
| { outcome: 'updated'; activeVersion: string }
@@ -45,35 +32,6 @@ export type ManagedOrcadAutoUpdateOutcome =
/** The candidate was rejected or the update threw; the incumbent keeps serving. */
| { outcome: 'failed'; reason: string }
export function planManagedOrcadAutoUpdate(input: {
record: OrcadActivationRecord
/** This app's bundled build for the host's target; null when the template lacks it. */
candidateVersion: string | null
appVersion: string
/** An update to this app version already failed on this host. */
failedBefore: boolean
}): ManagedOrcadAutoUpdatePlan {
const { record, candidateVersion } = input
if (!candidateVersion) {
return { action: 'skip', reason: 'no-template' }
}
if (record.active === candidateVersion) {
return { action: 'skip', reason: 'current' }
}
if (record.rolledBackFrom === candidateVersion) {
return { action: 'skip', reason: 'rolled-back' }
}
const refusal = refuseOrcadHostDowngrade(record, candidateVersion, input.appVersion)
if (refusal) {
return { action: 'skip', reason: refusal }
}
// Why no `failedBefore` skip on a stopped host: nothing serves, so retrying is the only way back.
if (record.active === null && record.previous !== null) {
return { action: 'update' }
}
return input.failedBefore ? { action: 'skip', reason: 'failed-before' } : { action: 'update' }
}
const WAITING_CODES: ReadonlySet<string> = new Set<
OrcadUpdateDeferCode | typeof ORCAD_ACTIVATION_FENCE_BUSY_CODE
>([
@@ -10,7 +10,7 @@ import { computeLocalOrcadBuildHash } from '../../ssh/orcad-local-build-hash'
import { sshOrcadHostTransport } from '../../ssh/orcad-ssh-host-transport'
import type { OrcadHostTarget } from '../../ssh/orcad-host-transport'
import type { OrcadActivationRecord } from '../activation/orcad-activation-record'
import { refuseOrcadHostDowngrade } from '../../ssh/orcad-host-version-admission'
import { admitOrcadRecordForIntent, type OrcadUpdateIntent } from './orcad-managed-update-decision'
import {
type OrcadTerminalCensus,
UNVERIFIABLE_ORCAD_TERMINAL_CENSUS
@@ -129,7 +129,7 @@ export function probeManagedOrcadReadiness(
export function managedOrcadDeployPolicy(
context: Pick<OrcadRemoteContext<OrcadHostTarget>, 'activationRecord'>,
appVersion: string,
force?: boolean
intent: OrcadUpdateIntent
): {
census: OrcadTerminalCensus
recountCensus: () => Promise<OrcadTerminalCensus>
@@ -148,14 +148,10 @@ export function managedOrcadDeployPolicy(
// Nothing was fenced: this recount never asks the server.
releaseIncumbentAdmission: async () => {},
censusRecord: context.activationRecord,
// Why: a host another desktop stopped may hold state its newer build migrated, and this
// path has no linked record to check first, so only the fenced read can refuse it.
// An explicit force runs this version anyway, as `environment update --force` does.
admitRecord: (record, candidateVersion) =>
!force && refuseOrcadHostDowngrade(record, candidateVersion, appVersion)
? `Orca ${record.activeAppVersion} last ran this host's server, newer than this ` +
`Orca (${appVersion}). Update Orca to start it.`
: null
// Why the intent's plan: a host another desktop stopped may hold state its newer build
// migrated, and a build the host rolled back from must not return on a connect; this path has
// no linked record to check first, so only the fenced read can refuse.
admitRecord: admitOrcadRecordForIntent(intent, appVersion)
}
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { emptyOrcadActivationRecord } from '../activation/orcad-activation-record'
import {
admitOrcadRecordForIntent,
planOrcadUpdateForIntent
} from './orcad-managed-update-decision'
const rolledBack = {
...emptyOrcadActivationRecord(),
active: '1.4.0+aa',
activeAppVersion: '1.4.0',
rolledBackFrom: '1.5.0+ab'
}
const newer = { ...emptyOrcadActivationRecord(), active: '2.0.0+cd', activeAppVersion: '2.0.0' }
const input = { candidateVersion: '1.5.0+ab', appVersion: '1.5.0', failedBefore: true }
describe('the three update intents', () => {
it('skips a build this host refused on a connect only', () => {
expect(planOrcadUpdateForIntent('connect', { ...input, record: rolledBack })).toEqual({
action: 'skip',
reason: 'rolled-back'
})
expect(planOrcadUpdateForIntent('update', { ...input, record: rolledBack })).toEqual({
action: 'update'
})
expect(planOrcadUpdateForIntent('force', { ...input, record: rolledBack })).toEqual({
action: 'update'
})
})
it('keeps a newer host on Update, and lifts it only on Force', () => {
expect(planOrcadUpdateForIntent('update', { ...input, record: newer })).toEqual({
action: 'skip',
reason: 'host-newer'
})
expect(admitOrcadRecordForIntent('update', '1.5.0')(newer, '1.5.0+ab')).toMatch(
/Orca 2\.0\.0 last ran/
)
expect(admitOrcadRecordForIntent('force', '1.5.0')(newer, '1.5.0+ab')).toBeNull()
expect(admitOrcadRecordForIntent('update', '1.5.0')(rolledBack, '1.5.0+ab')).toBeNull()
})
})
@@ -0,0 +1,109 @@
/**
* Whether a connect may put this app's orcad build on a host, shared by SSH and WSL: a host a
* newer Orca activated is never downgraded, and a build the host rolled back from or that already
* failed is not retried. The fenced activation applies it again, so a race cannot bypass it.
*/
import type { OrcadActivationRecord } from '../activation/orcad-activation-record'
import {
refuseOrcadHostDowngrade,
type OrcadHostVersionRefusal
} from '../../ssh/orcad-host-version-admission'
export type ManagedOrcadAutoUpdateSkip =
| 'current'
| 'no-template'
| 'rolled-back'
| 'failed-before'
| 'migrating'
| OrcadHostVersionRefusal
/**
* Why a host's orcad build may change, the same three for SSH and WSL. A connect applies every
* skip. An explicit Update lifts only this host's own skips (rolled back, failed before), still
* behind the terminal census, so it never downgrades a host a newer Orca runs. Force lifts every
* skip and passes the census.
*/
export type OrcadUpdateIntent = 'connect' | 'update' | 'force'
export type ManagedOrcadAutoUpdatePlan =
| { action: 'update' }
| { action: 'skip'; reason: ManagedOrcadAutoUpdateSkip }
export function planManagedOrcadAutoUpdate(input: {
record: OrcadActivationRecord
/** This app's bundled build for the host's target; null when the template lacks it. */
candidateVersion: string | null
appVersion: string
/** An update to this app version already failed on this host. */
failedBefore: boolean
}): ManagedOrcadAutoUpdatePlan {
const { record, candidateVersion } = input
if (!candidateVersion) {
return { action: 'skip', reason: 'no-template' }
}
if (record.active === candidateVersion) {
return { action: 'skip', reason: 'current' }
}
if (record.rolledBackFrom === candidateVersion) {
return { action: 'skip', reason: 'rolled-back' }
}
const refusal = refuseOrcadHostDowngrade(record, candidateVersion, input.appVersion)
if (refusal) {
return { action: 'skip', reason: refusal }
}
// Why no `failedBefore` skip on a stopped host: nothing serves, so retrying is the only way back.
if (record.active === null && record.previous !== null) {
return { action: 'update' }
}
return input.failedBefore ? { action: 'skip', reason: 'failed-before' } : { action: 'update' }
}
export function planOrcadUpdateForIntent(
intent: OrcadUpdateIntent,
input: Parameters<typeof planManagedOrcadAutoUpdate>[0]
): ManagedOrcadAutoUpdatePlan {
if (intent === 'force') {
return { action: 'update' }
}
if (intent === 'connect') {
return planManagedOrcadAutoUpdate(input)
}
const { rolledBackFrom: _retried, ...record } = input.record
return planManagedOrcadAutoUpdate({ ...input, record, failedBefore: false })
}
/** The fenced activation's admission for an intent: why it refuses the record, or null. */
export function admitOrcadRecordForIntent(
intent: OrcadUpdateIntent,
appVersion: string
): (record: OrcadActivationRecord, candidateVersion: string) => string | null {
return (record, candidateVersion) =>
orcadUpdateSkipRefusal(
planOrcadUpdateForIntent(intent, {
record,
candidateVersion,
appVersion,
failedBefore: false
}),
record,
candidateVersion,
appVersion
)
}
/** Why a fenced activation refuses a build the plan skips; null when it may activate. */
export function orcadUpdateSkipRefusal(
plan: ManagedOrcadAutoUpdatePlan,
record: OrcadActivationRecord,
candidateVersion: string,
appVersion: string
): string | null {
if (plan.action !== 'skip' || plan.reason === 'current') {
return null
}
return plan.reason === 'rolled-back'
? `This host's server rolled back from ${candidateVersion}, so a connect does not activate ` +
'it again; choose Update or Force to retry it.'
: `Orca ${record.activeAppVersion} last ran this host's server, newer than this ` +
`Orca (${appVersion}). Update Orca to start it.`
}
@@ -0,0 +1,38 @@
/**
* Hosts whose orcad update to this app's build failed during this launch, SSH and WSL alike: the
* update decision's `failedBefore` input. Session-only, so a restart retries once.
*/
export type OrcadUpdateFailureHost = { kind: 'ssh' | 'wsl'; id: string }
export type OrcadUpdateFailure = { code: string; reason: string }
const failedThisLaunch = new Map<string, OrcadUpdateFailure>()
function key(host: OrcadUpdateFailureHost): string {
return `${host.kind}:${host.id}`
}
export function recordOrcadUpdateFailedThisLaunch(
host: OrcadUpdateFailureHost,
failure: OrcadUpdateFailure
): void {
failedThisLaunch.set(key(host), failure)
}
export function orcadUpdateFailureThisLaunch(
host: OrcadUpdateFailureHost
): OrcadUpdateFailure | null {
return failedThisLaunch.get(key(host)) ?? null
}
export function orcadUpdateFailedThisLaunch(host: OrcadUpdateFailureHost): boolean {
return failedThisLaunch.has(key(host))
}
export function clearOrcadUpdateFailedThisLaunch(host: OrcadUpdateFailureHost): void {
failedThisLaunch.delete(key(host))
}
export function resetOrcadUpdateFailuresForTests(): void {
failedThisLaunch.clear()
}
@@ -197,7 +197,7 @@ async function deployOwnOrcad(
...slot,
localOrcadDir,
target: context.serverTarget,
...managedOrcadDeployPolicy(context, appVersion, args.force),
...managedOrcadDeployPolicy(context, appVersion, args.force ? 'force' : 'connect'),
force: args.force,
appVersion
})
+11 -6
View File
@@ -3,9 +3,10 @@ import { getAppEnvironment } from '../../shared/app-environment'
import type { ManagedServerUpdateDeps } from './managed-server-update-check'
import { autoUpdateManagedOrcadEnvironment } from '../managed-server/lifecycle/orcad-managed-auto-update'
import { requireManagedOrcadTargetStore } from '../managed-server/lifecycle/orcad-managed-runtime-context'
// Hosts whose update failed this launch. Session-only: SshTarget is published to peers.
const failedThisLaunch = new Set<string>()
import {
orcadUpdateFailedThisLaunch,
recordOrcadUpdateFailedThisLaunch
} from '../managed-server/lifecycle/orcad-managed-update-failure-memory'
export function managedServerUpdateDeps(userDataPath: string): ManagedServerUpdateDeps {
const registry = requireManagedOrcadTargetStore()
@@ -18,10 +19,14 @@ export function managedServerUpdateDeps(userDataPath: string): ManagedServerUpda
const failure = (registry.getTarget(target.id) ?? target).managedServerUpdateFailure
return failure?.appVersion === appVersion ? failure.reason : null
},
// Why only a failure counts: a deferral or skip never tried the update.
failedThisLaunch: (target) => failedThisLaunch.has(target.id),
// Why only a failure counts: a deferral or skip never tried the update. Why session-only:
// SshTarget is published to peers.
failedThisLaunch: (target) => orcadUpdateFailedThisLaunch({ kind: 'ssh', id: target.id }),
recordUpdateFailure: (target, reason) => {
failedThisLaunch.add(target.id)
recordOrcadUpdateFailedThisLaunch(
{ kind: 'ssh', id: target.id },
{ code: 'orcad_update_failed', reason }
)
registry.updateTarget(target.id, { managedServerUpdateFailure: { reason, appVersion } })
},
clearUpdateFailure: (target) => {
@@ -3,6 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { listEnvironments } from '../../shared/runtime-environment-store'
import { ORCAD_UPDATE_ADMISSION_FENCE_MS } from '../../shared/orcad-terminal-census'
import { recordManagedOrcadMigration } from '../../shared/runtime-environment-managed-orcad-store'
import {
orcadUpdateFailedThisLaunch,
recordOrcadUpdateFailedThisLaunch
} from '../managed-server/lifecycle/orcad-managed-update-failure-memory'
import { orcadMigrationCutoverFixture } from './orcad-migration-cutover-fixture'
import { writeOrcadMigrationSourceCutover } from './orcad-migration-cutover-journal'
import {
@@ -146,6 +150,53 @@ describe('updateManagedOrcadEnvironment', () => {
expect(status.deferredUpdate).toBeNull()
})
it('keeps a connect from retrying a build an explicit Update also failed', async () => {
const host = { kind: 'ssh', id: 'ssh-1' } as const
recordOrcadUpdateFailedThisLaunch(host, { code: 'orcad_update_failed', reason: 'rejected' })
mocks.deploy.mockResolvedValueOnce({
outcome: 'installed-not-activated',
fullVersion: 'v3',
code: 'orcad_candidate_readiness_rejected',
reason: 'rejected again'
})
await updateManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' })
expect(orcadUpdateFailedThisLaunch(host)).toBe(true)
})
it('refuses Update over a host a newer Orca runs, and lets Force take it', async () => {
const newer = {
...harness.context().activationRecord,
active: '9.0.0+newer',
activeAppVersion: '99.0.0'
}
mocks.deploy.mockResolvedValue({ outcome: 'installed-and-activated', fullVersion: 'v3' })
await updateManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' })
expect(mocks.deploy.mock.calls[0]?.[0].admitRecord(newer, 'v3')).toMatch(/last ran/)
await updateManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed', force: true })
expect(mocks.deploy.mock.calls[1]?.[0].admitRecord(newer, 'v3')).toBeNull()
})
it('offers Force when Update was refused by policy', async () => {
mocks.deploy.mockResolvedValueOnce({
outcome: 'installed-not-activated',
fullVersion: 'v3',
code: 'orcad_activation_policy_refused',
reason: 'newer'
})
await expect(
updateManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' })
).resolves.toMatchObject({ outcome: 'deferred', forceable: true })
})
it('lets an explicit Update retry a build a connect remembered as failed', async () => {
const host = { kind: 'ssh', id: 'ssh-1' } as const
recordOrcadUpdateFailedThisLaunch(host, { code: 'orcad_update_failed', reason: 'rejected' })
mocks.deploy.mockResolvedValueOnce({ outcome: 'installed-and-activated', fullVersion: 'v3' })
await updateManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' })
expect(orcadUpdateFailedThisLaunch(host)).toBe(false)
expect(mocks.deploy.mock.calls[0]?.[0]).toMatchObject({ census: idle })
})
it('hands the deploy a recount that counts again, so a terminal opened during the upload counts', async () => {
const opened = { ...idle, liveSessions: 1, inProcessSessions: 1 }
mocks.census.mockResolvedValueOnce(idle).mockResolvedValue(opened)
+17 -5
View File
@@ -40,6 +40,8 @@ import { readRemoteOrcadBuildHash } from '../managed-server/install/orcad-remote
import { deployOrcad, type OrcadDeployOptions } from './orcad-remote-deploy'
import { ORCAD_ACTIVATION_POLICY_REFUSED_CODE } from './orcad-installed-activation'
import { pruneManagedOrcadVersions } from '../managed-server/lifecycle/orcad-managed-version-gc'
import { clearOrcadUpdateFailedThisLaunch } from '../managed-server/lifecycle/orcad-managed-update-failure-memory'
import { admitOrcadRecordForIntent } from '../managed-server/lifecycle/orcad-managed-update-decision'
import { rollbackOrcad } from '../managed-server/install/orcad-remote-rollback'
import { collectManagedTerminalCensus } from './orcad-terminal-census-client'
import { updateTerminalCounts } from './orcad-fenced-stop'
@@ -94,14 +96,21 @@ export function updateManagedOrcadEnvironment(
userDataPath: string,
args: LifecycleArgs & { force?: boolean }
): Promise<OrcadManagedDeployResult> {
return withManagedOrcadLifecycle(userDataPath, args.selector, async (managed) =>
runManagedOrcadUpdate(
const intent = args.force ? 'force' : 'update'
return withManagedOrcadLifecycle(userDataPath, args.selector, async (managed) => {
const admitRecord = admitOrcadRecordForIntent(intent, getAppEnvironment().getVersion())
const result = await runManagedOrcadUpdate(
userDataPath,
managed,
await resolveLinkedOrcadContext(managed.environment, managed.deployment, args.signal),
args
{ ...args, admitRecord }
)
)
// Only a landed Update re-enables the connect's attempts; a failed retry stays remembered.
if (result.outcome !== 'deferred') {
clearOrcadUpdateFailedThisLaunch({ kind: 'ssh', id: managed.deployment.sshTargetId })
}
return result
})
}
/** The Managed servers update, run inside the target's lifecycle queue with a resolved context. */
@@ -147,7 +156,10 @@ export async function runManagedOrcadUpdate(
candidateVersion: result.fullVersion,
code: result.code,
reason: result.reason,
forceable: isForceableOrcadDeferral(result.code)
// A policy refusal is what Force exists to pass, e.g. over a host a newer Orca runs.
forceable:
isForceableOrcadDeferral(result.code) ||
result.code === ORCAD_ACTIVATION_POLICY_REFUSED_CODE
}
// A policy refusal is a decision about this host, not an update waiting to be retried.
if (result.code !== ORCAD_ACTIVATION_POLICY_REFUSED_CODE) {
@@ -6,7 +6,7 @@ import {
withDeactivatedVersionCommitted,
type OrcadActivationRecord
} from '../managed-server/activation/orcad-activation-record'
import { planManagedOrcadAutoUpdate } from '../managed-server/lifecycle/orcad-managed-auto-update'
import { planManagedOrcadAutoUpdate } from '../managed-server/lifecycle/orcad-managed-update-decision'
import { compareAppVersions } from '../../shared/app-version'
import {
MANAGED_ORCAD_FENCED_DETAIL,
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import type { WslHostDeploymentRecord } from '../../shared/wsl-host-deployment-store'
import { toWslHostStatus } from './wsl-host-connection-record'
const record: WslHostDeploymentRecord = {
kind: 'wsl',
distro: 'Ubuntu',
localPort: 41_000,
remotePort: 6_768,
environmentId: 'wsl:ubuntu',
createdAt: 1
}
const deferredUpdate = {
candidateVersion: '1.5.0+ab',
code: 'orcad_activation_policy_refused',
reason: 'rolled back',
forceable: true
}
describe('toWslHostStatus', () => {
it('keeps the deferral of a connect that deferred, so Update and Force stay offered', () => {
expect(
toWslHostStatus(record, {
state: 'failed',
reason: 'rolled back',
terminals: 'unverifiable',
deferredUpdate
}).deferredUpdate
).toEqual(deferredUpdate)
expect(
toWslHostStatus(record, { state: 'failed', reason: 'x', terminals: 'unverifiable' })
.deferredUpdate
).toBeNull()
expect(toWslHostStatus(record, { state: 'connecting' }).deferredUpdate).toBeNull()
})
})
+8 -2
View File
@@ -33,7 +33,13 @@ export function toWslHostStatus(
distro: record.distro,
state: status.state,
detail: status.state === 'failed' || status.state === 'unverifiable' ? status.reason : null,
// Only a ready host is serving the incumbent the deferral describes.
deferredUpdate: status.state === 'ready' ? (connection?.deferredUpdate ?? null) : null
// A ready host serves the incumbent the deferral describes; a deferred connect serves nothing,
// and its deferral is what offers Update or Force.
deferredUpdate:
status.state === 'ready'
? (connection?.deferredUpdate ?? null)
: status.state === 'failed'
? (status.deferredUpdate ?? null)
: null
}
}
+2 -2
View File
@@ -253,9 +253,9 @@ describe('WslHostService', () => {
deferredUpdate: null
})
await service.connect('wsl:ubuntu', { force: true })
await service.connect('wsl:ubuntu', { intent: 'force' })
expect(connector.connect).toHaveBeenLastCalledWith('Ubuntu', {
force: true
intent: 'force'
})
})
+8 -4
View File
@@ -11,6 +11,7 @@ import {
} from '../../shared/wsl-host-deployment-store'
import type {
WslHostAddResult,
WslHostConnectIntent,
WslHostDistro,
WslHostServerStop,
WslHostStatus
@@ -33,8 +34,8 @@ export type WslHostConnector = {
listDistros(): Promise<WslHostDistroCandidate[]>
/** Runs in the distro, so it boots a stopped one: only after the user chose to. */
readOrcadPublicKey(distro: string): Promise<string | null>
/** User-initiated; may boot the distro. `force` takes an update live terminals deferred. */
connect(distro: string, options?: { force?: boolean }): Promise<WslOrcadConnection>
/** User-initiated; may boot the distro. An intent retries or forces a skipped update. */
connect(distro: string, options?: { intent?: WslHostConnectIntent }): Promise<WslOrcadConnection>
get(distro: string): WslOrcadConnection | null
/** Checks the paired server without starting a stopped or unconfirmed distro. */
isReachable(distro: string, environmentId: string): Promise<boolean>
@@ -203,8 +204,11 @@ export class WslHostService {
}
}
/** The user asked for this host: may boot its distro. `force` takes a deferred update now. */
async connect(environmentId: string, options: { force?: boolean } = {}): Promise<void> {
/** The user asked for this host: may boot its distro. An intent retries or forces an update. */
async connect(
environmentId: string,
options: { intent?: WslHostConnectIntent } = {}
): Promise<void> {
const record = this.recordForEnvironment(environmentId)
if (!record) {
throw new Error('This server is not a WSL host.')
+24 -1
View File
@@ -1,4 +1,5 @@
import type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census'
import type { WslHostConnectIntent, WslHostDeferredUpdate } from '../../shared/wsl-host-types'
import type { WslLivenessUnverifiableReason } from './wsl-orcad-liveness-verdict'
/**
@@ -30,5 +31,27 @@ export type WslOrcadConnectionStatus =
}
/** orcad stopped (crash or managed idle exit) with nothing user-visible on it; not relaunched. */
| { state: 'idle'; terminals: 'unverifiable' }
| { state: 'failed'; reason: string; terminals: 'exited' | 'unverifiable' }
| {
state: 'failed'
reason: string
terminals: 'exited' | 'unverifiable'
/** A connect the host deferred: what Update or Force would retry, since nothing serves. */
deferredUpdate?: WslHostDeferredUpdate
}
| { state: 'closed' }
/** What a user-initiated connect asks of the host's update. */
export type WslOrcadConnectIntent = { intent?: WslHostConnectIntent }
/** A connect that failed; one the host deferred keeps its deferral, the retry nothing else offers. */
export function wslConnectFailedStatus(
reason: string,
deferral: WslHostDeferredUpdate | null
): WslOrcadConnectionStatus {
if (!deferral) {
return { state: 'failed', reason, terminals: 'unverifiable' }
}
const { candidateVersion, code, reason: why, forceable } = deferral
const deferredUpdate = { candidateVersion, code, reason: why, forceable }
return { state: 'failed', reason, terminals: 'unverifiable', deferredUpdate }
}
+20 -6
View File
@@ -220,10 +220,10 @@ describe('WslOrcadConnectionManager', () => {
const incumbent = await manager.ensure('Ubuntu')
expect(incumbent.deferredUpdate).toEqual(deferredUpdate)
launches.push({ ...ready(first.port), fullVersion: '2.0.0' })
const updated = await manager.ensure('Ubuntu', { force: true })
const updated = await manager.ensure('Ubuntu', { intent: 'force' })
expect(deployAndLaunch).toHaveBeenLastCalledWith(
'Ubuntu',
expect.objectContaining({ force: true })
expect.objectContaining({ intent: 'force' })
)
expect(updated).toMatchObject({
fullVersion: '2.0.0',
@@ -238,19 +238,19 @@ describe('WslOrcadConnectionManager', () => {
it('runs a forced connect after a plain one in flight instead of joining it', async () => {
const { manager, deployAndLaunch } = setup()
const plain = manager.ensure('Ubuntu')
const forced = manager.ensure('Ubuntu', { force: true })
const forced = manager.ensure('Ubuntu', { intent: 'force' })
await Promise.all([plain, forced])
expect(deployAndLaunch).toHaveBeenCalledTimes(2)
expect(deployAndLaunch).toHaveBeenLastCalledWith(
'Ubuntu',
expect.objectContaining({ force: true })
expect.objectContaining({ intent: 'force' })
)
})
it('drops a queued forced connect that a close overtook', async () => {
const { manager, deployAndLaunch } = setup()
const plain = manager.ensure('Ubuntu')
const forced = manager.ensure('Ubuntu', { force: true })
const forced = manager.ensure('Ubuntu', { intent: 'force' })
await manager.close('Ubuntu')
await plain.catch(() => {})
await expect(forced).rejects.toBeInstanceOf(WslOrcadConnectionSupersededError)
@@ -258,7 +258,7 @@ describe('WslOrcadConnectionManager', () => {
})
it('throws the deferral and starts no bridge', async () => {
const { manager, launches, children } = setup()
const { manager, launches, children, onChange } = setup()
launches.push({
outcome: 'deferred',
distro: 'Ubuntu',
@@ -270,6 +270,20 @@ describe('WslOrcadConnectionManager', () => {
await expect(manager.ensure('Ubuntu')).rejects.toBeInstanceOf(WslOrcadLaunchDeferredError)
expect(children.commands).toHaveLength(0)
expect(manager.get('Ubuntu')).toBeNull()
// Nothing serves, so the deferral is what offers Update or Force.
expect(onChange).toHaveBeenLastCalledWith(
'Ubuntu',
null,
expect.objectContaining({
state: 'failed',
deferredUpdate: {
candidateVersion: '2.0.0',
code: 'live_terminals',
reason: 'Terminals are running.',
forceable: true
}
})
)
})
it('refuses a foreign server behind the port and closes its bridge', async () => {
+12 -10
View File
@@ -4,6 +4,7 @@
* distro; a relaunch that moved orcad's port moves the bridge with it. In memory only: the
* deployment record (C7) persists what `onChange` reports.
*/
import type { WslHostConnectIntent } from '../../shared/wsl-host-types'
import { errorMessage } from '../../shared/error-message'
import { parsePairingCode, type PairingOffer } from '../../shared/pairing'
import type { ServeReadiness } from '../server/serve-readiness'
@@ -11,7 +12,10 @@ import type { OrcadHostTransport } from '../ssh/orcad-host-transport'
import type { OrcadTunnelIdentity } from '../managed-server/lifecycle/orcad-managed-tunnel-identity'
import type { StartedPortForward } from '../ssh/port-forwarding/ssh-port-forward-provider'
import type { WslDistroHolder } from './wsl-distro-holder'
import type { WslOrcadConnectionStatus } from './wsl-orcad-connection-status'
import {
wslConnectFailedStatus,
type WslOrcadConnectionStatus
} from './wsl-orcad-connection-status'
import { wslDistroHolderKey } from './wsl-distro-holder-record'
import {
defaultWslOrcadEnvironmentId,
@@ -68,7 +72,7 @@ export type WslOrcadConnectOptions = {
label?: string
/** Rebinds a port a persisted pairing offer already names; 0 or absent picks a free one. */
localPort?: number
force?: boolean
intent?: WslHostConnectIntent
signal?: AbortSignal
/** What `onChange` reports while this connect runs and once it is ready (liveness recovery). */
status?: WslOrcadConnectStatus
@@ -118,10 +122,10 @@ export class WslOrcadConnectionManager {
const key = wslDistroHolderKey(name)
const pending = this.inFlight.get(key)
if (pending) {
if (!options.force) {
if (!options.intent) {
return pending
}
// Force needs its own deploy; joining a plain connect would silently drop it.
// An Update or Force needs its own deploy; joining a plain connect would silently drop it.
const generation = this.generation(key)
return pending
.catch(() => {})
@@ -140,11 +144,9 @@ export class WslOrcadConnectionManager {
const operation = this.connect(name, key, options, status).catch((error: unknown) => {
// Liveness owns the status of its own reconnects; a superseded connect was closed.
if (!options.status && !(error instanceof WslOrcadConnectionSupersededError)) {
this.dependencies.onChange?.(name, this.get(name), {
state: 'failed',
reason: errorMessage(error),
terminals: 'unverifiable'
})
const deferral = error instanceof WslOrcadLaunchDeferredError ? error.launch : null
const failed = wslConnectFailedStatus(errorMessage(error), deferral)
this.dependencies.onChange?.(name, this.get(name), failed)
}
throw error
})
@@ -211,7 +213,7 @@ export class WslOrcadConnectionManager {
const launch = await (this.dependencies.deployAndLaunch ?? deployAndLaunchWslOrcad)(distro, {
environmentId,
label,
force: options.force,
intent: options.intent,
signal: options.signal,
holder: this.dependencies.holder,
createTransport: () => transport
+348 -3
View File
@@ -2,7 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { OrcadHostTransport } from '../ssh/orcad-host-transport'
import type * as ManagedRuntimeContext from '../managed-server/lifecycle/orcad-managed-runtime-context'
import type * as RemoteVersionedInstall from '../managed-server/install/remote-versioned-install'
import { emptyOrcadActivationRecord } from '../managed-server/activation/orcad-activation-record'
import {
emptyOrcadActivationRecord,
type OrcadActivationRecord
} from '../managed-server/activation/orcad-activation-record'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import type { WslDistroHolder } from './wsl-distro-holder'
import type { WslOrcadHostTarget } from './wsl-orcad-host-target'
@@ -18,8 +21,13 @@ const mocks = vi.hoisted(() => ({
wake: vi.fn(),
adopt: vi.fn(),
incumbent: vi.fn(),
adoptRunning: vi.fn(),
localVersion: vi.fn(),
readRecord: vi.fn()
readRecord: vi.fn(),
transaction: vi.fn(async (): Promise<unknown> => null)
}))
vi.mock('../managed-server/activation/orcad-activation-transaction-store', () => ({
readOrcadActivationTransaction: mocks.transaction
}))
vi.mock('../managed-server/activation/orcad-activation-record-store', () => ({
readOrcadActivationRecord: mocks.readRecord
@@ -29,6 +37,9 @@ vi.mock('../managed-server/install/remote-versioned-install', async (importOrigi
readLocalFullVersion: mocks.localVersion
}))
vi.mock('./wsl-orcad-instance-adoption', () => ({ adoptUnrecordedWslOrcad: mocks.adopt }))
vi.mock('../ssh/orcad-running-version-adoption', () => ({
adoptRunningOrcadVersion: mocks.adoptRunning
}))
vi.mock('../../shared/app-environment', () => ({
getAppEnvironment: () => ({ getVersion: () => '1.5.0', getPath: () => '/user-data' })
@@ -56,6 +67,9 @@ vi.mock('../managed-server/lifecycle/orcad-managed-runtime-context', async (impo
}))
import { deployAndLaunchWslOrcad, isWsl1KernelRelease } from './wsl-orcad-deploy'
import { resetOrcadUpdateFailuresForTests } from '../managed-server/lifecycle/orcad-managed-update-failure-memory'
beforeEach(() => resetOrcadUpdateFailuresForTests())
const host = getRemoteHostPlatform('linux-x64')
const READINESS = {
@@ -390,7 +404,7 @@ describe('deployAndLaunchWslOrcad across an app update (E1)', () => {
fullVersion: '1.5.0+ab',
verdict: {}
})
await launch(undefined, { force: true })
await launch(undefined, { intent: 'force' })
expect(mocks.deploy.mock.calls[0][0]).toMatchObject({
force: true,
census: LIVE
@@ -431,6 +445,337 @@ describe('deployAndLaunchWslOrcad across an app update (E1)', () => {
})
})
describe('the SSH auto-update rules', () => {
const newer = {
...activeOld,
active: '2.0.0+cd',
activeAppVersion: '2.0.0',
previous: '1.5.0+ab'
}
const NEWER_READINESS = { ...INCUMBENT_READINESS, runtimeId: 'r-new' }
const policyRefused = {
outcome: 'installed-not-activated',
fullVersion: '1.5.0+ab',
code: 'orcad_activation_policy_refused',
reason: 'newer Orca ran this host'
}
function withRecord(record: OrcadActivationRecord): void {
mocks.resolveContext.mockImplementation(async (target: WslOrcadHostTarget, connection) => ({
activationRecord: record,
serverTarget: 'linux-x64-glibc',
connection,
host,
remoteHome: '/home/u',
target,
userDataDir: '/home/u/.orca'
}))
mocks.readRecord.mockResolvedValue(record)
}
const adoptedAs = (version: string, readiness: typeof INCUMBENT_READINESS) => ({
version,
readiness,
reprobe: async () => readiness
})
it('serves a newer build another Orca activated instead of deploying over it', async () => {
withRecord(newer)
mocks.deploy.mockResolvedValue(policyRefused)
mocks.adoptRunning.mockResolvedValueOnce(adoptedAs('2.0.0+cd', NEWER_READINESS))
await expect(launch()).resolves.toEqual({
outcome: 'ready',
distro: 'Ubuntu-24.04',
distroId: '0283592d-be56-40d4-b935-3dc18c3aa007',
port: 6791,
fullVersion: '2.0.0+cd',
pid: 4242,
readiness: NEWER_READINESS,
holder: 'spawned'
})
expect(mocks.adoptRunning).toHaveBeenCalledWith(expect.anything(), newer, '1.5.0+ab')
expect(mocks.deploy).not.toHaveBeenCalled()
expect(mocks.incumbent).not.toHaveBeenCalled()
expect(mocks.prune).not.toHaveBeenCalled()
expect(mocks.exec).toHaveBeenLastCalledWith(
expect.anything(),
"cat '/home/u/.orca-remote/orcad-2.0.0+cd/.orcad-pid' 2>/dev/null || true",
expect.anything()
)
})
it('does not re-activate the build this host rolled back from', async () => {
withRecord({ ...activeOld, rolledBackFrom: '1.5.0+ab' })
mocks.deploy.mockResolvedValue({
outcome: 'installed-and-activated',
fullVersion: '1.5.0+ab',
verdict: {}
})
mocks.adoptRunning.mockResolvedValueOnce(adoptedAs('1.4.0+aa', INCUMBENT_READINESS))
await expect(launch()).resolves.toMatchObject({
outcome: 'ready',
fullVersion: '1.4.0+aa',
readiness: INCUMBENT_READINESS,
// Force stays offered: it is the one explicit retry.
deferredUpdate: {
candidateVersion: '1.5.0+ab',
code: 'orcad_activation_policy_refused',
reason: expect.stringMatching(/rolled back from 1\.5\.0\+ab/),
forceable: true
}
})
expect(mocks.deploy).not.toHaveBeenCalled()
})
it('labels the launch with the build the adoption found serving, not the stale record', async () => {
withRecord(newer)
mocks.adoptRunning.mockResolvedValueOnce(adoptedAs('2.1.0+ef', NEWER_READINESS))
await expect(launch()).resolves.toMatchObject({ outcome: 'ready', fullVersion: '2.1.0+ef' })
})
it('defers while another run holds the host fence', async () => {
withRecord(newer)
mocks.adoptRunning.mockResolvedValueOnce({
outcome: 'deferred',
candidateVersion: '1.5.0+ab',
code: 'orcad_activation_fence_busy',
reason: 'busy'
})
await expect(launch()).resolves.toEqual({
outcome: 'deferred',
distro: 'Ubuntu-24.04',
candidateVersion: '1.5.0+ab',
code: 'orcad_activation_fence_busy',
reason: 'busy',
forceable: false
})
expect(mocks.deploy).not.toHaveBeenCalled()
})
it('lets an explicit force deploy past both skips', async () => {
withRecord({ ...newer, rolledBackFrom: '1.5.0+ab' })
mocks.deploy.mockResolvedValueOnce({
outcome: 'installed-and-activated',
fullVersion: '1.5.0+ab',
verdict: {}
})
await expect(launch(undefined, { intent: 'force' })).resolves.toMatchObject({
outcome: 'ready',
fullVersion: '1.5.0+ab'
})
expect(mocks.adoptRunning).not.toHaveBeenCalled()
})
it('refuses the rolled-back build under the fence when nothing could be adopted', async () => {
const rolledBack = { ...activeOld, rolledBackFrom: '1.5.0+ab' }
withRecord(rolledBack)
mocks.deploy.mockResolvedValueOnce(policyRefused)
mocks.adoptRunning.mockResolvedValueOnce(null)
await expect(launch()).resolves.toMatchObject({ outcome: 'deferred', forceable: true })
const { admitRecord } = mocks.deploy.mock.calls[0][0]
expect(admitRecord(rolledBack, '1.5.0+ab')).toMatch(/rolled back from 1\.5\.0\+ab/)
expect(admitRecord(activeOld, '1.5.0+ab')).toBeNull()
expect(admitRecord(newer, '1.5.0+ab')).toMatch(/Orca 2\.0\.0 last ran/)
})
const rejected = {
outcome: 'installed-not-activated',
fullVersion: '1.5.0+ab',
code: 'orcad_candidate_readiness_rejected',
reason: 'the candidate never became ready; the incumbent was restored'
}
it('does not retry a candidate rejected earlier this launch, and serves the incumbent', async () => {
mocks.deploy.mockResolvedValue(rejected)
await expect(launch()).resolves.toMatchObject({ outcome: 'deferred' })
mocks.adoptRunning.mockResolvedValueOnce(adoptedAs('1.4.0+aa', INCUMBENT_READINESS))
await expect(launch()).resolves.toMatchObject({
outcome: 'ready',
fullVersion: '1.4.0+aa',
deferredUpdate: {
candidateVersion: '1.5.0+ab',
code: 'orcad_candidate_readiness_rejected',
reason: rejected.reason,
forceable: true
}
})
expect(mocks.deploy).toHaveBeenCalledTimes(1)
// Force is the explicit retry.
await launch(undefined, { intent: 'force' })
expect(mocks.deploy).toHaveBeenCalledTimes(2)
})
it('serves the restored incumbent on the very connect that rejected the candidate', async () => {
mocks.deploy.mockResolvedValueOnce(rejected)
mocks.adoptRunning.mockResolvedValueOnce(adoptedAs('1.4.0+aa', INCUMBENT_READINESS))
await expect(launch()).resolves.toMatchObject({
outcome: 'ready',
fullVersion: '1.4.0+aa',
deferredUpdate: { code: 'orcad_candidate_readiness_rejected', forceable: true }
})
})
it('retries a failed candidate on Update, and still defers over live terminals', async () => {
mocks.deploy.mockResolvedValueOnce(rejected)
await launch()
mocks.adoptRunning.mockClear()
mocks.deploy.mockResolvedValueOnce({
outcome: 'installed-not-activated',
fullVersion: '1.5.0+ab',
code: 'orcad_update_terminals_running',
reason: '2 terminals are running'
})
await expect(launch(undefined, { intent: 'update' })).resolves.toMatchObject({
outcome: 'ready',
fullVersion: '1.4.0+aa',
deferredUpdate: { code: 'orcad_update_terminals_running', forceable: true }
})
expect(mocks.adoptRunning).not.toHaveBeenCalled()
// Update is not Force: the deploy still gates on the terminals the incumbent counted.
expect(mocks.deploy.mock.calls[1][0]).toMatchObject({ force: false, census: LIVE })
const { admitRecord } = mocks.deploy.mock.calls[1][0]
expect(admitRecord({ ...activeOld, rolledBackFrom: '1.5.0+ab' }, '1.5.0+ab')).toBeNull()
})
it('reports the rejection as is when the incumbent was not restored', async () => {
mocks.deploy.mockResolvedValueOnce(rejected)
mocks.transaction.mockResolvedValueOnce({ transactionId: 't1' })
await expect(launch()).resolves.toMatchObject({
outcome: 'deferred',
code: 'orcad_candidate_readiness_rejected',
reason: rejected.reason
})
// Nor when the host could not be read: that proves no restore.
mocks.deploy.mockResolvedValueOnce(rejected)
mocks.transaction.mockRejectedValueOnce(new Error('wsl.exe did not answer'))
await expect(launch(undefined, { intent: 'update' })).resolves.toMatchObject({
outcome: 'deferred',
code: 'orcad_candidate_readiness_rejected'
})
expect(mocks.adoptRunning).not.toHaveBeenCalled()
})
it('serves a newer host on Update rather than downgrading it, and only Force deploys', async () => {
withRecord(newer)
mocks.adoptRunning.mockResolvedValueOnce(adoptedAs('2.0.0+cd', NEWER_READINESS))
await expect(launch(undefined, { intent: 'update' })).resolves.toMatchObject({
outcome: 'ready',
fullVersion: '2.0.0+cd'
})
expect(mocks.deploy).not.toHaveBeenCalled()
mocks.adoptRunning.mockResolvedValueOnce(null)
mocks.deploy.mockResolvedValue(policyRefused)
await launch(undefined, { intent: 'update' })
expect(mocks.deploy.mock.calls[0][0].admitRecord(newer, '1.5.0+ab')).toMatch(/last ran/)
await launch(undefined, { intent: 'force' })
expect(mocks.deploy.mock.calls[1][0]).toMatchObject({ force: true })
expect(mocks.deploy.mock.calls[1][0].admitRecord(newer, '1.5.0+ab')).toBeNull()
})
it('still skips a failed build on the next connect when an explicit Update only waited', async () => {
mocks.deploy.mockResolvedValueOnce(rejected)
await launch()
mocks.deploy.mockResolvedValueOnce({
outcome: 'installed-not-activated',
fullVersion: '1.5.0+ab',
code: 'orcad_update_terminals_running',
reason: 'busy'
})
await launch(undefined, { intent: 'update' })
mocks.adoptRunning.mockResolvedValueOnce(adoptedAs('1.4.0+aa', INCUMBENT_READINESS))
await expect(launch()).resolves.toMatchObject({ outcome: 'ready', fullVersion: '1.4.0+aa' })
expect(mocks.deploy).toHaveBeenCalledTimes(2)
})
it('names a newer host refusal apart from a rolled-back one, so only Force is offered', async () => {
withRecord(newer)
mocks.adoptRunning.mockResolvedValueOnce(null)
mocks.deploy.mockResolvedValueOnce(policyRefused)
await expect(launch(undefined, { intent: 'update' })).resolves.toMatchObject({
outcome: 'deferred',
code: 'orcad_host_newer',
forceable: true
})
withRecord({ ...activeOld, rolledBackFrom: '1.5.0+ab' })
mocks.adoptRunning.mockResolvedValueOnce(null)
mocks.deploy.mockResolvedValueOnce(policyRefused)
await expect(launch()).resolves.toMatchObject({
outcome: 'deferred',
code: 'orcad_activation_policy_refused'
})
})
it('keeps retrying an update that only waited, or that a newer host refused', async () => {
mocks.deploy.mockResolvedValue({
outcome: 'installed-not-activated',
fullVersion: '1.5.0+ab',
code: 'orcad_update_terminals_running',
reason: 'busy'
})
await launch()
await launch()
mocks.deploy.mockResolvedValue(policyRefused)
await launch()
await launch()
expect(mocks.deploy).toHaveBeenCalledTimes(4)
expect(mocks.adoptRunning).not.toHaveBeenCalled()
})
it('lets the next connect try again once an explicit Force went through', async () => {
mocks.deploy.mockResolvedValueOnce(rejected)
await launch()
mocks.deploy.mockResolvedValueOnce({
outcome: 'installed-and-activated',
fullVersion: '1.5.0+ab',
verdict: {}
})
await launch(undefined, { intent: 'force' })
mocks.deploy.mockResolvedValueOnce(rejected)
await launch()
expect(mocks.deploy).toHaveBeenCalledTimes(3)
})
it('deploys on the record the wake read when the skipped build stopped meanwhile', async () => {
withRecord(newer)
const stopped = { ...newer, active: null, activatedAt: null }
mocks.adoptRunning.mockResolvedValueOnce(null)
mocks.readRecord.mockResolvedValueOnce(stopped)
mocks.deploy.mockResolvedValueOnce(policyRefused)
await launch()
expect(mocks.incumbent).not.toHaveBeenCalled()
expect(mocks.deploy.mock.calls[0][0]).toMatchObject({
censusRecord: stopped,
census: { liveSessions: 0 }
})
})
it('leaves a host stopped after a rollback for Force, not the build it rolled back from', async () => {
// No bundle of `previous` is on this desktop, so nothing here could restart it.
const stopped = {
...emptyOrcadActivationRecord(),
previous: '1.4.0+aa',
rolledBackFrom: '1.5.0+ab'
}
withRecord(stopped)
mocks.deploy.mockResolvedValueOnce(policyRefused)
await expect(launch()).resolves.toMatchObject({ outcome: 'deferred', forceable: true })
expect(mocks.adoptRunning).not.toHaveBeenCalled()
expect(mocks.deploy.mock.calls[0][0].admitRecord(stopped, '1.5.0+ab')).toMatch(
/rolled back from 1\.5\.0\+ab/
)
})
it('updates an older host as before, without asking to adopt it', async () => {
mocks.deploy.mockResolvedValueOnce({
outcome: 'installed-and-activated',
fullVersion: '1.5.0+ab',
verdict: {}
})
await expect(launch()).resolves.toMatchObject({ outcome: 'ready', fullVersion: '1.5.0+ab' })
expect(mocks.adoptRunning).not.toHaveBeenCalled()
})
})
it('never asks the incumbent when it already runs this build', async () => {
mocks.localVersion.mockReturnValue('1.4.0+aa')
mocks.deploy.mockResolvedValueOnce({
+60 -5
View File
@@ -9,6 +9,7 @@ import type { ServeReadiness } from '../server/serve-readiness'
import type { WslHostDeferredUpdate } from '../../shared/wsl-host-types'
import { recoverInterruptedOrcadActivation } from '../managed-server/activation/orcad-activation-recovery'
import { readOrcadActivationRecord } from '../managed-server/activation/orcad-activation-record-store'
import { readOrcadActivationTransaction } from '../managed-server/activation/orcad-activation-transaction-store'
import { materializeOrcadArtifact } from '../orcad/orcad-artifact-materializer'
import { OrcadHostUnsupportedError } from '../orcad/orcad-host-unavailable'
import type { OrcadHostTransport } from '../ssh/orcad-host-transport'
@@ -22,6 +23,10 @@ import {
probeManagedOrcadReadiness
} from '../managed-server/lifecycle/orcad-managed-runtime-context'
import { isWaitingOrcadUpdateDeferral } from '../managed-server/lifecycle/orcad-managed-auto-update'
import { clearOrcadUpdateFailedThisLaunch } from '../managed-server/lifecycle/orcad-managed-update-failure-memory'
import type { OrcadUpdateIntent } from '../managed-server/lifecycle/orcad-managed-update-decision'
import type { OrcadActivationRecord } from '../managed-server/activation/orcad-activation-record'
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 { resolveOrcadRemoteContext } from '../managed-server/install/orcad-remote-context'
@@ -38,6 +43,12 @@ import type { WslOrcadHostTarget } from './wsl-orcad-host-target'
import { createWslOrcadHostTransport, type WslOrcadHostTransport } from './wsl-orcad-host-transport'
import { readWslOrcadIncumbent, type WslOrcadIncumbent } from './wsl-orcad-incumbent'
import { adoptUnrecordedWslOrcad } from './wsl-orcad-instance-adoption'
import {
admitWslOrcadLaunch,
recordWslOrcadUpdateFailure,
wslOrcadDeferralCode,
wslOrcadDeferralFailure
} from './wsl-orcad-update-admission'
/** An update this app carries that the host may not take yet; the incumbent keeps serving. */
export type WslOrcadDeferredUpdate = WslHostDeferredUpdate
@@ -65,7 +76,8 @@ export type WslOrcadDeployOptions = {
/** The WSL environment's id; keys host-scoped state (wake dedupe). Defaults per distro. */
environmentId?: string
label?: string
force?: boolean
/** Defaults to a connect. */
intent?: OrcadUpdateIntent
signal?: AbortSignal
/** Defaults to one created over `userData`; the app should pass its shared instance. */
holder?: WslDistroHolder
@@ -147,10 +159,24 @@ async function launchOverTransport(
...(options.templateDir ? { templateDir: options.templateDir } : {})
})
const appVersion = getAppEnvironment().getVersion()
const candidateVersion = readLocalFullVersion(localOrcadDir)
const failureHost = { kind: 'wsl', id: target.id } as const
const intent = options.intent ?? 'connect'
const admit = (record: OrcadActivationRecord, admitIntent: OrcadUpdateIntent) =>
admitWslOrcadLaunch(
{ slot, record, candidateVersion, appVersion, host: failureHost, intent: admitIntent },
name,
readyLaunch
)
const admitted = await admit(context.activationRecord, intent)
if ('outcome' in admitted) {
return admitted
}
context = { ...context, activationRecord: admitted }
const incumbentVersion = context.activationRecord.active
// Only an update asks the incumbent: the same version is a no-op deploy on every connect.
const incumbent: WslOrcadIncumbent | null =
incumbentVersion && incumbentVersion !== readLocalFullVersion(localOrcadDir)
incumbentVersion && incumbentVersion !== candidateVersion
? await (options.readIncumbent ?? readWslOrcadIncumbent)({
context,
slot
@@ -161,7 +187,7 @@ async function launchOverTransport(
conn,
localOrcadDir,
target: context.serverTarget,
...managedOrcadDeployPolicy(context, appVersion, options.force),
...managedOrcadDeployPolicy(context, appVersion, intent),
...(incumbent
? {
census: incumbent.census,
@@ -169,14 +195,32 @@ async function launchOverTransport(
releaseIncumbentAdmission: incumbent.releaseAdmission
}
: {}),
force: options.force,
force: intent === 'force',
appVersion,
recordedNodeRuntimeProbe: true
}).catch((error: unknown) => {
if (!signal?.aborted) {
recordWslOrcadUpdateFailure(failureHost, incumbentVersion, {
code: 'orcad_update_failed',
reason: errorMessage(error)
})
}
throw error
})
if (deployed.outcome === 'installed-not-activated') {
const failure = wslOrcadDeferralFailure(deployed)
recordWslOrcadUpdateFailure(failureHost, incumbentVersion, failure)
const restored = incumbentVersion && failure ? await readRestoredRecord(incumbentVersion) : null
if (restored) {
// The rejected candidate was undone, so serve the restored incumbent on this connect too.
const retried = await admit(restored, 'connect')
if ('outcome' in retried) {
return retried
}
}
const deferredUpdate: WslOrcadDeferredUpdate = {
candidateVersion: deployed.fullVersion,
code: deployed.code,
code: await wslOrcadDeferralCode(slot, deployed, appVersion),
reason: deployed.reason,
forceable:
isForceableOrcadDeferral(deployed.code) ||
@@ -198,6 +242,7 @@ async function launchOverTransport(
}
return { outcome: 'deferred', distro: name, ...deferredUpdate }
}
clearOrcadUpdateFailedThisLaunch(failureHost)
if (deployed.outcome === 'already-active') {
// A stopped distro ended the active slot with it.
await wakeAlreadyActiveOrcad(slot)
@@ -216,6 +261,16 @@ async function launchOverTransport(
})
return readyLaunch({ fullVersion: deployed.fullVersion, readiness })
/** The record only once the incumbent is back and no journal awaits recovery; else null. */
async function readRestoredRecord(incumbent: string): Promise<OrcadActivationRecord | null> {
// An unreadable host is not proof of a restore: keep the rejection as reported.
if (await readOrcadActivationTransaction(slot).catch(() => true)) {
return null
}
const record = await readOrcadActivationRecord(slot).catch(() => null)
return record?.active === incumbent ? record : null
}
async function readyLaunch(serving: {
fullVersion: string
readiness: ServeReadiness
@@ -234,8 +234,10 @@ describe('WslOrcadLivenessSupervisor', () => {
const h = harness()
await h.supervisor.connect('Ubuntu')
h.state.ensureFails = 1
await expect(h.supervisor.connect('Ubuntu', { force: true })).rejects.toThrow('relaunch failed')
expect(h.ensure).toHaveBeenLastCalledWith('Ubuntu', { force: true })
await expect(h.supervisor.connect('Ubuntu', { intent: 'force' })).rejects.toThrow(
'relaunch failed'
)
expect(h.ensure).toHaveBeenLastCalledWith('Ubuntu', { intent: 'force' })
expect(h.watches).toHaveLength(2)
expect(h.lastWatch().closed).toBe(false)
})
@@ -9,7 +9,7 @@
* Timers are monotonic, and a power resume re-verifies instead of trusting them.
*/
import type { WslOrcadConnection } from './wsl-orcad-connection'
import type { WslOrcadConnectionStatus } from './wsl-orcad-connection-status'
import type { WslOrcadConnectIntent, WslOrcadConnectionStatus } from './wsl-orcad-connection-status'
import { wslDistroHolderKey } from './wsl-distro-holder-record'
import { recoverWslOrcad, type WslOrcadRecoveryReason } from './wsl-orcad-liveness-recovery'
import {
@@ -64,7 +64,7 @@ export class WslOrcadLivenessSupervisor {
}
/** User-initiated, so it may boot the distro. Resolves once the watch is armed. */
async connect(distro: string, options: { force?: boolean } = {}): Promise<WslOrcadConnection> {
async connect(distro: string, options: WslOrcadConnectIntent = {}): Promise<WslOrcadConnection> {
const entry = this.entryFor(distro)
// An unchanged bridge offer may stay quiet even when an earlier failure needs clearing.
const reportReady = entry.busy || (entry.state !== null && entry.state !== 'ready')
@@ -425,7 +425,7 @@ async function updatePhase(distro: string): Promise<Receipt> {
// Force: the new build serves, and the terminal survives on the preserved daemon.
started = performance.now()
const forced = await supervisor.connect(distro, { force: true })
const forced = await supervisor.connect(distro, { intent: 'force' })
result.forceMs = Math.round(performance.now() - started)
expect(forced.fullVersion).toBe(deferred.deferredUpdate?.candidateVersion)
expect(forced.deferredUpdate).toBeNull()
+182
View File
@@ -0,0 +1,182 @@
/**
* The SSH connect's update decision on a WSL connect: a newer build, the one the host rolled back
* from, or one already rejected this launch keeps the incumbent serving, adopted as an SSH first
* deploy adopts it. The fenced activation applies the same plan again.
*/
import {
ORCAD_HOST_NEWER_DEFERRAL_CODE,
type WslHostDeferredUpdate
} from '../../shared/wsl-host-types'
import { refuseOrcadHostDowngrade } from '../ssh/orcad-host-version-admission'
import type { ServeReadiness } from '../server/serve-readiness'
import type { OrcadActivationRecord } from '../managed-server/activation/orcad-activation-record'
import { readOrcadActivationRecord } from '../managed-server/activation/orcad-activation-record-store'
import { isWaitingOrcadUpdateDeferral } from '../managed-server/lifecycle/orcad-managed-auto-update'
import {
orcadUpdateSkipRefusal,
planOrcadUpdateForIntent,
type ManagedOrcadAutoUpdatePlan,
type OrcadUpdateIntent
} from '../managed-server/lifecycle/orcad-managed-update-decision'
import {
orcadUpdateFailureThisLaunch,
recordOrcadUpdateFailedThisLaunch,
type OrcadUpdateFailure,
type OrcadUpdateFailureHost
} from '../managed-server/lifecycle/orcad-managed-update-failure-memory'
import { ORCAD_ACTIVATION_POLICY_REFUSED_CODE } from '../ssh/orcad-installed-activation'
import type { OrcadSlotOptions } from '../ssh/orcad-recovery-slot'
import { adoptRunningOrcadVersion } from '../ssh/orcad-running-version-adoption'
import type { WslOrcadLaunch } from './wsl-orcad-deploy'
type WslOrcadReadyLaunch = Extract<WslOrcadLaunch, { outcome: 'ready' }>
export type WslOrcadUpdateAdmission =
| {
action: 'serve'
version: string
readiness: ServeReadiness
deferredUpdate: WslHostDeferredUpdate | null
}
/** Another run holds the host fence; a later connect adopts or deploys. */
| { action: 'defer'; deferredUpdate: WslHostDeferredUpdate }
/** Deploy this app's build on this record. */
| { action: 'deploy'; record: OrcadActivationRecord }
export async function admitWslOrcadUpdate(args: {
slot: OrcadSlotOptions
record: OrcadActivationRecord
candidateVersion: string
appVersion: string
host: OrcadUpdateFailureHost
intent: OrcadUpdateIntent
}): Promise<WslOrcadUpdateAdmission> {
const { record, candidateVersion, appVersion } = args
if (!record.active) {
return { action: 'deploy', record }
}
const failure = orcadUpdateFailureThisLaunch(args.host)
const plan = planOrcadUpdateForIntent(args.intent, {
record,
candidateVersion,
appVersion,
failedBefore: failure !== null
})
if (plan.action !== 'skip' || plan.reason === 'current') {
return { action: 'deploy', record }
}
const adopted = await adoptRunningOrcadVersion(args.slot, record, candidateVersion)
if (!adopted) {
// Nothing active any more: deploy on the record the wake read; its fenced admission re-plans.
return { action: 'deploy', record: await readOrcadActivationRecord(args.slot) }
}
if ('outcome' in adopted) {
const { candidateVersion: version, code, reason, forceable } = adopted
return {
action: 'defer',
deferredUpdate: { candidateVersion: version, code, reason, forceable: forceable === true }
}
}
return {
action: 'serve',
version: adopted.version,
readiness: adopted.readiness,
deferredUpdate:
adopted.version === candidateVersion
? null
: skippedUpdateHint(plan, failure, record, candidateVersion, appVersion)
}
}
/** The launch an admission ends in, or the record to deploy on. */
export async function admitWslOrcadLaunch(
args: Parameters<typeof admitWslOrcadUpdate>[0],
distro: string,
readyLaunch: (serving: {
fullVersion: string
readiness: ServeReadiness
}) => Promise<WslOrcadReadyLaunch>
): Promise<WslOrcadLaunch | OrcadActivationRecord> {
const admission = await admitWslOrcadUpdate(args)
if (admission.action === 'deploy') {
return admission.record
}
if (admission.action === 'defer') {
return { outcome: 'deferred', distro, ...admission.deferredUpdate }
}
const ready = await readyLaunch({
fullVersion: admission.version,
readiness: admission.readiness
})
return admission.deferredUpdate ? { ...ready, deferredUpdate: admission.deferredUpdate } : ready
}
/**
* Keeps Update and Force offered on a connect that skipped this app's build for this host's own
* reasons. A newer host gets no hint: an Update keeps that skip, and nothing here updates it.
*/
function skippedUpdateHint(
plan: ManagedOrcadAutoUpdatePlan,
failure: OrcadUpdateFailure | null,
record: OrcadActivationRecord,
candidateVersion: string,
appVersion: string
): WslHostDeferredUpdate | null {
if (plan.action !== 'skip') {
return null
}
if (plan.reason === 'failed-before' && failure) {
return { candidateVersion, ...failure, forceable: true }
}
const refusal =
plan.reason === 'rolled-back'
? orcadUpdateSkipRefusal(plan, record, candidateVersion, appVersion)
: null
return refusal
? {
candidateVersion,
code: ORCAD_ACTIVATION_POLICY_REFUSED_CODE,
reason: refusal,
forceable: true
}
: null
}
/**
* As SSH's connect: an update over an incumbent that was rejected or threw is not retried this
* launch. Null failures (a wait or a policy refusal) never tried it.
*/
export function recordWslOrcadUpdateFailure(
host: OrcadUpdateFailureHost,
incumbentVersion: string | null,
failure: OrcadUpdateFailure | null
): void {
if (incumbentVersion && failure) {
recordOrcadUpdateFailedThisLaunch(host, failure)
}
}
export function wslOrcadDeferralFailure(deployed: {
code: string
reason: string
}): OrcadUpdateFailure | null {
return isWaitingOrcadUpdateDeferral(deployed.code) ||
deployed.code === ORCAD_ACTIVATION_POLICY_REFUSED_CODE
? null
: { code: deployed.code, reason: deployed.reason }
}
/** A policy refusal over a host a newer Orca runs gets its own code: an Update cannot pass it. */
export async function wslOrcadDeferralCode(
slot: OrcadSlotOptions,
deployed: { code: string; fullVersion: string },
appVersion: string
): Promise<string> {
if (deployed.code !== ORCAD_ACTIVATION_POLICY_REFUSED_CODE) {
return deployed.code
}
const record = await readOrcadActivationRecord(slot).catch(() => null)
return record && refuseOrcadHostDowngrade(record, deployed.fullVersion, appVersion)
? ORCAD_HOST_NEWER_DEFERRAL_CODE
: deployed.code
}
+3 -2
View File
@@ -6,6 +6,7 @@ import { ipcRenderer } from 'electron'
import {
WSL_HOST_STATUS_CHANNEL,
type WslHostAddResult,
type WslHostConnectIntent,
type WslHostCapability,
type WslHostDistro,
type WslHostServerStop,
@@ -18,8 +19,8 @@ export type WslHostsPreloadApi = {
listDistros: () => Promise<WslHostDistro[]>
add: (args: { distro: string; startIfStopped?: boolean }) => Promise<WslHostAddResult>
remove: (args: { environmentId: string }) => Promise<WslHostServerStop>
/** `force` takes an update that live terminals deferred. */
connect: (args: { environmentId: string; force?: boolean }) => Promise<void>
/** Without an intent this is a plain connect, which skips builds a host refused or rolled back. */
connect: (args: { environmentId: string; intent?: WslHostConnectIntent }) => Promise<void>
getStatuses: () => Promise<WslHostStatus[]>
onStatusChanged: (callback: (status: WslHostStatus) => void) => () => void
/** The WSL hosts an open project or terminal needs; only these may boot a stopped distro. */
@@ -1,6 +1,10 @@
import { useState } from 'react'
import { Plus, TerminalSquare, Trash2 } from 'lucide-react'
import type { WslHostStatus } from '../../../../shared/wsl-host-types'
import {
ORCAD_HOST_NEWER_DEFERRAL_CODE,
type WslHostConnectIntent,
type WslHostStatus
} from '../../../../shared/wsl-host-types'
import { toast } from 'sonner'
import {
isWslHostRuntimeEnvironment,
@@ -89,15 +93,15 @@ export function WslHostsSection({
return null
}
const connect = async (environmentId: string, force = false): Promise<void> => {
const connect = async (environmentId: string, intent?: WslHostConnectIntent): Promise<void> => {
const api = window.api.runtimeEnvironments?.wslHosts
if (!api || connectingId) {
return
}
setConnectingId(environmentId)
try {
// A connect retries a deferred update; force takes it over the running terminals.
await api.connect({ environmentId, force })
// Update retries a deferred or skipped update behind the terminal check; Force passes it.
await api.connect({ environmentId, ...(intent ? { intent } : {}) })
onChanged()
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error))
@@ -180,13 +184,14 @@ export function WslHostsSection({
}
status={getWslHostRowStatus(status, unavailableLabel !== null)}
primaryAction={
deferred ? (
// A newer Orca's host refuses Update; Force stays in the menu.
deferred && deferred.code !== ORCAD_HOST_NEWER_DEFERRAL_CODE ? (
<Button
type="button"
size="xs"
variant="outline"
disabled={connectingId !== null}
onClick={() => void connect(environment.id)}
onClick={() => void connect(environment.id, 'update')}
>
{translate('auto.components.settings.managedServers.row.update', 'Update')}
</Button>
@@ -210,12 +215,14 @@ export function WslHostsSection({
{deferred?.forceable ? (
<DropdownMenuItem
disabled={connectingId !== null}
onSelect={() => void connect(environment.id, true)}
onSelect={() => void connect(environment.id, 'force')}
>
{translate('auto.components.settings.wslHosts.forceUpdate', 'Force update')}
</DropdownMenuItem>
) : null}
{deferred && !(ready && !unavailableLabel) ? (
{deferred &&
deferred.code !== ORCAD_HOST_NEWER_DEFERRAL_CODE &&
!(ready && !unavailableLabel) ? (
<DropdownMenuItem
disabled={connectDisabled}
onSelect={() => void connect(environment.id)}
+6
View File
@@ -31,6 +31,12 @@ export type WslHostStatus = {
deferredUpdate?: WslHostDeferredUpdate | null
}
/** An explicit Update retries a skipped build behind the terminal census; Force also passes it. */
export type WslHostConnectIntent = 'update' | 'force'
/** A host a newer Orca runs refused this build: only Force (or updating Orca) changes that. */
export const ORCAD_HOST_NEWER_DEFERRAL_CODE = 'orcad_host_newer'
export type WslHostDeferredUpdate = {
candidateVersion: string
/** The update planner's deferral code, as Managed servers reports it. */