From a57e027cb3894d2d84a7dfd2d32211699caffaf6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:26:35 -0700 Subject: [PATCH] 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 --- src/main/ipc/wsl-host-handlers.ts | 7 +- .../orcad-activation-crash-recovery.test.ts | 2 +- .../orcad-managed-auto-update.test.ts | 2 +- .../lifecycle/orcad-managed-auto-update.ts | 48 +-- .../orcad-managed-runtime-context.ts | 16 +- .../orcad-managed-update-decision.test.ts | 42 +++ .../orcad-managed-update-decision.ts | 109 ++++++ .../orcad-managed-update-failure-memory.ts | 38 ++ .../lifecycle/orcad-runtime-deployment.ts | 2 +- src/main/ssh/managed-server-update-deps.ts | 17 +- .../ssh/orcad-runtime-maintenance.test.ts | 51 +++ src/main/ssh/orcad-runtime-maintenance.ts | 22 +- ...erver-on-connect-stopped-elsewhere.test.ts | 2 +- .../wsl/wsl-host-connection-record.test.ts | 36 ++ src/main/wsl/wsl-host-connection-record.ts | 10 +- src/main/wsl/wsl-host-service.test.ts | 4 +- src/main/wsl/wsl-host-service.ts | 12 +- src/main/wsl/wsl-orcad-connection-status.ts | 25 +- src/main/wsl/wsl-orcad-connection.test.ts | 26 +- src/main/wsl/wsl-orcad-connection.ts | 22 +- src/main/wsl/wsl-orcad-deploy.test.ts | 351 +++++++++++++++++- src/main/wsl/wsl-orcad-deploy.ts | 65 +++- .../wsl/wsl-orcad-liveness-supervisor.test.ts | 6 +- src/main/wsl/wsl-orcad-liveness-supervisor.ts | 4 +- .../wsl/wsl-orcad-real-distro-lane.test.ts | 2 +- src/main/wsl/wsl-orcad-update-admission.ts | 182 +++++++++ src/preload/api/wsl-hosts-api.ts | 5 +- .../components/settings/WslHostsSection.tsx | 23 +- src/shared/wsl-host-types.ts | 6 + 29 files changed, 1017 insertions(+), 120 deletions(-) create mode 100644 src/main/managed-server/lifecycle/orcad-managed-update-decision.test.ts create mode 100644 src/main/managed-server/lifecycle/orcad-managed-update-decision.ts create mode 100644 src/main/managed-server/lifecycle/orcad-managed-update-failure-memory.ts create mode 100644 src/main/wsl/wsl-host-connection-record.test.ts create mode 100644 src/main/wsl/wsl-orcad-update-admission.ts diff --git a/src/main/ipc/wsl-host-handlers.ts b/src/main/ipc/wsl-host-handlers.ts index 7333fc723c4..f4e3d5dc866 100644 --- a/src/main/ipc/wsl-host-handlers.ts +++ b/src/main/ipc/wsl-host-handlers.ts @@ -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() diff --git a/src/main/managed-server/activation/orcad-activation-crash-recovery.test.ts b/src/main/managed-server/activation/orcad-activation-crash-recovery.test.ts index 61be9225299..77e8296face 100644 --- a/src/main/managed-server/activation/orcad-activation-crash-recovery.test.ts +++ b/src/main/managed-server/activation/orcad-activation-crash-recovery.test.ts @@ -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' diff --git a/src/main/managed-server/lifecycle/orcad-managed-auto-update.test.ts b/src/main/managed-server/lifecycle/orcad-managed-auto-update.test.ts index ee57ff8f05f..5d01d75ca66 100644 --- a/src/main/managed-server/lifecycle/orcad-managed-auto-update.test.ts +++ b/src/main/managed-server/lifecycle/orcad-managed-auto-update.test.ts @@ -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 { return { ...emptyOrcadActivationRecord(), active: '0.1.0+old', ...overrides } diff --git a/src/main/managed-server/lifecycle/orcad-managed-auto-update.ts b/src/main/managed-server/lifecycle/orcad-managed-auto-update.ts index 3af2778ce5d..50f68e29ab6 100644 --- a/src/main/managed-server/lifecycle/orcad-managed-auto-update.ts +++ b/src/main/managed-server/lifecycle/orcad-managed-auto-update.ts @@ -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 = new Set< OrcadUpdateDeferCode | typeof ORCAD_ACTIVATION_FENCE_BUSY_CODE >([ diff --git a/src/main/managed-server/lifecycle/orcad-managed-runtime-context.ts b/src/main/managed-server/lifecycle/orcad-managed-runtime-context.ts index ab2dcd8273c..6814cb1a6d9 100644 --- a/src/main/managed-server/lifecycle/orcad-managed-runtime-context.ts +++ b/src/main/managed-server/lifecycle/orcad-managed-runtime-context.ts @@ -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, 'activationRecord'>, appVersion: string, - force?: boolean + intent: OrcadUpdateIntent ): { census: OrcadTerminalCensus recountCensus: () => Promise @@ -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) } } diff --git a/src/main/managed-server/lifecycle/orcad-managed-update-decision.test.ts b/src/main/managed-server/lifecycle/orcad-managed-update-decision.test.ts new file mode 100644 index 00000000000..a08426c50cf --- /dev/null +++ b/src/main/managed-server/lifecycle/orcad-managed-update-decision.test.ts @@ -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() + }) +}) diff --git a/src/main/managed-server/lifecycle/orcad-managed-update-decision.ts b/src/main/managed-server/lifecycle/orcad-managed-update-decision.ts new file mode 100644 index 00000000000..41293e03b78 --- /dev/null +++ b/src/main/managed-server/lifecycle/orcad-managed-update-decision.ts @@ -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[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.` +} diff --git a/src/main/managed-server/lifecycle/orcad-managed-update-failure-memory.ts b/src/main/managed-server/lifecycle/orcad-managed-update-failure-memory.ts new file mode 100644 index 00000000000..aefcab23638 --- /dev/null +++ b/src/main/managed-server/lifecycle/orcad-managed-update-failure-memory.ts @@ -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() + +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() +} diff --git a/src/main/managed-server/lifecycle/orcad-runtime-deployment.ts b/src/main/managed-server/lifecycle/orcad-runtime-deployment.ts index 1892ca25758..a9c64286fd0 100644 --- a/src/main/managed-server/lifecycle/orcad-runtime-deployment.ts +++ b/src/main/managed-server/lifecycle/orcad-runtime-deployment.ts @@ -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 }) diff --git a/src/main/ssh/managed-server-update-deps.ts b/src/main/ssh/managed-server-update-deps.ts index 31fd611cffb..da13cc86d6b 100644 --- a/src/main/ssh/managed-server-update-deps.ts +++ b/src/main/ssh/managed-server-update-deps.ts @@ -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() +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) => { diff --git a/src/main/ssh/orcad-runtime-maintenance.test.ts b/src/main/ssh/orcad-runtime-maintenance.test.ts index c39003b82e7..0e1a985d013 100644 --- a/src/main/ssh/orcad-runtime-maintenance.test.ts +++ b/src/main/ssh/orcad-runtime-maintenance.test.ts @@ -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) diff --git a/src/main/ssh/orcad-runtime-maintenance.ts b/src/main/ssh/orcad-runtime-maintenance.ts index d91feefcf1b..091a5b5d87c 100644 --- a/src/main/ssh/orcad-runtime-maintenance.ts +++ b/src/main/ssh/orcad-runtime-maintenance.ts @@ -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 { - 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) { diff --git a/src/main/ssh/ssh-host-server-on-connect-stopped-elsewhere.test.ts b/src/main/ssh/ssh-host-server-on-connect-stopped-elsewhere.test.ts index aa09a59019b..a875c77ce6f 100644 --- a/src/main/ssh/ssh-host-server-on-connect-stopped-elsewhere.test.ts +++ b/src/main/ssh/ssh-host-server-on-connect-stopped-elsewhere.test.ts @@ -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, diff --git a/src/main/wsl/wsl-host-connection-record.test.ts b/src/main/wsl/wsl-host-connection-record.test.ts new file mode 100644 index 00000000000..aa8faf0846f --- /dev/null +++ b/src/main/wsl/wsl-host-connection-record.test.ts @@ -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() + }) +}) diff --git a/src/main/wsl/wsl-host-connection-record.ts b/src/main/wsl/wsl-host-connection-record.ts index 9be1d5bf7be..c4612b7b9d9 100644 --- a/src/main/wsl/wsl-host-connection-record.ts +++ b/src/main/wsl/wsl-host-connection-record.ts @@ -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 } } diff --git a/src/main/wsl/wsl-host-service.test.ts b/src/main/wsl/wsl-host-service.test.ts index 316656b31a5..2e918c7b492 100644 --- a/src/main/wsl/wsl-host-service.test.ts +++ b/src/main/wsl/wsl-host-service.test.ts @@ -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' }) }) diff --git a/src/main/wsl/wsl-host-service.ts b/src/main/wsl/wsl-host-service.ts index ed53520c327..7b2c7ca2a0d 100644 --- a/src/main/wsl/wsl-host-service.ts +++ b/src/main/wsl/wsl-host-service.ts @@ -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 /** Runs in the distro, so it boots a stopped one: only after the user chose to. */ readOrcadPublicKey(distro: string): Promise - /** User-initiated; may boot the distro. `force` takes an update live terminals deferred. */ - connect(distro: string, options?: { force?: boolean }): Promise + /** User-initiated; may boot the distro. An intent retries or forces a skipped update. */ + connect(distro: string, options?: { intent?: WslHostConnectIntent }): Promise get(distro: string): WslOrcadConnection | null /** Checks the paired server without starting a stopped or unconfirmed distro. */ isReachable(distro: string, environmentId: string): Promise @@ -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 { + /** 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 { const record = this.recordForEnvironment(environmentId) if (!record) { throw new Error('This server is not a WSL host.') diff --git a/src/main/wsl/wsl-orcad-connection-status.ts b/src/main/wsl/wsl-orcad-connection-status.ts index 56c0b7d231c..cace33b6235 100644 --- a/src/main/wsl/wsl-orcad-connection-status.ts +++ b/src/main/wsl/wsl-orcad-connection-status.ts @@ -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 } +} diff --git a/src/main/wsl/wsl-orcad-connection.test.ts b/src/main/wsl/wsl-orcad-connection.test.ts index f4f9a1ea6b5..983982c50f1 100644 --- a/src/main/wsl/wsl-orcad-connection.test.ts +++ b/src/main/wsl/wsl-orcad-connection.test.ts @@ -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 () => { diff --git a/src/main/wsl/wsl-orcad-connection.ts b/src/main/wsl/wsl-orcad-connection.ts index 7b30f80eb4f..0526cab90a0 100644 --- a/src/main/wsl/wsl-orcad-connection.ts +++ b/src/main/wsl/wsl-orcad-connection.ts @@ -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 diff --git a/src/main/wsl/wsl-orcad-deploy.test.ts b/src/main/wsl/wsl-orcad-deploy.test.ts index a91741d61b0..3a58e4a2a53 100644 --- a/src/main/wsl/wsl-orcad-deploy.test.ts +++ b/src/main/wsl/wsl-orcad-deploy.test.ts @@ -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 => 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({ diff --git a/src/main/wsl/wsl-orcad-deploy.ts b/src/main/wsl/wsl-orcad-deploy.ts index 54888aa0fea..3cad2eb7a39 100644 --- a/src/main/wsl/wsl-orcad-deploy.ts +++ b/src/main/wsl/wsl-orcad-deploy.ts @@ -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 { + // 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 diff --git a/src/main/wsl/wsl-orcad-liveness-supervisor.test.ts b/src/main/wsl/wsl-orcad-liveness-supervisor.test.ts index 064be6a3207..6db3538a940 100644 --- a/src/main/wsl/wsl-orcad-liveness-supervisor.test.ts +++ b/src/main/wsl/wsl-orcad-liveness-supervisor.test.ts @@ -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) }) diff --git a/src/main/wsl/wsl-orcad-liveness-supervisor.ts b/src/main/wsl/wsl-orcad-liveness-supervisor.ts index 0c04f37f4a1..70b593dbb57 100644 --- a/src/main/wsl/wsl-orcad-liveness-supervisor.ts +++ b/src/main/wsl/wsl-orcad-liveness-supervisor.ts @@ -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 { + async connect(distro: string, options: WslOrcadConnectIntent = {}): Promise { 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') diff --git a/src/main/wsl/wsl-orcad-real-distro-lane.test.ts b/src/main/wsl/wsl-orcad-real-distro-lane.test.ts index e46659c7d9a..8dde8c1e569 100644 --- a/src/main/wsl/wsl-orcad-real-distro-lane.test.ts +++ b/src/main/wsl/wsl-orcad-real-distro-lane.test.ts @@ -425,7 +425,7 @@ async function updatePhase(distro: string): Promise { // 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() diff --git a/src/main/wsl/wsl-orcad-update-admission.ts b/src/main/wsl/wsl-orcad-update-admission.ts new file mode 100644 index 00000000000..dd79e082e4f --- /dev/null +++ b/src/main/wsl/wsl-orcad-update-admission.ts @@ -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 + +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 { + 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[0], + distro: string, + readyLaunch: (serving: { + fullVersion: string + readiness: ServeReadiness + }) => Promise +): Promise { + 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 { + 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 +} diff --git a/src/preload/api/wsl-hosts-api.ts b/src/preload/api/wsl-hosts-api.ts index d58fa4829ce..619a21021e3 100644 --- a/src/preload/api/wsl-hosts-api.ts +++ b/src/preload/api/wsl-hosts-api.ts @@ -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 add: (args: { distro: string; startIfStopped?: boolean }) => Promise remove: (args: { environmentId: string }) => Promise - /** `force` takes an update that live terminals deferred. */ - connect: (args: { environmentId: string; force?: boolean }) => Promise + /** Without an intent this is a plain connect, which skips builds a host refused or rolled back. */ + connect: (args: { environmentId: string; intent?: WslHostConnectIntent }) => Promise getStatuses: () => Promise onStatusChanged: (callback: (status: WslHostStatus) => void) => () => void /** The WSL hosts an open project or terminal needs; only these may boot a stopped distro. */ diff --git a/src/renderer/src/components/settings/WslHostsSection.tsx b/src/renderer/src/components/settings/WslHostsSection.tsx index 29a104c22c8..072249bab30 100644 --- a/src/renderer/src/components/settings/WslHostsSection.tsx +++ b/src/renderer/src/components/settings/WslHostsSection.tsx @@ -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 => { + const connect = async (environmentId: string, intent?: WslHostConnectIntent): Promise => { 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 ? ( @@ -210,12 +215,14 @@ export function WslHostsSection({ {deferred?.forceable ? ( void connect(environment.id, true)} + onSelect={() => void connect(environment.id, 'force')} > {translate('auto.components.settings.wslHosts.forceUpdate', 'Force update')} ) : null} - {deferred && !(ready && !unavailableLabel) ? ( + {deferred && + deferred.code !== ORCAD_HOST_NEWER_DEFERRAL_CODE && + !(ready && !unavailableLabel) ? ( void connect(environment.id)} diff --git a/src/shared/wsl-host-types.ts b/src/shared/wsl-host-types.ts index a13ddcdb794..6760efa73e0 100644 --- a/src/shared/wsl-host-types.ts +++ b/src/shared/wsl-host-types.ts @@ -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. */