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. */