mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(wsl-host): let an app update reach a WSL host orcad (E1) (#28458)
* fix(wsl-host): read the running orcad's census before an app update replaces it * fix(wsl-host): keep Force, the incumbent watch and its freshness through an update * fix(orcad): recount terminals just before an update stops the incumbent * refactor(wsl-host): move watch arming and pairing identity out of oversized modules
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<OrcadTerminalCensus>
|
||||
force?: boolean
|
||||
/** The Orca app version activating, recorded so an older client never downgrades the host. */
|
||||
appVersion?: string
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -78,6 +78,7 @@ export function createFakeWslHostConnector(
|
||||
fullVersion: 'e2e',
|
||||
pid: null,
|
||||
pairingOffer: config.pairingCode,
|
||||
deferredUpdate: null,
|
||||
close: async () => {
|
||||
active.delete(key(distro))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -40,8 +40,8 @@ export type WslHostConnector = {
|
||||
listDistros(): Promise<WslHostDistroCandidate[]>
|
||||
/** Runs in the distro, so it boots a stopped one: only after the user chose to. */
|
||||
readOrcadPublicKey(distro: string): Promise<string | null>
|
||||
/** User-initiated; may boot the distro. */
|
||||
connect(distro: string): Promise<WslOrcadConnection>
|
||||
/** User-initiated; may boot the distro. `force` takes an update live terminals deferred. */
|
||||
connect(distro: string, options?: { force?: boolean }): Promise<WslOrcadConnection>
|
||||
get(distro: string): WslOrcadConnection | null
|
||||
disconnect(distro: string): Promise<void>
|
||||
/** 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<void> {
|
||||
/** The user asked for this host: may boot its distro. `force` takes a deferred update now. */
|
||||
async connect(environmentId: string, options: { force?: boolean } = {}): Promise<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<WslOrcadLaunch, { outcome: 'ready' }> {
|
||||
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({
|
||||
|
||||
@@ -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:<localPort>`. */
|
||||
pairingOffer: string
|
||||
/** An app update the incumbent `fullVersion` is waiting to take (live terminals). */
|
||||
deferredUpdate: WslOrcadDeferredUpdate | null
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
@@ -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<OrcadTunnelIdentity> {
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -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<typeof RemoteVersionedInstall>()),
|
||||
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)
|
||||
|
||||
@@ -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<string | null>
|
||||
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<Extract<WslOrcadLaunch, { outcome: 'ready' }>> {
|
||||
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 } }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<WslOrcadHostTarget> {
|
||||
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<WslOrcadIncumbentDependencies> = {}) {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<OrcadTerminalCensus>) | 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<StartedPortForward>
|
||||
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<WslOrcadHostTarget>
|
||||
slot: OrcadSlotOptions
|
||||
},
|
||||
dependencies: WslOrcadIncumbentDependencies = DEFAULT_DEPENDENCIES
|
||||
): Promise<WslOrcadIncumbent> {
|
||||
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<OrcadTerminalCensus> =>
|
||||
countIncumbentTerminals(args, readiness, activatedAt, dependencies)
|
||||
return { census: await recount(), readiness, recount }
|
||||
}
|
||||
|
||||
async function countIncumbentTerminals(
|
||||
args: { context: OrcadRemoteContext<WslOrcadHostTarget>; slot: OrcadSlotOptions },
|
||||
readiness: ServeReadiness,
|
||||
activatedAt: number,
|
||||
dependencies: WslOrcadIncumbentDependencies
|
||||
): Promise<OrcadTerminalCensus> {
|
||||
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(() => {})
|
||||
}
|
||||
}
|
||||
@@ -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<WslOrcadWatch>,
|
||||
onExit: (code: number | null) => void
|
||||
): Promise<WslOrcadWatch['signal']> {
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<string, Supervised>()
|
||||
@@ -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<WslOrcadConnection> {
|
||||
async connect(distro: string, options: { force?: boolean } = {}): Promise<WslOrcadConnection> {
|
||||
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<void> {
|
||||
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<WslOrcadWatch['signal']> {
|
||||
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<void> {
|
||||
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') {
|
||||
|
||||
@@ -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<OrcadTunnelIdentity> {
|
||||
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.' }
|
||||
}
|
||||
@@ -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 <this file>
|
||||
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<string, number | null>
|
||||
/** The template each distro last deployed from, so the survive phase does not update it. */
|
||||
templates: Record<string, string>
|
||||
}
|
||||
type Receipt = Record<string, unknown>
|
||||
|
||||
@@ -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<string, string>()
|
||||
|
||||
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<void> {
|
||||
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<Receipt> {
|
||||
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<string> => {
|
||||
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<Receipt> {
|
||||
const result: Receipt = {}
|
||||
visibleWork = true
|
||||
@@ -416,6 +532,9 @@ async function launchPhase(distro: string, systemd: boolean): Promise<Receipt> {
|
||||
|
||||
async function survivePhase(): Promise<void> {
|
||||
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')(
|
||||
|
||||
@@ -18,7 +18,8 @@ export type WslHostsPreloadApi = {
|
||||
listDistros: () => Promise<WslHostDistro[]>
|
||||
add: (args: { distro: string; startIfStopped?: boolean }) => Promise<WslHostAddResult>
|
||||
remove: (args: { environmentId: string }) => Promise<WslHostServerStop>
|
||||
connect: (args: { environmentId: string }) => Promise<void>
|
||||
/** `force` takes an update that live terminals deferred. */
|
||||
connect: (args: { environmentId: string; force?: boolean }) => Promise<void>
|
||||
getStatuses: () => Promise<WslHostStatus[]>
|
||||
onStatusChanged: (callback: (status: WslHostStatus) => void) => () => void
|
||||
/** The WSL hosts an open project or terminal needs; only these may boot a stopped distro. */
|
||||
|
||||
@@ -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<void> => {
|
||||
const connect = async (environmentId: string, force = false): Promise<void> => {
|
||||
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 (
|
||||
<div key={environment.id} className="flex items-center gap-2 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -118,7 +121,44 @@ export function WslHostsSection({
|
||||
{unavailableLabel ?? getWslHostRowDetail(status)}
|
||||
{!unavailableLabel && status?.detail ? ` — ${status.detail}` : ''}
|
||||
</p>
|
||||
{deferred ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.managedServers.row.deferredVersion',
|
||||
'Update to {{version}} deferred.',
|
||||
{ version: deferred.candidateVersion }
|
||||
)}{' '}
|
||||
{managedServerOutcomeLabel({
|
||||
outcome: 'deferred',
|
||||
code: deferred.code
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{deferred ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={connectingId !== null}
|
||||
onClick={() => void connect(environment.id)}
|
||||
>
|
||||
{translate('auto.components.settings.managedServers.row.update', 'Update')}
|
||||
</Button>
|
||||
{deferred.forceable ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={connectingId !== null}
|
||||
onClick={() => void connect(environment.id, true)}
|
||||
>
|
||||
{translate('auto.components.settings.wslHosts.forceUpdate', 'Force update')}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{ready && !unavailableLabel ? null : (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -12805,6 +12805,7 @@
|
||||
"add": "Add WSL distro",
|
||||
"connect": "Connect",
|
||||
"remove": "Remove",
|
||||
"forceUpdate": "Force update",
|
||||
"turnOn": "Turn on in Experimental"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -27,6 +27,17 @@ export type WslHostStatus = {
|
||||
state: WslHostConnectionState
|
||||
/** Why it failed or is unverifiable, in the main process's words. */
|
||||
detail: string | null
|
||||
/** An app update the running server waits to take (e.g. live terminals); Update/Force retry it. */
|
||||
deferredUpdate?: WslHostDeferredUpdate | null
|
||||
}
|
||||
|
||||
export type WslHostDeferredUpdate = {
|
||||
candidateVersion: string
|
||||
/** The update planner's deferral code, as Managed servers reports it. */
|
||||
code: string
|
||||
reason: string
|
||||
/** False when forcing cannot make the update safe (e.g. it would strand terminals). */
|
||||
forceable: boolean
|
||||
}
|
||||
|
||||
/** What this build can do for WSL hosts; the UI reads it once at startup. */
|
||||
|
||||
Reference in New Issue
Block a user