diff --git a/src/main/ipc/wsl-host-handlers.ts b/src/main/ipc/wsl-host-handlers.ts index b66c09ce43c..6d7d99c2c71 100644 --- a/src/main/ipc/wsl-host-handlers.ts +++ b/src/main/ipc/wsl-host-handlers.ts @@ -192,8 +192,10 @@ export function registerWslHostHandlers(args: WslHostHandlerArgs): void { } return wsl.remove(request.environmentId) }) - ipcMain.handle('wslHosts:connect', (_event, request: { environmentId: string }) => - wsl.connect(request.environmentId) + ipcMain.handle( + 'wslHosts:connect', + (_event, request: { environmentId: string; force?: boolean }) => + wsl.connect(request.environmentId, { force: request.force === true }) ) ipcMain.handle('wslHosts:getCapability', (): WslHostCapability => ({ serverFiles: wsl.hasServerFiles() diff --git a/src/main/ssh/orcad-installed-activation.ts b/src/main/ssh/orcad-installed-activation.ts index bb3d608cbd2..f504ba3ca6f 100644 --- a/src/main/ssh/orcad-installed-activation.ts +++ b/src/main/ssh/orcad-installed-activation.ts @@ -71,16 +71,19 @@ const UNVERIFIABLE_CENSUS: OrcadTerminalCensus = { daemonProtocolVersion: null } +function countedLockedRecord(options: OrcadDeployOptions, record: OrcadActivationRecord): boolean { + const counted = options.censusRecord + return ( + !counted || (counted.active === record.active && counted.activatedAt === record.activatedAt) + ) +} + /** A census counted against another incumbent says nothing about this one's terminals. */ function censusForLockedRecord( options: OrcadDeployOptions, record: OrcadActivationRecord ): OrcadTerminalCensus { - const counted = options.censusRecord - return !counted || - (counted.active === record.active && counted.activatedAt === record.activatedAt) - ? options.census - : UNVERIFIABLE_CENSUS + return countedLockedRecord(options, record) ? options.census : UNVERIFIABLE_CENSUS } export async function activateInstalledOrcad( @@ -160,6 +163,20 @@ export async function activateInstalledOrcad( } } + // Last check before anything stops: the census above may predate a long upload. + if (incumbent && options.recountCensus && countedLockedRecord(options, record)) { + const recount = planOrcadUpdate({ + record, + candidateVersion: fullVersion, + census: await options.recountCensus(), + candidateDaemonProtocol: CURRENT_ORCAD_DAEMON_PROTOCOL, + ...(options.force !== undefined ? { force: options.force } : {}) + }) + if (recount.action === 'defer') { + return notActivated(recount.code, recount.reason) + } + } + const startedAt = now() let transaction: OrcadActivateTransaction = createOrcadActivationTransaction({ transactionId: randomUUID(), diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts index c033592c47b..6723de518ca 100644 --- a/src/main/ssh/orcad-remote-deploy.test.ts +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -629,6 +629,42 @@ describe('deployOrcad', () => { expect(script.log.indexOf(`stop:${OLD_VERSION}`)).toBeLessThan(script.log.indexOf('snapshot')) }) + it('defers on a recount just before the stop when a terminal opened during the upload', async () => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [NEW_VERSION]: readyLine({}) }, + log: [] + } + scriptHost(script) + const recountCensus = vi.fn(async () => ({ + liveSessions: 1, + startedSinceActivation: 1, + daemonProtocolVersion: 3 + })) + expect( + await deployOrcad(options({ censusRecord: JSON.parse(ACTIVE_OLD), recountCensus })) + ).toMatchObject({ outcome: 'installed-not-activated', code: 'orcad_update_terminals_running' }) + expect(recountCensus).toHaveBeenCalledTimes(1) + expect(script.log).toEqual(['preflight']) + }) + + it('never recounts an incumbent other than the one it counted', async () => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [NEW_VERSION]: readyLine({}) }, + log: [] + } + scriptHost(script) + const counted = JSON.parse(ACTIVE_OLD) + const peer = withActivatedVersion(counted, '0.1.5+dd01', null, new Date(5), '1.4.0') + vi.mocked(uploadRelayDirectory).mockImplementationOnce(async () => { + script.activationRecord = JSON.stringify(peer) + }) + const recountCensus = vi.fn() + await deployOrcad(options({ censusRecord: counted, admitRecord: () => null, recountCensus })) + expect(recountCensus).not.toHaveBeenCalled() + }) + it('installs but does not activate when terminals are running', async () => { const script: HostScript = { activationRecord: ACTIVE_OLD, diff --git a/src/main/ssh/orcad-remote-deploy.ts b/src/main/ssh/orcad-remote-deploy.ts index e6fcbbc7d77..44dd36c93b8 100644 --- a/src/main/ssh/orcad-remote-deploy.ts +++ b/src/main/ssh/orcad-remote-deploy.ts @@ -57,6 +57,11 @@ export type OrcadDeployOptions = { * incumbent found under the fence makes the census unverifiable rather than wrongly zero. */ censusRecord?: OrcadActivationRecord + /** + * Counts again under the fence just before the incumbent stops, so a terminal opened during + * the upload still defers the update. Called only while the incumbent is the one counted. + */ + recountCensus?: () => Promise force?: boolean /** The Orca app version activating, recorded so an older client never downgrades the host. */ appVersion?: string diff --git a/src/main/wsl/wsl-host-connection-record.ts b/src/main/wsl/wsl-host-connection-record.ts index e3770597f63..6c8d65bffca 100644 --- a/src/main/wsl/wsl-host-connection-record.ts +++ b/src/main/wsl/wsl-host-connection-record.ts @@ -25,12 +25,15 @@ export function persistWslHostConnection( export function toWslHostStatus( record: WslHostDeploymentRecord, - status: WslOrcadConnectionStatus + status: WslOrcadConnectionStatus, + connection: WslOrcadConnection | null = null ): WslHostStatus { return { environmentId: record.environmentId, distro: record.distro, state: status.state, - detail: status.state === 'failed' || status.state === 'unverifiable' ? status.reason : null + 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 } } diff --git a/src/main/wsl/wsl-host-connector.ts b/src/main/wsl/wsl-host-connector.ts index 95080ac3161..bd869b33c89 100644 --- a/src/main/wsl/wsl-host-connector.ts +++ b/src/main/wsl/wsl-host-connector.ts @@ -69,7 +69,7 @@ export function createWslHostConnector(options: { return { listDistros: () => listWslHostDistros(), readOrcadPublicKey: (distro) => readWslDistroOrcadPublicKey(transportFor(distro)), - connect: (distro) => liveness.connect(distro), + connect: (distro, connectOptions) => liveness.connect(distro, connectOptions), get: (distro) => manager.get(distro), disconnect: (distro) => liveness.stop(distro), stopServer: async (distro, environmentId) => { diff --git a/src/main/wsl/wsl-host-e2e-fake-connector.ts b/src/main/wsl/wsl-host-e2e-fake-connector.ts index 372ae0cc9cc..dac9362e5be 100644 --- a/src/main/wsl/wsl-host-e2e-fake-connector.ts +++ b/src/main/wsl/wsl-host-e2e-fake-connector.ts @@ -78,6 +78,7 @@ export function createFakeWslHostConnector( fullVersion: 'e2e', pid: null, pairingOffer: config.pairingCode, + deferredUpdate: null, close: async () => { active.delete(key(distro)) } diff --git a/src/main/wsl/wsl-host-service.test.ts b/src/main/wsl/wsl-host-service.test.ts index 4b73c47bbd8..f53fd27df8b 100644 --- a/src/main/wsl/wsl-host-service.test.ts +++ b/src/main/wsl/wsl-host-service.test.ts @@ -30,6 +30,7 @@ function connection(distro: string, port = 42000, token = 'token-1'): WslOrcadCo fullVersion: '1.0.0+abc', pid: 10, pairingOffer: pairing(port, token), + deferredUpdate: null, close: async () => {} } } @@ -217,6 +218,45 @@ describe('WslHostService', () => { expect(published.at(-1)).toMatchObject({ state: 'distro-stopped', detail: null }) }) + it('shows a deferred update while the incumbent serves, and forwards Force to the connect', async () => { + const connector = makeConnector([UBUNTU]) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + const deferredUpdate = { + candidateVersion: '2.0.0+ff', + code: 'orcad_update_terminals_running', + reason: '1 terminal is running on this host.', + forceable: true + } + service.handleConnectionChange( + 'Ubuntu', + { ...connection('Ubuntu'), deferredUpdate }, + { state: 'ready', census: null } + ) + expect(published.at(-1)).toMatchObject({ state: 'ready', deferredUpdate }) + // A reconnecting host is not serving the incumbent the deferral describes. + service.handleConnectionChange( + 'Ubuntu', + { ...connection('Ubuntu'), deferredUpdate }, + { + state: 'reconnecting', + reason: 'orcad-exited', + attempt: 1, + retryInMs: null, + terminals: 'unverifiable' + } + ) + expect(published.at(-1)).toMatchObject({ + state: 'reconnecting', + deferredUpdate: null + }) + + await service.connect('wsl:ubuntu', { force: true }) + expect(connector.connect).toHaveBeenLastCalledWith('Ubuntu', { + force: true + }) + }) + it('connects for a runtime call only while an open project or terminal needs the host', async () => { const connector = makeConnector([UBUNTU]) const service = makeService(connector) diff --git a/src/main/wsl/wsl-host-service.ts b/src/main/wsl/wsl-host-service.ts index 04a57399e80..aa983f8dca7 100644 --- a/src/main/wsl/wsl-host-service.ts +++ b/src/main/wsl/wsl-host-service.ts @@ -40,8 +40,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. */ - connect(distro: string): Promise + /** User-initiated; may boot the distro. `force` takes an update live terminals deferred. */ + connect(distro: string, options?: { force?: boolean }): Promise get(distro: string): WslOrcadConnection | null disconnect(distro: string): Promise /** Ends the host's terminals, orcad and its terminal daemon; never boots a stopped distro. */ @@ -98,7 +98,7 @@ export class WslHostService { if (connection && persistWslHostConnection(this.deps.userDataPath(), record, connection)) { this.deps.pairingChanged(record.environmentId) } - this.setStatus(record, status) + this.setStatus(record, status, connection) if (status.state === 'ready' && connection) { this.deps.ready(record.environmentId) } @@ -174,8 +174,8 @@ export class WslHostService { } } - /** The user asked for this host: may boot its distro. */ - async connect(environmentId: string): Promise { + /** The user asked for this host: may boot its distro. `force` takes a deferred update now. */ + async connect(environmentId: string, options: { force?: boolean } = {}): Promise { const record = this.recordForEnvironment(environmentId) if (!record) { throw new Error('This server is not a WSL host.') @@ -184,7 +184,7 @@ export class WslHostService { if (refusal || this.removing.has(environmentId)) { throw new Error(refusal?.message ?? WSL_HOST_REMOVING_MESSAGE) } - await this.wsl.connect(record.distro) + await this.wsl.connect(record.distro, options) } /** @@ -315,14 +315,18 @@ export class WslHostService { } const record = this.recordForEnvironment(environmentId) if (record) { - this.setStatus(record, { state: 'ready', census: null }) + this.setStatus(record, { state: 'ready', census: null }, connection) } this.deps.ready(environmentId) return { ok: true, environmentId } } - private setStatus(record: WslHostDeploymentRecord, status: WslOrcadConnectionStatus): void { - const next = toWslHostStatus(record, status) + private setStatus( + record: WslHostDeploymentRecord, + status: WslOrcadConnectionStatus, + connection: WslOrcadConnection | null = null + ): void { + const next = toWslHostStatus(record, status, connection) this.statuses.set(record.environmentId, next) this.deps.publish(next) } diff --git a/src/main/wsl/wsl-orcad-connection.test.ts b/src/main/wsl/wsl-orcad-connection.test.ts index e2229e2ffb2..2e8139ec356 100644 --- a/src/main/wsl/wsl-orcad-connection.test.ts +++ b/src/main/wsl/wsl-orcad-connection.test.ts @@ -65,7 +65,7 @@ function readiness(port: number, deviceToken = 'device-token'): ServeReadiness { } } -function ready(port: number, deviceToken?: string): WslOrcadLaunch { +function ready(port: number, deviceToken?: string): Extract { return { outcome: 'ready', distro: 'Ubuntu', @@ -208,6 +208,55 @@ describe('WslOrcadConnectionManager', () => { expect(children.commands.at(-1)).toContain(` ${second.port}; fi; done;`) }) + it('serves the incumbent with its deferred update, then reports the update landing', async () => { + const { manager, launches, onChange, deployAndLaunch } = setup() + const deferredUpdate = { + candidateVersion: '2.0.0', + code: 'orcad_update_terminals_running', + reason: 'Terminals are running.', + forceable: true + } + launches.push({ ...ready(first.port), deferredUpdate }) + 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 }) + expect(deployAndLaunch).toHaveBeenLastCalledWith( + 'Ubuntu', + expect.objectContaining({ force: true }) + ) + expect(updated).toMatchObject({ + fullVersion: '2.0.0', + deferredUpdate: null + }) + expect(onChange).toHaveBeenLastCalledWith('Ubuntu', updated, { + state: 'ready', + census: null + }) + }) + + 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 }) + await Promise.all([plain, forced]) + expect(deployAndLaunch).toHaveBeenCalledTimes(2) + expect(deployAndLaunch).toHaveBeenLastCalledWith( + 'Ubuntu', + expect.objectContaining({ force: true }) + ) + }) + + 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 }) + await manager.close('Ubuntu') + await plain.catch(() => {}) + await expect(forced).rejects.toBeInstanceOf(WslOrcadConnectionSupersededError) + expect(deployAndLaunch).toHaveBeenCalledTimes(1) + }) + it('throws the deferral and starts no bridge', async () => { const { manager, launches, children } = setup() launches.push({ diff --git a/src/main/wsl/wsl-orcad-connection.ts b/src/main/wsl/wsl-orcad-connection.ts index 9cb39fc08e4..5ef2fc39788 100644 --- a/src/main/wsl/wsl-orcad-connection.ts +++ b/src/main/wsl/wsl-orcad-connection.ts @@ -5,13 +5,10 @@ * deployment record (C7) persists what `onChange` reports. */ import { errorMessage } from '../../shared/error-message' -import { parsePairingCode } from '../../shared/pairing' -import { pairedRuntimeConnectionFromOffer } from '../../shared/paired-runtime-connection' import type { ServeReadiness } from '../server/serve-readiness' import type { OrcadHostTransport } from '../ssh/orcad-host-transport' import { OrcadManagedIdentityError, - verifyOrcadPairingIdentity, type OrcadTunnelIdentity } from '../ssh/orcad-managed-tunnel-identity' import { tunneledOrcadPairingCode } from '../ssh/orcad-tunneled-pairing' @@ -22,11 +19,13 @@ import { wslDistroHolderKey } from './wsl-distro-holder-record' import { defaultWslOrcadEnvironmentId, deployAndLaunchWslOrcad, + type WslOrcadDeferredUpdate, type WslOrcadDeployOptions, type WslOrcadLaunch } from './wsl-orcad-deploy' import type { WslOrcadHostTarget } from './wsl-orcad-host-target' import { createWslOrcadHostTransport } from './wsl-orcad-host-transport' +import { verifyWslOrcadIdentity } from './wsl-orcad-pairing-identity' import { trackWslOrcadHostEnvironment } from './wsl-orcad-host-environments' import { listenWslOrcadStdioBridge, @@ -45,6 +44,8 @@ export type WslOrcadConnection = { pid: number | null /** orcad's pairing code, with its endpoint rewritten to `ws://127.0.0.1:`. */ pairingOffer: string + /** An app update the incumbent `fullVersion` is waiting to take (live terminals). */ + deferredUpdate: WslOrcadDeferredUpdate | null close(): Promise } @@ -119,7 +120,19 @@ export class WslOrcadConnectionManager { const key = wslDistroHolderKey(name) const pending = this.inFlight.get(key) if (pending) { + if (!options.force) { + return pending + } + // Force needs its own deploy; joining a plain connect would silently drop it. + const generation = this.generation(key) return pending + .catch(() => {}) + .then(() => { + if (this.generation(key) !== generation) { + throw new WslOrcadConnectionSupersededError(name) + } + return this.ensure(distro, options) + }) } const status = options.status ?? { pending: CONNECTING, ready: READY } // A refresh of a live connection stays quiet; liveness recovery always reports. @@ -262,6 +275,7 @@ export class WslOrcadConnectionManager { fullVersion: launch.fullVersion, pid: launch.pid, pairingOffer, + deferredUpdate: launch.deferredUpdate ?? null, close: () => this.closeEntry(key, entry) } } @@ -283,13 +297,15 @@ export class WslOrcadConnectionManager { announce: WslOrcadConnectionStatus | null ): WslOrcadConnection { const pairingOffer = tunneledOrcadPairingCode(launch.readiness, entry.connection.localPort) + const deferredUpdate = launch.deferredUpdate ?? null const changed = pairingOffer !== entry.connection.pairingOffer || launch.fullVersion !== entry.connection.fullVersion || - launch.pid !== entry.connection.pid + launch.pid !== entry.connection.pid || + JSON.stringify(deferredUpdate) !== JSON.stringify(entry.connection.deferredUpdate) if (changed) { const { fullVersion, pid } = launch - entry.connection = { ...entry.connection, pairingOffer, fullVersion, pid } + entry.connection = { ...entry.connection, pairingOffer, fullVersion, pid, deferredUpdate } this.active.set(key, entry) } trackWslOrcadHostEnvironment(entry.connection, entry.connection) @@ -326,18 +342,6 @@ export class WslOrcadConnectionManager { } } -async function verifyWslOrcadIdentity( - pairingOffer: string, - readiness: ServeReadiness -): Promise { - const offer = parsePairingCode(pairingOffer) - return offer - ? verifyOrcadPairingIdentity(pairedRuntimeConnectionFromOffer(offer), { - runtimeId: readiness.runtimeId || null - }) - : { verdict: 'foreign', detail: 'The Orca server in WSL published an invalid pairing offer.' } -} - function defaultTransport(target: WslOrcadHostTarget): OrcadHostTransport { return createWslOrcadHostTransport({ target }) } diff --git a/src/main/wsl/wsl-orcad-deploy.test.ts b/src/main/wsl/wsl-orcad-deploy.test.ts index 0ff1a893b1c..4cf60cf0763 100644 --- a/src/main/wsl/wsl-orcad-deploy.test.ts +++ b/src/main/wsl/wsl-orcad-deploy.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { OrcadHostTransport } from '../ssh/orcad-host-transport' import type * as ManagedRuntimeContext from '../ssh/orcad-managed-runtime-context' +import type * as RemoteVersionedInstall from '../ssh/remote-versioned-install' import { emptyOrcadActivationRecord } from '../ssh/orcad-activation-record' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' import type { WslDistroHolder } from './wsl-distro-holder' @@ -15,7 +16,17 @@ const mocks = vi.hoisted(() => ({ probe: vi.fn(), prune: vi.fn(), wake: vi.fn(), - adopt: vi.fn() + adopt: vi.fn(), + incumbent: vi.fn(), + localVersion: vi.fn(), + readRecord: vi.fn() +})) +vi.mock('../ssh/orcad-activation-record-store', () => ({ + readOrcadActivationRecord: mocks.readRecord +})) +vi.mock('../ssh/remote-versioned-install', async (importOriginal) => ({ + ...(await importOriginal()), + readLocalFullVersion: mocks.localVersion })) vi.mock('./wsl-orcad-instance-adoption', () => ({ adoptUnrecordedWslOrcad: mocks.adopt })) @@ -83,6 +94,7 @@ function launch(kernel = '5.15.167.4-microsoft-standard-WSL2', options = {}) { holder: holder(), createTransport: transport, readRegistryId: async () => '0283592d-be56-40d4-b935-3dc18c3aa007', + readIncumbent: mocks.incumbent, ...options }) } @@ -114,6 +126,15 @@ describe('deployAndLaunchWslOrcad', () => { return { outcome: 'installed-and-activated', fullVersion: '1.5.0+ab', verdict: {} } }) mocks.probe.mockResolvedValue(READINESS) + mocks.localVersion.mockReturnValue('1.5.0+ab') + mocks.incumbent.mockResolvedValue({ + census: { + liveSessions: null, + startedSinceActivation: null, + daemonProtocolVersion: null + }, + readiness: null + }) }) it('holds the distro before it deploys and launches, and reports what C5 dials', async () => { @@ -258,6 +279,165 @@ describe('deployAndLaunchWslOrcad', () => { }) }) +describe('deployAndLaunchWslOrcad across an app update (E1)', () => { + const INCUMBENT_READINESS = { + ...READINESS, + runtimeId: 'r-old', + boundEndpoint: 'ws://127.0.0.1:6791' + } + const LIVE = { + liveSessions: 2, + startedSinceActivation: 0, + daemonProtocolVersion: 3 + } + const activeOld = { + ...emptyOrcadActivationRecord(), + active: '1.4.0+aa', + activeAppVersion: '1.4.0', + activatedAt: '2026-10-12T01:00:00.000Z' + } + + beforeEach(() => { + vi.clearAllMocks() + mocks.steps.length = 0 + mocks.resolveContext.mockImplementation(async (target: WslOrcadHostTarget, connection) => ({ + activationRecord: activeOld, + serverTarget: 'linux-x64-glibc', + connection, + host, + remoteHome: '/home/u', + target, + userDataDir: '/home/u/.orca' + })) + mocks.recover.mockResolvedValue({ outcome: 'none' }) + mocks.adopt.mockResolvedValue({ outcome: 'none' }) + mocks.probe.mockResolvedValue(READINESS) + mocks.localVersion.mockReturnValue('1.5.0+ab') + mocks.incumbent.mockResolvedValue({ census: LIVE, readiness: INCUMBENT_READINESS }) + mocks.readRecord.mockResolvedValue(activeOld) + }) + + it('plans the update on the census the running orcad reports, not an unknown one', 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.incumbent).toHaveBeenCalledTimes(1) + expect(mocks.deploy.mock.calls[0][0]).toMatchObject({ + census: LIVE, + censusRecord: activeOld + }) + }) + + it('keeps the incumbent serving, with a forceable deferral, while terminals run', async () => { + 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()).resolves.toEqual({ + outcome: 'ready', + distro: 'Ubuntu-24.04', + distroId: '0283592d-be56-40d4-b935-3dc18c3aa007', + port: 6791, + fullVersion: '1.4.0+aa', + pid: 4242, + readiness: INCUMBENT_READINESS, + holder: 'spawned', + deferredUpdate: { + candidateVersion: '1.5.0+ab', + code: 'orcad_update_terminals_running', + reason: '2 terminals are running', + forceable: true + } + }) + expect(mocks.exec).toHaveBeenLastCalledWith( + expect.anything(), + "cat '/home/u/.orca-remote/orcad-1.4.0+aa/.orcad-pid' 2>/dev/null || true", + expect.anything() + ) + // The installed candidate is what a later Force activates; nothing may collect it. + expect(mocks.prune).not.toHaveBeenCalled() + expect(mocks.probe).not.toHaveBeenCalled() + }) + + it('does not serve an incumbent another client replaced during the deploy', async () => { + mocks.readRecord.mockResolvedValueOnce({ ...activeOld, active: '1.4.5+bb' }) + mocks.deploy.mockResolvedValueOnce({ + outcome: 'installed-not-activated', + fullVersion: '1.5.0+ab', + code: 'orcad_update_terminals_running', + reason: 'terminals are running' + }) + await expect(launch()).resolves.toMatchObject({ outcome: 'deferred', forceable: true }) + }) + + it('forwards force to the update planner', async () => { + mocks.deploy.mockResolvedValueOnce({ + outcome: 'installed-and-activated', + fullVersion: '1.5.0+ab', + verdict: {} + }) + await launch(undefined, { force: true }) + expect(mocks.deploy.mock.calls[0][0]).toMatchObject({ + force: true, + census: LIVE + }) + }) + + it('reports a deferral when the incumbent could not be proven serving', async () => { + mocks.incumbent.mockResolvedValueOnce({ + census: { + liveSessions: null, + startedSinceActivation: null, + daemonProtocolVersion: null + }, + readiness: null + }) + mocks.deploy.mockResolvedValueOnce({ + outcome: 'installed-not-activated', + fullVersion: '1.5.0+ab', + code: 'orcad_update_terminal_census_unavailable', + reason: 'no census' + }) + await expect(launch()).resolves.toMatchObject({ + outcome: 'deferred', + forceable: false + }) + }) + + it('does not serve the incumbent past a refusal that is not a wait', async () => { + mocks.deploy.mockResolvedValueOnce({ + outcome: 'installed-not-activated', + fullVersion: '1.5.0+ab', + code: 'orcad_activation_policy_refused', + reason: 'newer Orca ran this host' + }) + await expect(launch()).resolves.toMatchObject({ + outcome: 'deferred', + forceable: true + }) + }) + + it('never asks the incumbent when it already runs this build', async () => { + mocks.localVersion.mockReturnValue('1.4.0+aa') + mocks.deploy.mockResolvedValueOnce({ + outcome: 'already-active', + fullVersion: '1.4.0+aa' + }) + mocks.wake.mockResolvedValueOnce({ outcome: 'serving' }) + await launch() + expect(mocks.incumbent).not.toHaveBeenCalled() + expect(mocks.deploy.mock.calls[0][0].census.liveSessions).toBeNull() + }) +}) + describe('isWsl1KernelRelease', () => { it('tells the WSL 1 syscall layer from a WSL 2 kernel, stock or custom', () => { expect(isWsl1KernelRelease('4.4.0-19041-Microsoft')).toBe(true) diff --git a/src/main/wsl/wsl-orcad-deploy.ts b/src/main/wsl/wsl-orcad-deploy.ts index 27cdb045696..d44019d44f4 100644 --- a/src/main/wsl/wsl-orcad-deploy.ts +++ b/src/main/wsl/wsl-orcad-deploy.ts @@ -6,6 +6,7 @@ import { getAppEnvironment } from '../../shared/app-environment' import { ORCAD_MANAGED_REMOTE_PORT } from '../../shared/orcad-managed-runtime' import type { ServeReadiness } from '../server/serve-readiness' +import type { WslHostDeferredUpdate } from '../../shared/wsl-host-types' import { recoverInterruptedOrcadActivation } from '../ssh/orcad-activation-recovery' import { readOrcadActivationRecord } from '../ssh/orcad-activation-record-store' import { materializeOrcadArtifact } from '../orcad/orcad-artifact-materializer' @@ -20,6 +21,7 @@ import { managedOrcadSlot, probeManagedOrcadReadiness } from '../ssh/orcad-managed-runtime-context' +import { isWaitingOrcadUpdateDeferral } from '../ssh/orcad-managed-auto-update' import { pruneManagedOrcadVersions } from '../ssh/orcad-managed-version-gc' import { wakeStoppedManagedOrcad } from '../ssh/orcad-managed-wake' import { resolveOrcadRemoteContext } from '../ssh/orcad-remote-context' @@ -28,13 +30,18 @@ import { ORCAD_PID_FILENAME } from '../ssh/orcad-remote-launch' import { shellEscape } from '../ssh/ssh-connection-utils' import { execCommand } from '../ssh/ssh-relay-exec-command' import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { readLocalFullVersion } from '../ssh/remote-versioned-install' import { createWslDistroHolder, type WslDistroHolder } from './wsl-distro-holder' import { wslDistroHolderKey } from './wsl-distro-holder-record' import { readWslDistroRegistryId } from './wsl-distro-registry-id' 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' +/** An update this app carries that the host may not take yet; the incumbent keeps serving. */ +export type WslOrcadDeferredUpdate = WslHostDeferredUpdate + export type WslOrcadLaunch = | { outcome: 'ready' @@ -49,15 +56,10 @@ export type WslOrcadLaunch = holder: 'adopted' | 'spawned' | 'unverifiable' /** An orcad the record did not name (e.g. left by a failed Remove), now recorded. */ adoptedInstance?: { version: string; pid: number } + /** Set when `fullVersion` is the incumbent because the update must wait. */ + deferredUpdate?: WslOrcadDeferredUpdate } - | { - outcome: 'deferred' - distro: string - candidateVersion: string - code: string - reason: string - forceable: boolean - } + | ({ outcome: 'deferred'; distro: string } & WslOrcadDeferredUpdate) export type WslOrcadDeployOptions = { /** The WSL environment's id; keys host-scoped state (wake dedupe). Defaults per distro. */ @@ -69,6 +71,9 @@ export type WslOrcadDeployOptions = { holder?: WslDistroHolder createTransport?: (target: WslOrcadHostTarget) => OrcadHostTransport & { dispose?(): void } readRegistryId?: (distro: string) => Promise + readIncumbent?: typeof readWslOrcadIncumbent + /** The orcad template to deploy from; defaults to the packaged one (the C10 lane swaps it). */ + templateDir?: string } /** Until the persisted deployment record (C7) assigns one, the environment is keyed per distro. */ @@ -138,23 +143,33 @@ async function launchOverTransport( } const localOrcadDir = await materializeOrcadArtifact(context.serverTarget, { signal, - trustVerificationRecord: true + trustVerificationRecord: true, + ...(options.templateDir ? { templateDir: options.templateDir } : {}) }) const appVersion = getAppEnvironment().getVersion() + 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) + ? await (options.readIncumbent ?? readWslOrcadIncumbent)({ + context, + slot + }) + : null const deployed = await deployOrcad({ ...slot, conn, localOrcadDir, target: context.serverTarget, ...managedOrcadDeployPolicy(context, appVersion, options.force), + ...(incumbent ? { census: incumbent.census } : {}), + ...(incumbent?.recount ? { recountCensus: incumbent.recount } : {}), force: options.force, appVersion, recordedNodeRuntimeProbe: true }) if (deployed.outcome === 'installed-not-activated') { - return { - outcome: 'deferred', - distro: name, + const deferredUpdate: WslOrcadDeferredUpdate = { candidateVersion: deployed.fullVersion, code: deployed.code, reason: deployed.reason, @@ -162,6 +177,21 @@ async function launchOverTransport( isForceableOrcadDeferral(deployed.code) || deployed.code === ORCAD_ACTIVATION_POLICY_REFUSED_CODE } + // Waiting (e.g. live terminals) is not broken: the incumbent serves until Update or Force. + if ( + incumbentVersion && + incumbent?.readiness && + isWaitingOrcadUpdateDeferral(deployed.code) && + // Another client may have activated a build since the census; then this one is stale. + (await readOrcadActivationRecord(slot)).active === incumbentVersion + ) { + const serving = { + fullVersion: incumbentVersion, + readiness: incumbent.readiness + } + return { ...(await readyLaunch(serving)), deferredUpdate } + } + return { outcome: 'deferred', distro: name, ...deferredUpdate } } if (deployed.outcome === 'already-active') { // A stopped distro ended the active slot with it; start it again only on proven exit. @@ -182,27 +212,34 @@ async function launchOverTransport( activeVersion: deployed.fullVersion, readiness }) - const pidFile = joinRemotePath( - context.host, - managedOrcadInstallDir(context, deployed.fullVersion), - ORCAD_PID_FILENAME - ) - const pid = Number.parseInt( - await execCommand(conn, `cat ${shellEscape(pidFile)} 2>/dev/null || true`, { signal }), - 10 - ) - return { - outcome: 'ready', - distro: name, - distroId: await distroId, - port: orcadBoundPort(readiness) ?? ORCAD_MANAGED_REMOTE_PORT, - fullVersion: deployed.fullVersion, - pid: Number.isSafeInteger(pid) && pid > 0 ? pid : null, - readiness, - holder: held.outcome, - ...(adoption.outcome === 'adopted' - ? { adoptedInstance: { version: adoption.version, pid: adoption.pid } } - : {}) + return readyLaunch({ fullVersion: deployed.fullVersion, readiness }) + + async function readyLaunch(serving: { + fullVersion: string + readiness: ServeReadiness + }): Promise> { + const pidFile = joinRemotePath( + context.host, + managedOrcadInstallDir(context, serving.fullVersion), + ORCAD_PID_FILENAME + ) + const pid = Number.parseInt( + await execCommand(conn, `cat ${shellEscape(pidFile)} 2>/dev/null || true`, { signal }), + 10 + ) + return { + outcome: 'ready', + distro: name, + distroId: await distroId, + port: orcadBoundPort(serving.readiness) ?? ORCAD_MANAGED_REMOTE_PORT, + fullVersion: serving.fullVersion, + pid: Number.isSafeInteger(pid) && pid > 0 ? pid : null, + readiness: serving.readiness, + holder: held.outcome, + ...(adoption.outcome === 'adopted' + ? { adoptedInstance: { version: adoption.version, pid: adoption.pid } } + : {}) + } } } diff --git a/src/main/wsl/wsl-orcad-incumbent.test.ts b/src/main/wsl/wsl-orcad-incumbent.test.ts new file mode 100644 index 00000000000..07e37ba0cd1 --- /dev/null +++ b/src/main/wsl/wsl-orcad-incumbent.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it, vi } from 'vitest' +import { encodePairingOffer } from '../../shared/pairing' +import { emptyOrcadActivationRecord } from '../ssh/orcad-activation-record' +import type { OrcadHostTransport } from '../ssh/orcad-host-transport' +import type { OrcadRemoteContext } from '../ssh/orcad-remote-context' +import type { ServeReadiness } from '../server/serve-readiness' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import type { StartedPortForward } from '../ssh/ssh-port-forward-provider' +import { readWslOrcadIncumbent, type WslOrcadIncumbentDependencies } from './wsl-orcad-incumbent' +import type { WslOrcadHostTarget } from './wsl-orcad-host-target' + +const host = getRemoteHostPlatform('linux-x64') +const KEY = Buffer.alloc(32, 7).toString('base64') +const READINESS: ServeReadiness = { + runtimeId: 'r-old', + boundEndpoint: 'ws://127.0.0.1:6791', + advertisedEndpoint: null, + managedWslCliReconciliation: 'settled' as const, + pairing: { + available: true as const, + url: encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:6791', + deviceToken: 'device', + publicKeyB64: KEY + }), + endpoint: 'ws://127.0.0.1:6791', + deviceId: 'device-id', + webClientUrl: null, + scope: 'runtime' as const, + qr: null + } +} +const LIVE = { + liveSessions: 1, + startedSinceActivation: 0, + daemonProtocolVersion: 3 +} +const UNVERIFIABLE = { + liveSessions: null, + startedSinceActivation: null, + daemonProtocolVersion: null +} +const target: WslOrcadHostTarget = { + kind: 'wsl', + id: 'wsl:ubuntu', + distro: 'Ubuntu', + label: 'Ubuntu' +} +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every host call is a dependency stub. +const conn = {} as OrcadHostTransport + +function context(active: string | null = '1.4.0+aa'): OrcadRemoteContext { + return { + activationRecord: { + ...emptyOrcadActivationRecord(), + active, + activatedAt: active ? '2026-10-12T01:00:00.000Z' : null + }, + serverTarget: 'linux-x64-glibc', + connection: conn, + host, + remoteHome: '/home/u', + target, + userDataDir: '/home/u/.orca' + } +} + +const slot = { + conn, + host, + remoteHome: '/home/u', + nodePath: '', + userDataDir: '/home/u/.orca', + bindHost: '127.0.0.1', + port: 6768 +} + +function dependencies(overrides: Partial = {}) { + const close = vi.fn(async () => {}) + const deps: WslOrcadIncumbentDependencies = { + wake: vi.fn(async () => ({ outcome: 'serving' as const })), + probe: vi.fn(async () => READINESS), + readBuildHash: vi.fn(async () => '0123456789abcdef'), + listen: vi.fn(async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only entry.localPort and close are read. + return { + entry: { localPort: 51000 }, + close + } as unknown as StartedPortForward + }), + census: vi.fn(async () => LIVE), + ...overrides + } + return { deps, close } +} + +describe('readWslOrcadIncumbent', () => { + it('asks a serving incumbent over a bridge to its bound port, then closes the bridge', async () => { + const { deps, close } = dependencies() + await expect(readWslOrcadIncumbent({ context: context(), slot }, deps)).resolves.toMatchObject({ + census: LIVE, + readiness: READINESS + }) + expect(deps.probe).toHaveBeenCalledWith( + expect.objectContaining({ + remoteInstallDir: '/home/u/.orca-remote/orcad-1.4.0+aa' + }), + { buildHash: '0123456789abcdef', fullVersion: '1.4.0+aa' } + ) + expect(deps.listen).toHaveBeenCalledWith(conn, 6791, target) + const [connection, activatedAt, , options] = vi.mocked(deps.census).mock.calls[0] + expect(connection.endpoint).toMatch(/^ws:\/\/127\.0\.0\.1:51000\/?$/u) + expect(activatedAt).toBe(Date.parse('2026-10-12T01:00:00.000Z')) + expect(options).toEqual({ releaseFinishedAutomationTerminals: true }) + expect(close).toHaveBeenCalledTimes(1) + }) + + it('recounts over a fresh bridge without waking anything again', async () => { + const { deps, close } = dependencies() + const incumbent = await readWslOrcadIncumbent({ context: context(), slot }, deps) + vi.mocked(deps.census).mockResolvedValueOnce({ ...LIVE, liveSessions: 3 }) + await expect(incumbent.recount?.()).resolves.toMatchObject({ liveSessions: 3 }) + expect(deps.wake).toHaveBeenCalledTimes(1) + expect(deps.listen).toHaveBeenCalledTimes(2) + expect(close).toHaveBeenCalledTimes(2) + }) + + // After a reboot the woken incumbent's daemon starts empty, so the census proves zero. + it('wakes an incumbent a reboot stopped and reads it without probing again', async () => { + const { deps } = dependencies({ + wake: vi.fn(async () => ({ + outcome: 'started' as const, + readiness: READINESS + })), + census: vi.fn(async () => ({ + liveSessions: 0, + startedSinceActivation: 0, + daemonProtocolVersion: 3 + })) + }) + await expect(readWslOrcadIncumbent({ context: context(), slot }, deps)).resolves.toMatchObject({ + census: { liveSessions: 0 }, + readiness: READINESS + }) + expect(deps.probe).not.toHaveBeenCalled() + }) + + it('is unverifiable, without a bridge, when the incumbent cannot be proven running', async () => { + const { deps } = dependencies({ + wake: vi.fn(async () => ({ outcome: 'unverifiable' as const })) + }) + await expect(readWslOrcadIncumbent({ context: context(), slot }, deps)).resolves.toEqual({ + census: UNVERIFIABLE, + readiness: null, + recount: null + }) + expect(deps.listen).not.toHaveBeenCalled() + }) + + it('keeps the readiness but never reads zero when the census fails', async () => { + const { deps, close } = dependencies({ + census: vi.fn(async () => { + throw new Error('socket closed') + }) + }) + await expect(readWslOrcadIncumbent({ context: context(), slot }, deps)).resolves.toMatchObject({ + census: UNVERIFIABLE, + readiness: READINESS + }) + expect(close).toHaveBeenCalledTimes(1) + }) + + it('touches nothing when no version is active', async () => { + const { deps } = dependencies() + await expect(readWslOrcadIncumbent({ context: context(null), slot }, deps)).resolves.toEqual({ + census: UNVERIFIABLE, + readiness: null, + recount: null + }) + expect(deps.wake).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/wsl/wsl-orcad-incumbent.ts b/src/main/wsl/wsl-orcad-incumbent.ts new file mode 100644 index 00000000000..c575a94f7b0 --- /dev/null +++ b/src/main/wsl/wsl-orcad-incumbent.ts @@ -0,0 +1,135 @@ +/** + * The orcad a WSL host already runs, read before an app update replaces it (Track E1). SSH asks + * it through its tunnel; a WSL host has no standing tunnel during deploy, so this opens a + * short-lived stdio bridge. Anything unproven reads as an unverifiable census, never zero. + */ +import { pairedRuntimeConnectionFromOffer } from '../../shared/paired-runtime-connection' +import { parsePairingCode } from '../../shared/pairing' +import { ORCAD_MANAGED_REMOTE_PORT } from '../../shared/orcad-managed-runtime' +import type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census' +import type { ServeReadiness } from '../server/serve-readiness' +import { probeActiveOrcadReadiness } from '../ssh/orcad-active-readiness' +import { orcadBoundPort } from '../ssh/orcad-managed-bound-port' +import { managedOrcadInstallDir } from '../ssh/orcad-managed-runtime-context' +import { wakeStoppedManagedOrcad } from '../ssh/orcad-managed-wake' +import type { OrcadRemoteContext } from '../ssh/orcad-remote-context' +import type { OrcadSlotOptions } from '../ssh/orcad-recovery-slot' +import { readRemoteOrcadBuildHash } from '../ssh/orcad-remote-build-hash' +import { requestOrcadTerminalCensus } from '../ssh/orcad-terminal-census-client' +import { tunneledOrcadPairingCode } from '../ssh/orcad-tunneled-pairing' +import type { StartedPortForward } from '../ssh/ssh-port-forward-provider' +import type { OrcadHostTransport } from '../ssh/orcad-host-transport' +import type { WslOrcadHostTarget } from './wsl-orcad-host-target' +import { listenWslOrcadStdioBridge } from './wsl-orcad-stdio-bridge' + +export type WslOrcadIncumbent = { + census: OrcadTerminalCensus + /** The incumbent's proven readiness; null when it could not be started or proven. */ + readiness: ServeReadiness | null + /** Counts again (no wake) just before activation stops the incumbent; null without readiness. */ + recount: (() => Promise) | null +} + +const UNVERIFIABLE: OrcadTerminalCensus = { + liveSessions: null, + startedSinceActivation: null, + daemonProtocolVersion: null +} + +export type WslOrcadIncumbentDependencies = { + wake: typeof wakeStoppedManagedOrcad + probe: typeof probeActiveOrcadReadiness + readBuildHash: typeof readRemoteOrcadBuildHash + listen: ( + transport: OrcadHostTransport, + remotePort: number, + target: WslOrcadHostTarget + ) => Promise + census: typeof requestOrcadTerminalCensus +} + +const DEFAULT_DEPENDENCIES: WslOrcadIncumbentDependencies = { + wake: wakeStoppedManagedOrcad, + probe: probeActiveOrcadReadiness, + readBuildHash: readRemoteOrcadBuildHash, + listen: (transport, remotePort, target) => + listenWslOrcadStdioBridge(transport, { + environmentId: target.id, + remotePort, + localPort: 0, + label: `Orca server in WSL: ${target.label} (update check)` + }), + census: requestOrcadTerminalCensus +} + +/** + * Wakes the active slot if a reboot or idle exit stopped it (its daemon then starts empty, so + * the census proves zero), then asks it for its census over a bridge closed before returning. + */ +export async function readWslOrcadIncumbent( + args: { + context: OrcadRemoteContext + slot: OrcadSlotOptions + }, + dependencies: WslOrcadIncumbentDependencies = DEFAULT_DEPENDENCIES +): Promise { + const record = args.context.activationRecord + const activatedAt = record.activatedAt ? Date.parse(record.activatedAt) : Number.NaN + if (!record.active || !Number.isFinite(activatedAt) || activatedAt < 0) { + return { census: UNVERIFIABLE, readiness: null, recount: null } + } + let readiness: ServeReadiness + try { + const wake = await dependencies.wake(args.slot) + if (wake.outcome === 'started') { + readiness = wake.readiness + } else if (wake.outcome === 'serving') { + const remoteInstallDir = managedOrcadInstallDir(args.context, record.active) + readiness = await dependencies.probe( + { ...args.slot, remoteInstallDir }, + { + buildHash: await dependencies.readBuildHash(args.slot, remoteInstallDir), + fullVersion: record.active + } + ) + } else { + return { census: UNVERIFIABLE, readiness: null, recount: null } + } + } catch { + return { census: UNVERIFIABLE, readiness: null, recount: null } + } + const recount = (): Promise => + countIncumbentTerminals(args, readiness, activatedAt, dependencies) + return { census: await recount(), readiness, recount } +} + +async function countIncumbentTerminals( + args: { context: OrcadRemoteContext; slot: OrcadSlotOptions }, + readiness: ServeReadiness, + activatedAt: number, + dependencies: WslOrcadIncumbentDependencies +): Promise { + let forward: StartedPortForward | null = null + try { + forward = await dependencies.listen( + args.slot.conn, + orcadBoundPort(readiness) ?? ORCAD_MANAGED_REMOTE_PORT, + args.context.target + ) + const offer = parsePairingCode(tunneledOrcadPairingCode(readiness, forward.entry.localPort)) + if (!offer) { + return UNVERIFIABLE + } + // Why release: finished automation shells would otherwise defer every update, as on SSH. + return await dependencies.census( + pairedRuntimeConnectionFromOffer(offer), + activatedAt, + undefined, + { releaseFinishedAutomationTerminals: true } + ) + } catch { + return UNVERIFIABLE + } finally { + await forward?.close().catch(() => {}) + } +} diff --git a/src/main/wsl/wsl-orcad-liveness-model.ts b/src/main/wsl/wsl-orcad-liveness-model.ts index 42532d4c284..132c6c595e9 100644 --- a/src/main/wsl/wsl-orcad-liveness-model.ts +++ b/src/main/wsl/wsl-orcad-liveness-model.ts @@ -160,3 +160,38 @@ export async function ensureHolderQuietly( console.warn(`[wsl] holder re-ensure for ${distro} failed:`, error) } } + +/** Cancels the entry's timer and wake wait and closes its watch. */ +export function disarmSupervised(entry: Supervised): void { + entry.timer?.cancel() + entry.timer = null + entry.wakeWait?.() + entry.wakeWait = null + entry.watch?.close() + entry.watch = null +} + +/** Holds a new watch unless a later connect superseded this one or already holds one. */ +export async function attachSupervisedWatch( + entry: Supervised, + openWatch: () => Promise, + onExit: (code: number | null) => void +): Promise { + const generation = entry.generation + const watch = await openWatch() + if (watch.signal !== 'ready') { + return watch.signal + } + if (entry.generation !== generation || entry.watch) { + watch.close() + return 'ready' + } + entry.watch = watch + watch.onExit((code) => { + if (entry.watch === watch) { + entry.watch = null + onExit(code) + } + }) + return 'ready' +} diff --git a/src/main/wsl/wsl-orcad-liveness-supervisor.test.ts b/src/main/wsl/wsl-orcad-liveness-supervisor.test.ts index 69bb591bf85..a37dc3024ea 100644 --- a/src/main/wsl/wsl-orcad-liveness-supervisor.test.ts +++ b/src/main/wsl/wsl-orcad-liveness-supervisor.test.ts @@ -58,6 +58,7 @@ function harness(options: { visible?: boolean } = {}) { fullVersion: '1', pid: pid++, pairingOffer: 'offer', + deferredUpdate: null, close: async () => {} } if (opts?.status) { @@ -229,6 +230,27 @@ describe('WslOrcadLivenessSupervisor', () => { expect(h.collectCensus).toHaveBeenCalledTimes(1) }) + it('keeps watching the incumbent when an update connect fails', async () => { + 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 }) + expect(h.watches).toHaveLength(2) + expect(h.lastWatch().closed).toBe(false) + }) + + it('reports ready itself when a user connect supersedes a recovery', async () => { + const h = harness() + await h.supervisor.connect('Ubuntu') + h.state.identity = { distroInstance: 'boot:1', orcad: null } + h.lastWatch().exit(0) + await h.settle() + expect(h.statuses.at(-1)).toMatchObject({ state: 'reconnecting' }) + await h.supervisor.connect('Ubuntu') + expect(h.statuses.at(-1)).toEqual({ state: 'ready', census: null }) + }) + it('leaves an orcad nobody uses stopped instead of undoing its idle exit', async () => { const h = harness({ visible: false }) await h.supervisor.connect('Ubuntu') diff --git a/src/main/wsl/wsl-orcad-liveness-supervisor.ts b/src/main/wsl/wsl-orcad-liveness-supervisor.ts index 16ebcd1e1c9..9e6d989facb 100644 --- a/src/main/wsl/wsl-orcad-liveness-supervisor.ts +++ b/src/main/wsl/wsl-orcad-liveness-supervisor.ts @@ -13,7 +13,9 @@ import type { WslOrcadConnectionStatus } from './wsl-orcad-connection-status' import { wslDistroHolderKey } from './wsl-distro-holder-record' import { recoverWslOrcad, type WslOrcadRecoveryReason } from './wsl-orcad-liveness-recovery' import { + attachSupervisedWatch, confirmsRunning, + disarmSupervised, defaultSchedule, ensureHolderQuietly, EXIT_SETTLE_MS, @@ -34,7 +36,6 @@ import { type WslLivenessVerdict } from './wsl-orcad-liveness-verdict' import { WslOrcadReconnectBackoff, wslOrcadVerifyRetryDelayMs } from './wsl-orcad-reconnect-backoff' -import type { WslOrcadWatch } from './wsl-orcad-watch' export class WslOrcadLivenessSupervisor { private readonly supervised = new Map() @@ -59,15 +60,30 @@ export class WslOrcadLivenessSupervisor { } /** User-initiated, so it may boot the distro. Resolves once the watch is armed. */ - async connect(distro: string): Promise { + async connect(distro: string, options: { force?: boolean } = {}): Promise { const entry = this.entryFor(distro) + // A recovery this connect supersedes would have reported ready itself; now nothing will. + const supersedesRecovery = entry.busy // Supersedes a recovery or deferred re-check: this connect relaunches and re-arms itself. const generation = (entry.generation += 1) - this.disarm(entry) - const connection = await this.deps.manager.ensure(entry.distro) + disarmSupervised(entry) + let connection: WslOrcadConnection + try { + connection = await this.deps.manager.ensure(entry.distro, options) + } catch (error) { + // e.g. a rejected update: the incumbent still serves and must stay watched. + const incumbent = this.deps.manager.get(entry.distro) + if (incumbent && entry.generation === generation) { + await this.arm(entry, incumbent) + } + throw error + } if (entry.generation === generation) { entry.backoff.reset() await this.arm(entry, connection) + if (supersedesRecovery && entry.generation === generation) { + this.publish(entry, { state: 'ready', census: null }) + } } return connection } @@ -84,7 +100,7 @@ export class WslOrcadLivenessSupervisor { const entry = this.supervised.get(key) if (entry) { entry.generation += 1 - this.disarm(entry) + disarmSupervised(entry) this.supervised.delete(key) } await this.deps.manager.close(distro) @@ -116,15 +132,6 @@ export class WslOrcadLivenessSupervisor { this.deps.manager.publish(entry.distro, status) } - private disarm(entry: Supervised): void { - entry.timer?.cancel() - entry.timer = null - entry.wakeWait?.() - entry.wakeWait = null - entry.watch?.close() - entry.watch = null - } - /** Records who this orcad is and holds the watch; that identity anchors every later verdict. */ private async arm(entry: Supervised, connection: WslOrcadConnection): Promise { const generation = entry.generation @@ -140,33 +147,23 @@ export class WslOrcadLivenessSupervisor { entry.verifyStreak = 0 entry.backoff.noteReady(this.now()) if (!entry.watch && (await this.attachWatch(entry, connection)) !== 'ready') { - this.scheduleVerify(entry) + if (entry.generation === generation) { + this.scheduleVerify(entry) + } return } - this.scheduleHeartbeat(entry) + // A later connect owns the timer now; this arm must not replace it. + if (entry.generation === generation) { + this.scheduleHeartbeat(entry) + } } - private async attachWatch( - entry: Supervised, - connection: WslOrcadConnection - ): Promise { - const generation = entry.generation - const watch = await this.deps.openWatch(connection) - if (watch.signal !== 'ready') { - return watch.signal - } - if (entry.generation !== generation || entry.watch) { - watch.close() - return 'ready' - } - entry.watch = watch - watch.onExit((code) => { - if (entry.watch === watch) { - entry.watch = null - this.trigger(entry, { source: 'bridge', code }) - } - }) - return 'ready' + private attachWatch(entry: Supervised, connection: WslOrcadConnection) { + return attachSupervisedWatch( + entry, + () => this.deps.openWatch(connection), + (code) => this.trigger(entry, { source: 'bridge', code }) + ) } private scheduleHeartbeat(entry: Supervised): void { @@ -282,10 +279,10 @@ export class WslOrcadLivenessSupervisor { case 'distro-stopped': return this.onDistroStopped(entry) case 'distro-restarted': - this.disarm(entry) + disarmSupervised(entry) return this.recover(entry, 'distro-restarted') case 'orcad-exited': - this.disarm(entry) + disarmSupervised(entry) if (!this.deps.hasUserVisibleWork(entry.distro)) { // A managed orcad idle-exits; relaunching it for nobody would undo that every time. this.publish(entry, { state: 'idle', terminals: 'unverifiable' }) @@ -296,7 +293,7 @@ export class WslOrcadLivenessSupervisor { } private async onDistroStopped(entry: Supervised): Promise { - this.disarm(entry) + disarmSupervised(entry) const recovering = this.deps.hasUserVisibleWork(entry.distro) // The holder and the watch both exit on a stop; report it once. if (recovering || entry.state !== 'distro-stopped') { diff --git a/src/main/wsl/wsl-orcad-pairing-identity.ts b/src/main/wsl/wsl-orcad-pairing-identity.ts new file mode 100644 index 00000000000..acda9dc6da8 --- /dev/null +++ b/src/main/wsl/wsl-orcad-pairing-identity.ts @@ -0,0 +1,20 @@ +import { pairedRuntimeConnectionFromOffer } from '../../shared/paired-runtime-connection' +import { parsePairingCode } from '../../shared/pairing' +import type { ServeReadiness } from '../server/serve-readiness' +import { + verifyOrcadPairingIdentity, + type OrcadTunnelIdentity +} from '../ssh/orcad-managed-tunnel-identity' + +/** Whether the server behind a WSL bridge is the orcad that published `readiness`. */ +export async function verifyWslOrcadIdentity( + pairingOffer: string, + readiness: ServeReadiness +): Promise { + const offer = parsePairingCode(pairingOffer) + return offer + ? verifyOrcadPairingIdentity(pairedRuntimeConnectionFromOffer(offer), { + runtimeId: readiness.runtimeId || null + }) + : { verdict: 'foreign', detail: 'The Orca server in WSL published an invalid pairing offer.' } +} 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 1d3d83dc91b..fbe624a45cd 100644 --- a/src/main/wsl/wsl-orcad-real-distro-lane.test.ts +++ b/src/main/wsl/wsl-orcad-real-distro-lane.test.ts @@ -2,14 +2,16 @@ // wsl.exe stdio bridge (C5), drive runtime RPC through it, and prove liveness (C6) on one // systemd-off and one systemd-on distro. Two phases, each its own process, so the holder is // proven to outlive the process that spawned it: -// launch - deploy, RPC, kill orcad => relaunch + census, `wsl --terminate` => terminals exited +// launch - deploy, RPC, kill orcad => relaunch + census, `wsl --terminate` => terminals exited; +// then an app update (E1): deferred over a live terminal, forced past it with the +// terminal kept, silent with none, and silent again after a reboot // survive - >= 60 s after launch exited: distros still Running, holders adopted live, reconnect, // teardown leaves no bridge or holder processes, WSLInterop intact // .github/workflows/wsl-orcad-e2e.yml imports the distros and runs both phases. // // Run: ORCA_RUN_WSL_ORCAD_LANE=1 ORCA_WSL_ORCAD_LANE_PHASE=launch|survive pnpm test -import { randomBytes } from 'node:crypto' -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { createHash, randomBytes } from 'node:crypto' +import { appendFileSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' @@ -23,6 +25,10 @@ import { pairedRuntimeConnectionFromOffer } from '../../shared/paired-runtime-co import { parsePairingCode } from '../../shared/pairing' import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' +import { + ORCAD_LAUNCHER_FILENAME, + ORCAD_TEMPLATE_MANIFEST_FILENAME +} from '../../shared/orcad-artifacts' import { requestOrcadTerminalCensus } from '../ssh/orcad-terminal-census-client' import { execCommand } from '../ssh/ssh-relay-exec-command' import { readConfirmedRunningWslDistros } from '../wsl-background-distro-gate' @@ -34,7 +40,7 @@ import { resolveWslExecutablePath } from './wsl-executable-path' import { wslDistroHolderKey } from './wsl-distro-holder-record' import { WslOrcadConnectionManager, type WslOrcadConnection } from './wsl-orcad-connection' import type { WslOrcadConnectionStatus } from './wsl-orcad-connection-status' -import { defaultWslOrcadEnvironmentId } from './wsl-orcad-deploy' +import { defaultWslOrcadEnvironmentId, deployAndLaunchWslOrcad } from './wsl-orcad-deploy' import { createWslOrcadHostTransport } from './wsl-orcad-host-transport' import type { WslOrcadLivenessSupervisor } from './wsl-orcad-liveness-supervisor' import { createWslOrcadLivenessSupervisor } from './wsl-orcad-liveness-wiring' @@ -63,6 +69,8 @@ const LANE_REPO = '$HOME/wsl-orcad-lane-repo' type LaunchState = { launchEndedAtMs: number orcadPids: Record + /** The template each distro last deployed from, so the survive phase does not update it. */ + templates: Record } type Receipt = Record @@ -74,6 +82,8 @@ let visibleWork = true let holder: WslDistroHolder let manager: WslOrcadConnectionManager let supervisor: WslOrcadLivenessSupervisor | null = null +// Per distro key; unset deploys the packaged template (ORCA_ORCAD_TEMPLATE_PATH). +const templates = new Map() function installLaneAppEnvironment(): void { const { version } = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) @@ -245,6 +255,11 @@ function createLaneManager(): void { }) manager = new WslOrcadConnectionManager({ holder, + deployAndLaunch: (distro, options) => + deployAndLaunchWslOrcad(distro, { + ...options, + templateDir: templates.get(wslDistroHolderKey(distro)) + }), onChange: (distro, _connection, status) => { statusLog(distro).push(status) console.log(`[lane] ${distro} ${JSON.stringify(status)}`) @@ -342,6 +357,107 @@ async function proveRuntimeRpc(connection: WslOrcadConnection, distro: string): } } +/** The packaged template with a different orcad build, as the next app release ships one. */ +function nextTemplate(label: string): string { + const packaged = process.env.ORCA_ORCAD_TEMPLATE_PATH + if (!packaged) { + throw new Error('ORCA_ORCAD_TEMPLATE_PATH must name the packaged orcad template') + } + const dir = join(WORK_DIR, `template-${label}`) + rmSync(dir, { recursive: true, force: true }) + cpSync(packaged, dir, { recursive: true }) + const launcher = join(dir, ORCAD_LAUNCHER_FILENAME) + appendFileSync(launcher, `\n// wsl-orcad-lane build ${label}\n`) + const manifestPath = join(dir, ORCAD_TEMPLATE_MANIFEST_FILENAME) + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + manifest.commonSha256[ORCAD_LAUNCHER_FILENAME] = createHash('sha256') + .update(readFileSync(launcher)) + .digest('hex') + writeFileSync(manifestPath, JSON.stringify(manifest)) + return dir +} + +async function waitForNoLiveTerminals(connection: WslOrcadConnection): Promise { + await waitFor( + 'the closed terminal to leave the census', + async () => + (await requestOrcadTerminalCensus(pairedConnection(connection), 0)).liveSessions === 0 + ? true + : null, + 30_000, + 500 + ) +} + +/** App updates (E1): N -> N+1 over a live terminal, N+2 idle, N+3 after a reboot. */ +async function updatePhase(distro: string): Promise { + const result: Receipt = {} + if (!supervisor) { + throw new Error('supervisor missing') + } + const key = wslDistroHolderKey(distro) + const before = await supervisor.connect(distro) + // The launch phase added this repo; its terminals died with the distro. + const worktree = `path:${(await guest(distro, 'printf %s "$HOME/wsl-orcad-lane-repo"')).trim()}` + const openTerminal = async (connection: WslOrcadConnection): Promise => { + const created = await rpc<{ terminal: { handle: string } }>(connection, 'terminal.create', { + worktree + }) + await runInTerminal(connection, created.terminal.handle, ':', `terminal in ${distro}`) + return created.terminal.handle + } + const terminal = await openTerminal(before) + + // A live terminal: the incumbent keeps serving and the update waits, forceably. + templates.set(key, nextTemplate('n1')) + let started = performance.now() + const deferred = await supervisor.connect(distro) + result.deferMs = Math.round(performance.now() - started) + expect(deferred.fullVersion).toBe(before.fullVersion) + expect(deferred.deferredUpdate).toMatchObject({ + code: 'orcad_update_terminals_running', + forceable: true + }) + result.deferred = deferred.deferredUpdate + await runInTerminal(deferred, terminal, ':', `terminal under the incumbent in ${distro}`) + + // Force: the new build serves, and the terminal survives on the preserved daemon. + started = performance.now() + const forced = await supervisor.connect(distro, { force: true }) + result.forceMs = Math.round(performance.now() - started) + expect(forced.fullVersion).toBe(deferred.deferredUpdate?.candidateVersion) + expect(forced.deferredUpdate).toBeNull() + await runInTerminal(forced, terminal, ':', `terminal kept across Force in ${distro}`) + + // No terminals: the next build lands on connect without asking. + await rpc(forced, 'terminal.close', { terminal }) + await waitForNoLiveTerminals(forced) + templates.set(key, nextTemplate('n2')) + started = performance.now() + const silent = await supervisor.connect(distro) + result.silentMs = Math.round(performance.now() - started) + expect(silent.fullVersion).not.toBe(forced.fullVersion) + expect(silent.deferredUpdate).toBeNull() + + // After a reboot the stopped incumbent is woken for its census, which proves zero. + await openTerminal(silent) + visibleWork = false + const since = statusLog(distro).length + expect((await wslExe(['--terminate', distro])).code).toBe(0) + await waitForStatus(distro, since, 'distro-stopped', 30_000) + visibleWork = true + templates.set(key, nextTemplate('n3')) + started = performance.now() + const rebooted = await supervisor.connect(distro) + result.rebootUpdateMs = Math.round(performance.now() - started) + expect(rebooted.fullVersion).not.toBe(silent.fullVersion) + expect(rebooted.deferredUpdate).toBeNull() + await rpc(rebooted, 'status.get') + result.versions = [before, forced, silent, rebooted].map((c) => c.fullVersion) + result.orcadPid = rebooted.pid + return result +} + async function launchPhase(distro: string, systemd: boolean): Promise { const result: Receipt = {} visibleWork = true @@ -416,6 +532,9 @@ async function launchPhase(distro: string, systemd: boolean): Promise { async function survivePhase(): Promise { const state: LaunchState = JSON.parse(readFileSync(STATE_FILE, 'utf8')) + for (const [key, dir] of Object.entries(state.templates ?? {})) { + templates.set(key, dir) + } const waitMs = state.launchEndedAtMs + HOLDER_SURVIVAL_MS - Date.now() if (waitMs > 0) { await new Promise((resolve) => setTimeout(resolve, waitMs)) @@ -503,6 +622,9 @@ describe.runIf(RUN && process.platform === 'win32')('WSL orcad on real WSL2 dist receipt[name] = result pids[name] = typeof result.orcadPid === 'number' ? result.orcadPid : null } + const update = await updatePhase(PLAIN) + receipt.update = update + pids[PLAIN] = typeof update.orcadPid === 'number' ? update.orcadPid : null // Bridges and watches go; the holders must not. await supervisor.dispose() supervisor = null @@ -512,11 +634,12 @@ describe.runIf(RUN && process.platform === 'win32')('WSL orcad on real WSL2 dist } const state: LaunchState = { launchEndedAtMs: Date.now(), - orcadPids: pids + orcadPids: pids, + templates: Object.fromEntries(templates) } writeFileSync(STATE_FILE, JSON.stringify(state)) }, - DISTROS.length * DISTRO_TIMEOUT_MS + (DISTROS.length + 1) * DISTRO_TIMEOUT_MS ) it.runIf(PHASE === 'survive')( diff --git a/src/preload/api/wsl-hosts-api.ts b/src/preload/api/wsl-hosts-api.ts index e373d1ab601..d58fa4829ce 100644 --- a/src/preload/api/wsl-hosts-api.ts +++ b/src/preload/api/wsl-hosts-api.ts @@ -18,7 +18,8 @@ export type WslHostsPreloadApi = { listDistros: () => Promise add: (args: { distro: string; startIfStopped?: boolean }) => Promise remove: (args: { environmentId: string }) => Promise - connect: (args: { environmentId: string }) => Promise + /** `force` takes an update that live terminals deferred. */ + connect: (args: { environmentId: string; force?: boolean }) => 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 bbdb1983ec8..d19336c26c7 100644 --- a/src/renderer/src/components/settings/WslHostsSection.tsx +++ b/src/renderer/src/components/settings/WslHostsSection.tsx @@ -23,6 +23,7 @@ import { } from '../sidebar/wsl-host-row-status' import { Badge } from '../ui/badge' import { Button } from '../ui/button' +import { managedServerOutcomeLabel } from './managed-server-copy' /** Settings → Orca servers: the WSL distros added as hosts (Windows, experimental). */ export function WslHostsSection({ @@ -50,14 +51,15 @@ export function WslHostsSection({ return null } - const connect = async (environmentId: string): Promise => { + const connect = async (environmentId: string, force = false): Promise => { const api = window.api.runtimeEnvironments?.wslHosts if (!api || connectingId) { return } setConnectingId(environmentId) try { - await api.connect({ environmentId }) + // A connect retries a deferred update; force takes it over the running terminals. + await api.connect({ environmentId, force }) onChanged() } catch (error) { toast.error(error instanceof Error ? error.message : String(error)) @@ -110,6 +112,7 @@ export function WslHostsSection({ {hosts.map((environment) => { const status = statuses[environment.id] const ready = status?.state === 'ready' + const deferred = unavailableLabel ? null : status?.deferredUpdate return (
@@ -118,7 +121,44 @@ export function WslHostsSection({ {unavailableLabel ?? getWslHostRowDetail(status)} {!unavailableLabel && status?.detail ? ` — ${status.detail}` : ''}

+ {deferred ? ( +

+ {translate( + 'auto.components.settings.managedServers.row.deferredVersion', + 'Update to {{version}} deferred.', + { version: deferred.candidateVersion } + )}{' '} + {managedServerOutcomeLabel({ + outcome: 'deferred', + code: deferred.code + })} +

+ ) : null}
+ {deferred ? ( + <> + + {deferred.forceable ? ( + + ) : null} + + ) : null} {ready && !unavailableLabel ? null : (