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:
Neil
2026-10-13 20:38:33 -07:00
committed by GitHub
parent 5f134d8f01
commit 03eee256f1
24 changed files with 1066 additions and 120 deletions
+4 -2
View File
@@ -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()
+22 -5
View File
@@ -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(),
+36
View File
@@ -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,
+5
View File
@@ -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
+5 -2
View File
@@ -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
}
}
+1 -1
View File
@@ -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))
}
+40
View File
@@ -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)
+13 -9
View File
@@ -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)
}
+50 -1
View File
@@ -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({
+21 -17
View File
@@ -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 })
}
+181 -1
View File
@@ -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)
+70 -33
View File
@@ -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 } }
: {})
}
}
}
+183
View File
@@ -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()
})
})
+135
View File
@@ -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(() => {})
}
}
+35
View File
@@ -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')
+37 -40
View File
@@ -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.' }
}
+129 -6
View File
@@ -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')(
+2 -1
View File
@@ -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"
+1
View File
@@ -12805,6 +12805,7 @@
"add": "Add WSL distro",
"connect": "Connect",
"remove": "Remove",
"forceUpdate": "Force update",
"turnOn": "Turn on in Experimental"
}
},
+11
View File
@@ -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. */