feat(wsl): deploy and launch orcad inside a WSL distro (WSL-via-orcad C4) (#27819)

* feat(wsl): deploy and launch orcad inside a WSL distro (WSL-via-orcad C4)

* fix(wsl): keep the install-dir fallback on the PATH detection searched, so a /mnt/c shim cannot re-enter

* fix(wsl): call the install-dir resolver exactly as before when no PATH is given

* fix(wsl): import orcad artifacts from their new orcad/ home

* fix(wsl): adopt an orcad serving in the distro that the activation record does not name

* fix(wsl): filter /mnt/<drive> PATH entries only in an orcad launched as a WSL host

SSH-managed orcad inside a distro and Orca under WSLg kept detecting
drive-mounted CLIs before this stack; the setsid launch now marks itself
with ORCA_WSL_ORCAD_HOST=1 and only that process filters.

* fix(wsl): keep the install-dir fallback on the process PATH outside a WSL host orcad

On Windows the preflight env merges the persisted registry Path, which
the fallback never searched before; only the marked WSL host runtime
now passes a filtered PATH.
This commit is contained in:
Neil
2026-10-13 01:29:19 -07:00
committed by GitHub
parent bc519c57fa
commit 8cd5bb2a74
28 changed files with 1619 additions and 51 deletions
+1
View File
@@ -21,6 +21,7 @@
"../src/main/agent-hooks/installer-utils.ts",
"../src/main/agent-hooks/installer-utils-remote.ts",
"../src/main/agent-hooks/local-agent-cli-presence.ts",
"../src/main/ipc/wsl-guest-windows-drive-path.ts",
"../src/main/agent-hooks/managed-toml-ownership.ts",
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
@@ -50,6 +50,23 @@ describe('detectLocalManagedAgentCliPresence', () => {
])
})
it('skips Windows drive dirs on a WSL guest, so a /mnt/c npm shim is not the Linux CLI', async () => {
const probe = vi.fn(async (filePath: string) => filePath.startsWith('/mnt/c/'))
const result = await detectLocalManagedAgentCliPresence(
[codexTarget],
{ agentCmdOverrides: {} },
{
pathEnv: '/home/u/.local/bin:/mnt/c/Users/u/AppData/Roaming/npm',
pathDelimiter: ':',
fileProbe: { isExecutableFile: probe },
platform: 'linux',
guestWindowsDriveMounts: ['/mnt/c']
}
)
expect(result.codex).toEqual({ state: 'missing' })
expect(probe.mock.calls.map(([filePath]) => filePath)).toEqual(['/home/u/.local/bin/codex'])
})
it('uses executable override paths as positive evidence', async () => {
const probe = vi.fn(async (filePath: string) => filePath === '/custom/bin/codex')
const result = await detectLocalManagedAgentCliPresence(
@@ -11,6 +11,10 @@ import {
import type { ManagedAgentHookTarget } from '../../shared/managed-agent-hook-targets'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
import {
isUnderWindowsDriveMount,
readGuestWindowsDriveMounts
} from '../ipc/wsl-guest-windows-drive-path'
export type LocalCliPresenceState = 'found' | 'missing' | 'unknown'
export type LocalCliPresenceByAgent = Partial<
@@ -37,6 +41,8 @@ type DetectOptions = {
hydratePath?: () => Promise<HydrationResult>
shouldHydrateShellPath?: boolean
homeDir?: string
/** Windows drive mounts to skip in a WSL guest; defaults to this process's own. */
guestWindowsDriveMounts?: readonly string[]
}
type CommandOverrideSettings = Partial<Pick<GlobalSettings, 'agentCmdOverrides'>> | null | undefined
@@ -163,7 +169,10 @@ export async function detectLocalManagedAgentCliPresence(
await maybeHydrateShellPath(options)
const platform = options.platform ?? process.platform
const delimiter = options.pathDelimiter ?? pathApiForPlatform(platform).delimiter
const dirs = pathEntries(options.pathEnv ?? process.env.PATH ?? '', delimiter)
const guestMounts = options.guestWindowsDriveMounts ?? readGuestWindowsDriveMounts()
const dirs = pathEntries(options.pathEnv ?? process.env.PATH ?? '', delimiter).filter(
(dir) => !isUnderWindowsDriveMount(dir, guestMounts)
)
const homeDir = options.homeDir ?? homedir()
const fileProbe = options.fileProbe ?? {
isExecutableFile: (filePath: string) => isExecutableFile(filePath, platform)
+14 -2
View File
@@ -1,4 +1,8 @@
import { mergePersistedWindowsPath } from '../pty/windows-environment-path'
import {
readGuestWindowsDriveMounts,
withoutWindowsDriveMountDirs
} from './wsl-guest-windows-drive-path'
function stringOnlyProcessEnv(env: NodeJS.ProcessEnv): Record<string, string> {
const result: Record<string, string> = {}
@@ -10,9 +14,17 @@ function stringOnlyProcessEnv(env: NodeJS.ProcessEnv): Record<string, string> {
return result
}
export function buildLocalPreflightEnv(): Record<string, string> | undefined {
export function buildLocalPreflightEnv(
guestWindowsDriveMounts: readonly string[] = readGuestWindowsDriveMounts()
): Record<string, string> | undefined {
if (process.platform !== 'win32') {
return undefined
if (guestWindowsDriveMounts.length === 0) {
return undefined
}
// In a WSL guest, detection must find the Linux CLI, not a Windows shim on /mnt/<drive>.
const env = stringOnlyProcessEnv(process.env)
env.PATH = withoutWindowsDriveMountDirs(env.PATH ?? '', guestWindowsDriveMounts)
return env
}
const env = stringOnlyProcessEnv(process.env)
// Why: newly installed CLIs update persisted Windows Path, but the running
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import {
isWslGuestProcess,
isWslHostOrcadProcess,
ORCAD_WSL_HOST_ENV,
parseWindowsDriveMountPoints,
withoutWindowsDriveMountDirs
} from './wsl-guest-windows-drive-path'
import { buildLocalPreflightEnv } from './preflight-local-env'
import { getPreflightWslTarget } from './preflight-runtime-target'
// Captured from Ubuntu 24.04 on WSL 2.7.10 (trimmed).
const WSL2_PROC_MOUNTS = [
'none /usr/lib/wsl/lib overlay rw,relatime 0 0',
'/dev/sdc / ext4 rw,relatime,discard 0 0',
'drivers /usr/lib/wsl/drivers 9p ro,nosuid,nodev,noatime,aname=drivers;fmask=222 0 0',
'C:\\134 /mnt/c 9p rw,noatime,dirsync,aname=drvfs;path=C:\\;uid=1000 0 0',
'D:\\134 /data/d\\040drive 9p rw,noatime,aname=drvfs;path=D:\\ 0 0',
'tmpfs /run tmpfs rw,nosuid,nodev,mode=755 0 0'
].join('\n')
describe('Windows drive dirs on a WSL guest PATH', () => {
it('reads drive mounts by type, wherever the automount root is', () => {
expect(parseWindowsDriveMountPoints(WSL2_PROC_MOUNTS)).toEqual([
'/usr/lib/wsl/drivers',
'/mnt/c',
'/data/d drive'
])
})
it('never treats a root mount as a drive, which would empty PATH', () => {
expect(parseWindowsDriveMountPoints('rootfs / virtiofs rw 0 0')).toEqual([])
})
it('drops /mnt/<drive> entries so a Windows shim cannot answer for a Linux CLI', () => {
const path = [
'/home/u/.local/bin',
'/usr/bin',
'/mnt/c/Users/u/AppData/Roaming/npm',
'/mnt/c/WINDOWS/system32',
'/mnt/cache/bin',
'/usr/lib/wsl/lib'
].join(':')
expect(withoutWindowsDriveMountDirs(path, ['/mnt/c'])).toBe(
'/home/u/.local/bin:/usr/bin:/mnt/cache/bin:/usr/lib/wsl/lib'
)
})
it('leaves PATH untouched outside a WSL guest', () => {
expect(withoutWindowsDriveMountDirs('/mnt/c/x:/usr/bin', [])).toBe('/mnt/c/x:/usr/bin')
expect(isWslGuestProcess({}, 'linux', () => false)).toBe(false)
expect(isWslGuestProcess({ WSL_DISTRO_NAME: 'Ubuntu' }, 'darwin', () => true)).toBe(false)
})
it('recognises the guest under `wsl.exe --exec` (WSL_DISTRO_NAME) or by its interop binfmt', () => {
expect(isWslGuestProcess({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux', () => false)).toBe(true)
expect(isWslGuestProcess({}, 'linux', () => true)).toBe(true)
})
// Regression: SSH-managed orcad in a distro and Orca under WSLg must keep /mnt/<drive> CLIs.
it('filters only for an orcad Orca launched as a WSL host', () => {
const guest = { WSL_DISTRO_NAME: 'Ubuntu' }
expect(isWslHostOrcadProcess(guest, 'linux', () => true)).toBe(false)
expect(isWslHostOrcadProcess({ ...guest, [ORCAD_WSL_HOST_ENV]: '1' }, 'linux')).toBe(true)
expect(isWslHostOrcadProcess({ [ORCAD_WSL_HOST_ENV]: '1' }, 'linux', () => false)).toBe(false)
})
it.skipIf(process.platform === 'win32')(
'gives local preflight a PATH without the drive dirs on a guest only',
() => {
const original = process.env.PATH
process.env.PATH = '/home/u/.local/bin:/mnt/c/Users/u/AppData/Roaming/npm:/usr/bin'
try {
expect(buildLocalPreflightEnv([])).toBeUndefined()
expect(buildLocalPreflightEnv(['/mnt/c'])?.PATH).toBe('/home/u/.local/bin:/usr/bin')
} finally {
process.env.PATH = original
}
}
)
it('never treats orcad inside the guest as a WSL preflight context', () => {
expect(getPreflightWslTarget({ wslDistro: 'Ubuntu' }, 'linux')).toBeNull()
expect(getPreflightWslTarget({ wslDefault: true }, 'linux')).toBeNull()
})
})
@@ -0,0 +1,90 @@
/**
* Inside a WSL distro (orcad on a WSL host), WSL appends the Windows PATH to the guest's, so a bare
* PATH walk can pick a Windows shim (`/mnt/c/.../codex`) over the Linux CLI or report a CLI the
* guest cannot really run. Same mount rule as `buildPosixCommandPathLookupScript`'s
* `skipWindowsMountDirs`: matched by mount type, since the automount root is configurable.
*/
import { existsSync, readFileSync } from 'node:fs'
const WINDOWS_DRIVE_MOUNT_TYPES = new Set(['drvfs', '9p', 'virtiofs'])
/** Set only by the WSL-host orcad launch, so SSH remotes and Orca under WSLg keep their PATH. */
export const ORCAD_WSL_HOST_ENV = 'ORCA_WSL_ORCAD_HOST'
let cachedGuestMounts: readonly string[] | null = null
export function isWslGuestProcess(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
interopRegistered: () => boolean = () => existsSync('/proc/sys/fs/binfmt_misc/WSLInterop')
): boolean {
if (platform !== 'linux') {
return false
}
return Boolean(env.WSL_DISTRO_NAME?.trim() || env.WSL_INTEROP?.trim()) || interopRegistered()
}
/** A WSL guest process that Orca launched as a WSL host's orcad. */
export function isWslHostOrcadProcess(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
interopRegistered?: () => boolean
): boolean {
return env[ORCAD_WSL_HOST_ENV] === '1' && isWslGuestProcess(env, platform, interopRegistered)
}
/** `/proc/mounts` escapes whitespace and backslashes in a mount point as octal (`\040`). */
function decodeMountField(field: string): string {
return field.replace(/\\([0-7]{3})/g, (_match, octal: string) =>
String.fromCharCode(Number.parseInt(octal, 8))
)
}
export function parseWindowsDriveMountPoints(procMounts: string): string[] {
const mounts: string[] = []
for (const line of procMounts.split('\n')) {
const [, mountPoint, type] = line.trim().split(/\s+/)
const decoded = mountPoint ? decodeMountField(mountPoint).replace(/\/+$/, '') : ''
// A root-level match would drop every PATH entry; no Windows drive mounts at `/`.
if (decoded && type && WINDOWS_DRIVE_MOUNT_TYPES.has(type)) {
mounts.push(decoded)
}
}
return mounts
}
export function isUnderWindowsDriveMount(dir: string, mounts: readonly string[]): boolean {
return mounts.some((mount) => dir === mount || dir.startsWith(`${mount}/`))
}
export function withoutWindowsDriveMountDirs(pathValue: string, mounts: readonly string[]): string {
if (mounts.length === 0) {
return pathValue
}
return pathValue
.split(':')
.filter((dir) => !isUnderWindowsDriveMount(dir.replace(/\/+$/, ''), mounts))
.join(':')
}
/** This process's Windows drive mounts when it is a WSL host's orcad; empty everywhere else. */
export function readGuestWindowsDriveMounts(): readonly string[] {
if (cachedGuestMounts) {
return cachedGuestMounts
}
let mounts: string[] = []
if (isWslHostOrcadProcess()) {
try {
mounts = parseWindowsDriveMountPoints(readFileSync('/proc/mounts', 'utf8'))
} catch {
// Unreadable: keep PATH as is rather than guess which entries are Windows drives.
}
}
cachedGuestMounts = mounts
return mounts
}
/** @internal - tests need a fresh mount read. */
export function _resetGuestWindowsDriveMountsForTests(): void {
cachedGuestMounts = null
}
@@ -0,0 +1,36 @@
/**
* orcad on a WSL host is launched by `wsl.exe --exec`, whose PATH misses Linux CLIs installed by
* the login shell's rc files (claude, opencode, gemini) and carries Windows shims. Its agent
* detection must hydrate PATH from `$SHELL -ilc` like any packaged build.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
const hydration = vi.hoisted(() => ({
hydrateShellPath: vi.fn(async () => ({ ok: true as const, segments: ['/home/u/.local/bin'] })),
mergePathSegments: vi.fn(() => [])
}))
vi.mock('../startup/hydrate-shell-path', () => hydration)
import { getAppEnvironment } from '../../shared/app-environment'
import { hydrateShellPathForAgentDetection } from '../ipc/agent-detection-shell-path'
import { installOrcadHostAdapters } from './orcad-entry'
describe('orcad PATH hydration on a WSL guest', () => {
const platform = process.platform
afterEach(() => {
Object.defineProperty(process, 'platform', { value: platform })
vi.clearAllMocks()
})
it('reports itself packaged, which is what gates the login-shell probe', () => {
installOrcadHostAdapters()
expect(getAppEnvironment().isPackaged()).toBe(true)
})
it('hydrates for detection even when a client names a WSL distro: inside it, Linux is local', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' })
await hydrateShellPathForAgentDetection({ wslDistro: 'Ubuntu' })
expect(hydration.hydrateShellPath).toHaveBeenCalledTimes(1)
expect(hydration.mergePathSegments).toHaveBeenCalledWith(['/home/u/.local/bin'])
})
})
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as DrivePath from '../ipc/wsl-guest-windows-drive-path'
const { detectInstallDirs, guestMounts } = vi.hoisted(() => {
const value: readonly string[] = []
return {
detectInstallDirs: vi.fn(
(_commands: readonly string[], _pathEnv?: string) => new Set<string>()
),
guestMounts: { value }
}
})
vi.mock('../ipc/local-agent-install-dir-detection', () => ({
detectCommandsInInstallDirs: detectInstallDirs
}))
vi.mock('../ipc/preflight-command-exec', () => ({
isCommandOnPath: vi.fn(async () => false)
}))
vi.mock('../ipc/wsl-guest-windows-drive-path', async (importOriginal) => ({
...(await importOriginal<typeof DrivePath>()),
readGuestWindowsDriveMounts: () => guestMounts.value
}))
import { detectAgentCommandsOnHost } from './agent-command-detection'
describe('detectAgentCommandsOnHost install-dir fallback', () => {
beforeEach(() => {
detectInstallDirs.mockClear()
guestMounts.value = []
})
// Regression: Windows must keep searching the process PATH, not the persisted-registry merge.
it('searches the process PATH everywhere except a WSL host orcad', async () => {
await detectAgentCommandsOnHost(['codex'])
expect(detectInstallDirs).toHaveBeenCalledWith(['codex'], undefined)
})
it.skipIf(process.platform === 'win32')(
'drops /mnt/<drive> dirs from the fallback PATH on a WSL host orcad',
async () => {
guestMounts.value = ['/mnt/c']
const original = process.env.PATH
process.env.PATH = '/usr/bin:/mnt/c/Users/u/AppData/Roaming/npm'
try {
await detectAgentCommandsOnHost(['codex'])
} finally {
process.env.PATH = original
}
expect(detectInstallDirs).toHaveBeenCalledWith(['codex'], '/usr/bin')
}
)
})
@@ -6,6 +6,8 @@ import {
type PreflightRuntimeContext
} from '../ipc/preflight-runtime-target'
import { isCommandOnPath } from '../ipc/preflight-command-exec'
import { buildLocalPreflightEnv } from '../ipc/preflight-local-env'
import { readGuestWindowsDriveMounts } from '../ipc/wsl-guest-windows-drive-path'
export async function detectAgentCommandsOnHost(
commands: readonly string[],
@@ -28,7 +30,13 @@ export async function detectAgentCommandsOnHost(
const missedCommands = pathChecks.filter((check) => !check.installedOnPath).map(({ cmd }) => cmd)
// Why: PATH may still be unhydrated on a cold GUI launch; bulk resolution
// computes user install dirs once instead of blocking once per missed CLI.
const installDirCommands = detectCommandsInInstallDirs(missedCommands)
// Same PATH as the walk above: a WSL host's orcad must not re-admit /mnt/<drive> shims.
// Elsewhere (Windows included) keep the process PATH the fallback always searched.
const guestMounts = readGuestWindowsDriveMounts()
const installDirCommands = detectCommandsInInstallDirs(
missedCommands,
guestMounts.length > 0 ? buildLocalPreflightEnv(guestMounts)?.PATH : undefined
)
const foundCommands = new Set(
pathChecks
.filter(({ cmd, installedOnPath }) => installedOnPath || installDirCommands.has(cmd))
+3
View File
@@ -1,6 +1,7 @@
import type { SshTarget } from '../../shared/ssh-types'
import type { WslOrcadHostTarget } from '../wsl/wsl-orcad-host-target'
import type { RemoteInstallTransport } from './remote-install-transport'
import type { OrcadLaunchDetach } from './orcad-remote-launch'
/** The host an orcad transport reaches; only `id` and `label` mean the same for every kind. */
export type OrcadHostTarget = SshTarget | WslOrcadHostTarget
@@ -13,4 +14,6 @@ export function isWslOrcadHostTarget(target: OrcadHostTarget): target is WslOrca
export type OrcadHostTransport = RemoteInstallTransport & {
/** Keys per-host state (remote context, wake dedupe) to the target it reaches. */
getTarget(): OrcadHostTarget
/** How a launch leaves the exec's session; absent (SSH) means `nohup`. */
readonly orcadLaunchDetach?: OrcadLaunchDetach
}
+42 -3
View File
@@ -7,6 +7,10 @@ import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ss
import type { ServeReadiness } from '../server/serve-readiness'
import { computeLocalOrcadBuildHash } from './orcad-local-build-hash'
import { sshOrcadHostTransport } from './orcad-ssh-host-transport'
import type { OrcadHostTarget } from './orcad-host-transport'
import type { OrcadActivationRecord } from './orcad-activation-record'
import { refuseOrcadHostDowngrade } from './orcad-host-version-admission'
import type { OrcadTerminalCensus } from './orcad-update-plan'
import { probeActiveOrcadReadiness } from './orcad-active-readiness'
import { resolveOrcadRemoteContext, type OrcadRemoteContext } from './orcad-remote-context'
import { ORCAD_INSTALL_MODEL } from './remote-install-model'
@@ -67,7 +71,11 @@ export async function resolveLinkedOrcadContext(
}
/** The slot options every remote lifecycle step takes for this managed server. */
export function managedOrcadSlot(context: OrcadRemoteContext, port: number, signal?: AbortSignal) {
export function managedOrcadSlot(
context: OrcadRemoteContext<OrcadHostTarget>,
port: number,
signal?: AbortSignal
) {
return {
conn: context.connection,
host: context.host,
@@ -80,7 +88,10 @@ export function managedOrcadSlot(context: OrcadRemoteContext, port: number, sign
}
}
export function managedOrcadInstallDir(context: OrcadRemoteContext, version: string): string {
export function managedOrcadInstallDir(
context: OrcadRemoteContext<OrcadHostTarget>,
version: string
): string {
return computeRemoteInstallDir(
ORCAD_INSTALL_MODEL,
context.remoteHome,
@@ -91,7 +102,7 @@ export function managedOrcadInstallDir(context: OrcadRemoteContext, version: str
/** The active slot's readiness, accepted only if it is the build this client holds. */
export function probeManagedOrcadReadiness(
context: OrcadRemoteContext,
context: OrcadRemoteContext<OrcadHostTarget>,
localOrcadDir: string,
fullVersion: string,
signal?: AbortSignal
@@ -107,6 +118,34 @@ export function probeManagedOrcadReadiness(
)
}
/** The census and version admission every first deploy onto a host applies, SSH or WSL. */
export function managedOrcadDeployPolicy(
context: Pick<OrcadRemoteContext<OrcadHostTarget>, 'activationRecord'>,
appVersion: string,
force?: boolean
): {
census: OrcadTerminalCensus
censusRecord: OrcadActivationRecord
admitRecord: (record: OrcadActivationRecord, candidateVersion: string) => string | null
} {
return {
// Why unknown when a version is active: only the daemon can count its sessions, and
// a deploy that guessed zero would restart over live terminals.
census: context.activationRecord.active
? { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: null }
: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null },
censusRecord: context.activationRecord,
// Why: a host another desktop stopped may hold state its newer build migrated, and this
// path has no linked record to check first, so only the fenced read can refuse it.
// An explicit force runs this version anyway, as `environment update --force` does.
admitRecord: (record, candidateVersion) =>
!force && refuseOrcadHostDowngrade(record, candidateVersion, appVersion)
? `Orca ${record.activeAppVersion} last ran this host's server, newer than this ` +
`Orca (${appVersion}). Update Orca to start it.`
: null
}
}
// Why only this code: deploy never has a session count to force past, so forcing an unknown
// census lands on the daemon-protocol deferral, which force cannot clear.
export function isForceableOrcadDeferral(code: string): boolean {
+15 -8
View File
@@ -1,4 +1,4 @@
/** Everything a managed-orcad operation needs to know about one SSH host before it acts. */
/** Everything a managed-orcad operation needs to know about one host (SSH or WSL) before it acts. */
import type { NodeRuntimeTarget } from '../../shared/node-runtime-pin'
import type { SshTarget } from '../../shared/ssh-types'
import type { OrcadActivationRecord } from './orcad-activation-record'
@@ -6,7 +6,11 @@ import { readOrcadActivationRecord } from './orcad-activation-record-store'
import { resolveOrcadRuntimeTarget } from './orcad-runtime-target'
import { prepareWindowsOrcadHost } from './orcad-windows-host-preparation'
import { execOrcadRemote } from './orcad-remote-runtime-control'
import type { OrcadHostTransport } from './orcad-host-transport'
import {
isWslOrcadHostTarget,
type OrcadHostTarget,
type OrcadHostTransport
} from './orcad-host-transport'
import { readRemoteHomeCommand } from './ssh-remote-commands'
import {
isWindowsRemoteHost,
@@ -19,23 +23,24 @@ import { detectRemoteHostPlatform } from './ssh-remote-platform-detection'
import { OrcadHostUnsupportedError } from '../orcad/orcad-host-unavailable'
import { rememberSshHostPlatform } from './ssh-host-platform-memo'
export type OrcadRemoteContext = {
/** Defaults to SSH: most lifecycle code still reads SSH-only target fields. */
export type OrcadRemoteContext<Target extends OrcadHostTarget = SshTarget> = {
activationRecord: OrcadActivationRecord
/** A compat runtime on hosts below the default runtime's glibc floor (design D6 rung B). */
serverTarget: NodeRuntimeTarget
connection: OrcadHostTransport
host: RemoteHostPlatform
remoteHome: string
target: SshTarget
target: Target
userDataDir: string
}
export async function resolveOrcadRemoteContext(
target: SshTarget,
export async function resolveOrcadRemoteContext<Target extends OrcadHostTarget = SshTarget>(
target: Target,
connection: OrcadHostTransport,
signal?: AbortSignal,
detectedHost?: RemoteHostPlatform
): Promise<OrcadRemoteContext> {
): Promise<OrcadRemoteContext<Target>> {
const host = detectedHost ?? (await detectRemoteHostPlatform(connection, { signal }))
if (!host) {
throw new OrcadHostUnsupportedError('This SSH host platform is not supported by managed orcad.')
@@ -49,7 +54,9 @@ export async function resolveOrcadRemoteContext(
throw new Error(`Remote home is not a valid path: ${remoteHome.slice(0, 100)}`)
}
const serverTarget = await resolveOrcadRuntimeTarget({ conn: connection, host, signal })
rememberSshHostPlatform(target.id, host, serverTarget)
if (!isWslOrcadHostTarget(target)) {
rememberSshHostPlatform(target.id, host, serverTarget)
}
if (isWindowsRemoteHost(host)) {
// Every Windows host op, the activation record read included, runs on the pinned node.exe.
await prepareWindowsOrcadHost({ conn: connection, host, remoteHome, serverTarget, signal })
@@ -0,0 +1,103 @@
import { execFileSync } from 'node:child_process'
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
ORCAD_PID_FILENAME,
ORCAD_READINESS_FILENAME,
orcadLaunchCommand,
type OrcadLaunchSpec
} from './orcad-remote-launch'
import { getRemoteHostPlatform } from './ssh-remote-platform'
import { ORCAD_WSL_HOST_ENV } from '../ipc/wsl-guest-windows-drive-path'
const host = getRemoteHostPlatform('linux-x64')
function spec(remoteInstallDir: string, nodePath = '/usr/bin/node'): OrcadLaunchSpec {
return {
remoteInstallDir,
nodePath,
fullVersion: '0.2.0+bb01',
userDataDir: '/home/u/.orca',
bindHost: '127.0.0.1',
port: 7777,
activationRoot: '/home/u/.orca-remote/.orcad-activation-transaction'
}
}
describe('the WSL launch (setsid)', () => {
const dirs: string[] = []
afterEach(() => {
for (const dir of dirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
// Regression: `nohup … &` is killed when its `wsl.exe --exec` exits on a distro without systemd.
it('never backgrounds under nohup, and starts the runtime in a session of its own', () => {
const command = orcadLaunchCommand(
host,
spec('/home/u/.orca-remote/orcad-0.2.0+bb01'),
'setsid'
)
expect(command).not.toContain('nohup')
expect(command).toContain('setsid -f "$@"')
// busybox has no `-f`; a background child of a non-interactive shell can still setsid.
expect(command).toContain('else setsid "$@" & fi')
expect(command).toContain(`${ORCAD_WSL_HOST_ENV}=1 sh -c`)
})
it('has the child record its own PID, since the setsid -f parent exits at once', () => {
const command = orcadLaunchCommand(host, spec('/d/orcad-1'), 'setsid')
const removeStale = command.indexOf(`rm -f '/d/orcad-1/${ORCAD_PID_FILENAME}'`)
const recordOwn = command.indexOf(`echo $$ > "$1" && shift && exec "$@"`)
expect(removeStale).toBeGreaterThan(-1)
expect(recordOwn).toBeGreaterThan(removeStale)
expect(command.trimEnd().endsWith(`cat '/d/orcad-1/${ORCAD_PID_FILENAME}'`)).toBe(true)
})
it('keeps the SSH launch byte-for-byte when no detach is named', () => {
const ssh = orcadLaunchCommand(host, spec('/d/orcad-1'))
expect(ssh).toBe(orcadLaunchCommand(host, spec('/d/orcad-1'), 'nohup'))
expect(ssh).toContain('exec nohup "$orcad_runtime"')
expect(ssh).not.toContain('setsid')
expect(ssh).not.toContain(ORCAD_WSL_HOST_ENV)
})
it.skipIf(process.platform !== 'linux')(
'really detaches: the recorded PID is the runtime, in a new session, and writes readiness',
() => {
const dir = mkdtempSync(join(tmpdir(), 'orcad-setsid-'))
dirs.push(dir)
const runtime = join(dir, 'fake-node')
writeFileSync(runtime, `#!/bin/sh\necho '{"type":"orca_server_ready"}'\nsleep 30\n`)
chmodSync(runtime, 0o755)
const out = execFileSync('sh', ['-c', orcadLaunchCommand(host, spec(dir, runtime), 'setsid')])
const pid = Number(out.toString().trim())
try {
expect(Number.isSafeInteger(pid) && pid > 0).toBe(true)
expect(readFileSync(join(dir, ORCAD_PID_FILENAME), 'utf8').trim()).toBe(String(pid))
const ps = (field: string, target: number): string =>
execFileSync('ps', ['-o', `${field}=`, '-p', String(target)])
.toString()
.trim()
expect(ps('args', pid)).toContain(join(dir, 'orcad.js'))
expect(ps('sid', pid)).toBe(String(pid))
expect(ps('sid', pid)).not.toBe(ps('sid', process.pid))
for (let i = 0; i < 50 && !readFileSync(join(dir, ORCAD_READINESS_FILENAME), 'utf8'); i++) {
execFileSync('sleep', ['0.05'])
}
expect(readFileSync(join(dir, ORCAD_READINESS_FILENAME), 'utf8')).toContain(
'orca_server_ready'
)
} finally {
try {
process.kill(pid)
} catch {
// Already gone.
}
}
}
)
})
+52 -7
View File
@@ -22,6 +22,7 @@ import {
type RemoteHostPlatform
} from './ssh-remote-platform'
import { RELAY_REMOTE_DIR } from './relay-protocol'
import { ORCAD_WSL_HOST_ENV } from '../ipc/wsl-guest-windows-drive-path'
import {
assertPosixOrcadHost as assertPosixHost,
ORCAD_LOG_FILENAME,
@@ -75,6 +76,14 @@ export function orcadManagedLaunchEnv(
]
}
/**
* How the candidate leaves the exec's session. `nohup … &` dies with its `wsl.exe --exec` session
* on a distro without systemd, so a WSL slot starts in a session of its own.
*/
export type OrcadLaunchDetach = 'nohup' | 'setsid'
const ORCAD_SETSID_PID_WAIT_TRIES = 100
/**
* Launch the candidate detached and echo its PID.
*
@@ -82,7 +91,11 @@ export function orcadManagedLaunchEnv(
* thing a future version can change. The deploy states the posture it intends rather than
* inheriting whatever the installed build happens to default to.
*/
export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSpec): string {
export function orcadLaunchCommand(
host: RemoteHostPlatform,
spec: OrcadLaunchSpec,
detach: OrcadLaunchDetach = 'nohup'
): string {
assertPosixHost(host)
const dir = shellEscape(spec.remoteInstallDir)
const readiness = shellEscape(
@@ -113,18 +126,50 @@ export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSp
`{ chmod 600 ${pidFile} ${log} 2>/dev/null || :; } &&`,
// A stop request the previous process never consumed must not stop this one.
`rm -f ${shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_STOP_REQUEST_FILENAME))} &&`,
...(detach === 'setsid' ? [`rm -f ${pidFile} &&`] : []),
`ORCA_VERSION=${shellEscape(spec.fullVersion)}`,
`ORCA_USER_DATA=${shellEscape(spec.userDataDir)}`,
...orcadManagedLaunchEnv(spec).map(([name, value]) => `${name}=${shellEscape(value)}`),
// Keep $! equal to the runtime PID rather than a waiting shell's PID.
// Older clients identify the live PID by orcad.js; the pinned launcher loads the body in-place.
`exec nohup "$orcad_runtime" ${entry}`,
`--json --bind ${shellEscape(spec.bindHost)} --port ${String(spec.port)}`,
`> ${readiness} 2>> ${log} < /dev/null &`,
`echo $! > ${pidFile} && cat ${pidFile}`
...(detach === 'setsid'
? setsidLaunchTail(entry, spec, readiness, log, pidFile)
: [
// Keep $! equal to the runtime PID rather than a waiting shell's PID.
// Older clients identify the live PID by orcad.js; the pinned launcher loads the body in-place.
`exec nohup "$orcad_runtime" ${entry}`,
`--json --bind ${shellEscape(spec.bindHost)} --port ${String(spec.port)}`,
`> ${readiness} 2>> ${log} < /dev/null &`,
`echo $! > ${pidFile} && cat ${pidFile}`
])
].join(' ')
}
/**
* The forked child records its own PID (`$$` survives its exec into the runtime) because the
* `setsid -f` parent exits at once. busybox `setsid` has no `-f`; backgrounding it is equivalent
* there because a background child of a non-interactive shell never leads a process group.
*/
function setsidLaunchTail(
entry: string,
spec: OrcadLaunchSpec,
readiness: string,
log: string,
pidFile: string
): string[] {
return [
// Only a setsid (WSL host) launch marks itself; SSH keeps its byte-for-byte command.
`${ORCAD_WSL_HOST_ENV}=1`,
'sh -c \'orcad_detach() { if setsid -f true >/dev/null 2>&1; then setsid -f "$@"; ' +
'else setsid "$@" & fi; }; orcad_detach "$@"\' orcad-detach',
`sh -c 'echo $$ > "$1" && shift && exec "$@"' orcad-launch ${pidFile}`,
`"$orcad_runtime" ${entry}`,
`--json --bind ${shellEscape(spec.bindHost)} --port ${String(spec.port)}`,
`> ${readiness} 2>> ${log} < /dev/null &&`,
`orcad_wait=0; while [ ! -s ${pidFile} ] && [ "$orcad_wait" -lt ${ORCAD_SETSID_PID_WAIT_TRIES} ];`,
'do sleep 0.05; orcad_wait=$((orcad_wait + 1)); done;',
`cat ${pidFile}`
]
}
export function readOrcadReadinessCommand(
host: RemoteHostPlatform,
remoteInstallDir: string
+4 -1
View File
@@ -117,7 +117,10 @@ export async function launchOrcadAndAwaitReadiness(
)
)
} else {
await execOrcadRemote(target, orcadLaunchCommand(target.host, spec))
await execOrcadRemote(
target,
orcadLaunchCommand(target.host, spec, target.conn.orcadLaunchDetach ?? 'nohup')
)
}
const deadline = Date.now() + (target.readinessTimeoutMs ?? ORCAD_STARTUP_READINESS_TIMEOUT_MS)
const sleep = target.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)))
+3 -16
View File
@@ -26,7 +26,6 @@ import { readOrcadActivationRecord } from './orcad-activation-record-store'
import { resolveOrcadRemoteContext } from './orcad-remote-context'
import { deployOrcad } from './orcad-remote-deploy'
import { sshOrcadHostTransport } from './orcad-ssh-host-transport'
import { refuseOrcadHostDowngrade } from './orcad-host-version-admission'
import { ORCAD_ACTIVATION_POLICY_REFUSED_CODE } from './orcad-installed-activation'
import { pruneManagedOrcadVersions } from './orcad-managed-version-gc'
import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing'
@@ -35,6 +34,7 @@ import type { SshTarget } from '../../shared/ssh-types'
import { deployedOrcadTunnelChecks } from './orcad-managed-tunnel-identity'
import {
isForceableOrcadDeferral,
managedOrcadDeployPolicy,
managedOrcadSlot,
probeManagedOrcadReadiness,
requireManagedOrcadInfrastructure
@@ -123,22 +123,9 @@ export async function createManagedOrcadEnvironment(
conn: connection,
localOrcadDir,
target: context.serverTarget,
// Why unknown when a version is active: only the daemon can count its sessions, and
// a deploy that guessed zero would restart over live terminals.
census: context.activationRecord.active
? { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: null }
: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null },
censusRecord: context.activationRecord,
...managedOrcadDeployPolicy(context, appVersion, args.force),
force: args.force,
appVersion,
// Why: a host another desktop stopped may hold state its newer build migrated, and this
// path has no linked record to check first, so only the fenced read can refuse it.
// An explicit force runs this version anyway, as `environment update --force` does.
admitRecord: (record, candidateVersion) =>
!args.force && refuseOrcadHostDowngrade(record, candidateVersion, appVersion)
? `Orca ${record.activeAppVersion} last ran this host's server, newer than this ` +
`Orca (${appVersion}). Update Orca to start it.`
: null
appVersion
})
if (deployResult.outcome === 'installed-not-activated') {
return {
+5 -5
View File
@@ -152,7 +152,7 @@ describe('createWslDistroHolder', () => {
host.processes.set(77, { commandLine: HOLDER_COMMAND('Alpine'), startedAtMs: 5_000 })
await writeRecord(record())
const holder = makeHolder()
expect(await holder.adoptHolders()).toEqual([{ distro: 'alpine', state: 'live' }])
expect(await holder.adoptHolders()).toEqual([{ distro: 'Alpine', state: 'live' }])
expect((await holder.ensureHeld('Alpine')).outcome).toBe('adopted')
expect(host.spawned).toEqual([])
})
@@ -161,7 +161,7 @@ describe('createWslDistroHolder', () => {
host.processes.set(77, { commandLine: HOLDER_COMMAND('Alpine'), startedAtMs: 9_999 })
await writeRecord(record())
const holder = makeHolder()
expect(await holder.adoptHolders()).toEqual([{ distro: 'alpine', state: 'exited' }])
expect(await holder.adoptHolders()).toEqual([{ distro: 'Alpine', state: 'exited' }])
expect(await recordFiles()).toEqual([])
expect(host.killed).toEqual([])
})
@@ -169,7 +169,7 @@ describe('createWslDistroHolder', () => {
it('rejects a pid whose command line is not our holder for that distro', async () => {
host.processes.set(77, { commandLine: HOLDER_COMMAND('Ubuntu'), startedAtMs: 5_000 })
await writeRecord(record())
expect(await makeHolder().adoptHolders()).toEqual([{ distro: 'alpine', state: 'exited' }])
expect(await makeHolder().adoptHolders()).toEqual([{ distro: 'Alpine', state: 'exited' }])
expect(await recordFiles()).toEqual([])
})
@@ -181,7 +181,7 @@ describe('createWslDistroHolder', () => {
const adopted = await holder.adoptHolders()
expect(adopted.sort((a, b) => a.distro.localeCompare(b.distro))).toEqual([
{ distro: 'broken', state: 'exited' },
{ distro: 'gone', state: 'exited' }
{ distro: 'Gone', state: 'exited' }
])
expect(await recordFiles()).toEqual([])
})
@@ -191,7 +191,7 @@ describe('createWslDistroHolder', () => {
await writeRecord(record())
host.tableFails = true
const holder = makeHolder()
expect(await holder.adoptHolders()).toEqual([{ distro: 'alpine', state: 'unverifiable' }])
expect(await holder.adoptHolders()).toEqual([{ distro: 'Alpine', state: 'unverifiable' }])
expect(await holder.holderState('Alpine')).toBe('unverifiable')
expect((await holder.ensureHeld('Alpine')).outcome).toBe('unverifiable')
expect(await holder.release('Alpine')).toBe('unverifiable')
+5 -4
View File
@@ -272,10 +272,11 @@ export function createWslDistroHolder(dependencies: WslDistroHolderDependencies)
const distros = await listWslDistroHolderRecordDistros(userDataPath)
return Promise.all(
distros.map((distro) =>
serialize(distro, async () => ({
distro,
state: (await recordedHolderState(distro)).state
}))
serialize(distro, async () => {
const { state, record } = await recordedHolderState(distro)
// The file name is the lowercase key; the record keeps the distro's own casing.
return { distro: record?.distro ?? distro, state }
})
)
)
},
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from 'vitest'
import { parseWslDistroRegistrations, readWslDistroRegistryId } from './wsl-distro-registry-id'
const LXSS = 'HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss'
const REG_OUTPUT = [
'',
`${LXSS}\\{0283592D-BE56-40D4-B935-3DC18C3AA007}`,
' DistributionName REG_SZ Ubuntu-24.04',
'',
`${LXSS}\\{1b2c3d4e-0000-4000-8000-000000000001}`,
' DistributionName REG_SZ docker-desktop',
'',
// Localized footer: matched by shape, never by text.
'Fin de la recherche : 2 correspondance(s) trouvée(s).',
''
].join('\r\n')
describe('the distro registration GUID', () => {
it('pairs each Lxss subkey with its DistributionName, lowercased', () => {
expect(parseWslDistroRegistrations(REG_OUTPUT)).toEqual([
{ id: '{0283592d-be56-40d4-b935-3dc18c3aa007}', distro: 'Ubuntu-24.04' },
{ id: '{1b2c3d4e-0000-4000-8000-000000000001}', distro: 'docker-desktop' }
])
})
it('reads it through one reg.exe query, matching the name case-insensitively', async () => {
const query = vi.fn(async () => ({ code: 0, stdout: REG_OUTPUT }))
await expect(readWslDistroRegistryId('ubuntu-24.04', query)).resolves.toBe(
'{0283592d-be56-40d4-b935-3dc18c3aa007}'
)
expect(query).toHaveBeenCalledWith([
'query',
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss',
'/s',
'/v',
'DistributionName'
])
})
it('answers null for an unknown distro, a failed query or a thrown spawn', async () => {
await expect(
readWslDistroRegistryId('Debian', async () => ({ code: 0, stdout: REG_OUTPUT }))
).resolves.toBeNull()
await expect(
readWslDistroRegistryId('Ubuntu-24.04', async () => ({ code: 1, stdout: '' }))
).resolves.toBeNull()
await expect(
readWslDistroRegistryId('Ubuntu-24.04', async () => {
throw new Error('ENOENT')
})
).resolves.toBeNull()
})
})
+67
View File
@@ -0,0 +1,67 @@
/**
* A distro's registration GUID (`HKCU\...\Lxss\{guid}`): stable across a rename and changed by an
* unregister + re-import, so it tells "the same distro" from "a new one with the old name".
*/
import { runProcess } from '@orca/process-host'
import { windowsSystem32Binary } from '@orca/process-host/windows-system-binary'
const LXSS_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss'
const REG_QUERY_TIMEOUT_MS = 5_000
const GUID_SUBKEY = /\\(\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\})\s*$/i
const DISTRIBUTION_NAME_VALUE = /^\s+DistributionName\s+REG_\w+\s+(.+?)\s*$/
export type WslDistroRegistration = { id: string; distro: string }
/** Parses `reg query <Lxss> /s /v DistributionName`; matched by shape, since the footer is localized. */
export function parseWslDistroRegistrations(output: string): WslDistroRegistration[] {
const registrations: WslDistroRegistration[] = []
let currentId: string | null = null
for (const line of output.split(/\r?\n/)) {
const key = GUID_SUBKEY.exec(line)
if (key?.[1] && /^HKEY_/i.test(line)) {
currentId = key[1].toLowerCase()
continue
}
const name = DISTRIBUTION_NAME_VALUE.exec(line)
if (name?.[1] && currentId) {
registrations.push({ id: currentId, distro: name[1] })
currentId = null
}
}
return registrations
}
export type RegQuery = (args: string[]) => Promise<{ code: number | null; stdout: string }>
const defaultRegQuery: RegQuery = (args) =>
// One reg.exe per lookup, as `openssh-default-shell` reads the registry; never powershell.exe.
runProcess({
program: windowsSystem32Binary('reg.exe'),
args,
timeoutMs: REG_QUERY_TIMEOUT_MS
})
/** The distro's registration GUID (lowercase, braced), or null when it is not registered or unreadable. */
export async function readWslDistroRegistryId(
distro: string,
query: RegQuery = defaultRegQuery
): Promise<string | null> {
if (process.platform !== 'win32' && query === defaultRegQuery) {
return null
}
let result: { code: number | null; stdout: string }
try {
result = await query(['query', LXSS_KEY, '/s', '/v', 'DistributionName'])
} catch {
return null
}
if (result.code !== 0) {
return null
}
// WSL treats distro names case-insensitively.
const wanted = distro.trim().toLowerCase()
const matches = parseWslDistroRegistrations(result.stdout).filter(
(registration) => registration.distro.toLowerCase() === wanted
)
return matches.length === 1 ? matches[0].id : null
}
+268
View File
@@ -0,0 +1,268 @@
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 { emptyOrcadActivationRecord } from '../ssh/orcad-activation-record'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import type { WslDistroHolder } from './wsl-distro-holder'
import type { WslOrcadHostTarget } from './wsl-orcad-host-target'
const mocks = vi.hoisted(() => ({
steps: new Array<string>(),
exec: vi.fn(),
resolveContext: vi.fn(),
recover: vi.fn(),
deploy: vi.fn(),
probe: vi.fn(),
prune: vi.fn(),
wake: vi.fn(),
adopt: vi.fn()
}))
vi.mock('./wsl-orcad-instance-adoption', () => ({ adoptUnrecordedWslOrcad: mocks.adopt }))
vi.mock('../../shared/app-environment', () => ({
getAppEnvironment: () => ({ getVersion: () => '1.5.0', getPath: () => '/user-data' })
}))
vi.mock('../ssh/ssh-relay-exec-command', () => ({ execCommand: mocks.exec }))
vi.mock('../ssh/orcad-remote-context', () => ({ resolveOrcadRemoteContext: mocks.resolveContext }))
vi.mock('../ssh/orcad-activation-recovery', () => ({
recoverInterruptedOrcadActivation: mocks.recover
}))
vi.mock('../orcad/orcad-artifact-materializer', () => ({
materializeOrcadArtifact: async () => '/local/orcad'
}))
vi.mock('../ssh/orcad-remote-deploy', () => ({ deployOrcad: mocks.deploy }))
vi.mock('../ssh/orcad-managed-version-gc', () => ({ pruneManagedOrcadVersions: mocks.prune }))
vi.mock('../ssh/orcad-managed-wake', () => ({ wakeStoppedManagedOrcad: mocks.wake }))
vi.mock('../ssh/orcad-managed-runtime-context', async (importOriginal) => ({
...(await importOriginal<typeof ManagedRuntimeContext>()),
probeManagedOrcadReadiness: mocks.probe
}))
import { deployAndLaunchWslOrcad, isWsl1KernelRelease } from './wsl-orcad-deploy'
const host = getRemoteHostPlatform('linux-x64')
const READINESS = {
runtimeId: 'r1',
boundEndpoint: 'ws://127.0.0.1:6790',
advertisedEndpoint: null,
managedWslCliReconciliation: 'settled' as const,
pairing: { available: false as const, reason: 'disabled_by_operator', guidance: '' }
}
function holder(): WslDistroHolder {
return {
ensureHeld: vi.fn(async (distro: string) => {
mocks.steps.push(`hold:${distro}`)
return {
outcome: 'spawned' as const,
record: { version: 1 as const, distro, pid: 9, creationTimeMs: 1, startedAtMs: 1 }
}
}),
adoptHolders: vi.fn(),
release: vi.fn(),
holderState: vi.fn()
}
}
let seenTarget: WslOrcadHostTarget | null = null
function transport(target: WslOrcadHostTarget): OrcadHostTransport {
seenTarget = target
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every host call is mocked at its module.
return { getTarget: () => target } as unknown as OrcadHostTransport
}
function launch(kernel = '5.15.167.4-microsoft-standard-WSL2', options = {}) {
mocks.exec.mockImplementation(async (_conn: unknown, command: string) => {
if (command === 'uname -r') {
mocks.steps.push('uname')
return `${kernel}\n`
}
return '4242\n'
})
return deployAndLaunchWslOrcad('Ubuntu-24.04', {
holder: holder(),
createTransport: transport,
readRegistryId: async () => '{0283592d-be56-40d4-b935-3dc18c3aa007}',
...options
})
}
describe('deployAndLaunchWslOrcad', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.steps.length = 0
seenTarget = null
mocks.resolveContext.mockImplementation(async (target: WslOrcadHostTarget, connection) => {
mocks.steps.push('context')
return {
activationRecord: emptyOrcadActivationRecord(),
serverTarget: 'linux-x64-glibc',
connection,
host,
remoteHome: '/home/u',
target,
userDataDir: '/home/u/.orca'
}
})
mocks.recover.mockResolvedValue({ outcome: 'none' })
mocks.adopt.mockImplementation(async () => {
mocks.steps.push('adopt-check')
return { outcome: 'none' }
})
mocks.deploy.mockImplementation(async () => {
mocks.steps.push('deploy+launch')
return { outcome: 'installed-and-activated', fullVersion: '1.5.0+ab', verdict: {} }
})
mocks.probe.mockResolvedValue(READINESS)
})
it('holds the distro before it deploys and launches, and reports what C5 dials', async () => {
await expect(launch()).resolves.toEqual({
outcome: 'ready',
distro: 'Ubuntu-24.04',
distroId: '{0283592d-be56-40d4-b935-3dc18c3aa007}',
port: 6790,
fullVersion: '1.5.0+ab',
pid: 4242,
readiness: READINESS,
holder: 'spawned'
})
expect(mocks.steps).toEqual([
'uname',
'hold:Ubuntu-24.04',
'context',
'adopt-check',
'deploy+launch'
])
expect(seenTarget).toEqual({
kind: 'wsl',
id: 'wsl:ubuntu-24.04',
distro: 'Ubuntu-24.04',
label: 'Ubuntu-24.04'
})
expect(mocks.exec).toHaveBeenLastCalledWith(
expect.anything(),
"cat '/home/u/.orca-remote/orcad-1.5.0+ab/.orcad-pid' 2>/dev/null || true",
expect.anything()
)
})
it('applies the SSH census and downgrade admission to the first deploy', async () => {
await launch()
const options = mocks.deploy.mock.calls[0][0]
expect(options).toMatchObject({
census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null },
appVersion: '1.5.0',
localOrcadDir: '/local/orcad',
target: 'linux-x64-glibc'
})
const newer = { ...emptyOrcadActivationRecord(), active: '2.0.0+cd', activeAppVersion: '2.0.0' }
expect(options.admitRecord(newer, '1.5.0+ab')).toBe(
"Orca 2.0.0 last ran this host's server, newer than this Orca (1.5.0). Update Orca to start it."
)
})
it('refuses a WSL 1 distro before holding it or touching its files', async () => {
await expect(launch('4.4.0-19041-Microsoft')).rejects.toMatchObject({
name: 'OrcadHostUnsupportedError'
})
expect(mocks.steps).toEqual(['uname'])
})
it('surfaces an unsupported libc as the SSH host refusal does', async () => {
const refusal = Object.assign(
new Error('No Orca runtime supports linux-x64-glibc with glibc 2.17.'),
{
name: 'OrcadHostUnsupportedError'
}
)
mocks.resolveContext.mockRejectedValueOnce(refusal)
await expect(launch()).rejects.toBe(refusal)
expect(mocks.deploy).not.toHaveBeenCalled()
})
it('starts an already-active slot the distro stop ended, instead of reporting it ready', async () => {
mocks.deploy.mockResolvedValueOnce({ outcome: 'already-active', fullVersion: '1.5.0+ab' })
mocks.wake.mockResolvedValueOnce({ outcome: 'started', readiness: READINESS })
await expect(launch()).resolves.toMatchObject({ outcome: 'ready', port: 6790 })
expect(mocks.wake).toHaveBeenCalledTimes(1)
})
// Real host (awin): a Remove that failed partway left orcad serving; the record named nothing.
it('deploys over an adopted unrecorded orcad under the SSH terminal gate, never a fresh launch', async () => {
const adoptedRecord = {
...emptyOrcadActivationRecord(),
active: '0.1.0+aa',
activatedAt: '2026-10-12T01:00:00.000Z'
}
mocks.adopt.mockResolvedValueOnce({
outcome: 'adopted',
version: '0.1.0+aa',
pid: 500,
record: adoptedRecord
})
mocks.deploy.mockResolvedValueOnce({
outcome: 'installed-not-activated',
fullVersion: '1.5.0+ab',
code: 'orcad_update_terminal_census_unavailable',
reason: 'unknown census'
})
await expect(launch()).resolves.toMatchObject({ outcome: 'deferred', forceable: false })
expect(mocks.deploy.mock.calls[0][0]).toMatchObject({
censusRecord: adoptedRecord,
census: { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: null }
})
})
it('reports the adopted instance once the same version is serving', async () => {
mocks.adopt.mockResolvedValueOnce({
outcome: 'adopted',
version: '1.5.0+ab',
pid: 500,
record: { ...emptyOrcadActivationRecord(), active: '1.5.0+ab' }
})
mocks.deploy.mockResolvedValueOnce({ outcome: 'already-active', fullVersion: '1.5.0+ab' })
mocks.wake.mockResolvedValueOnce({ outcome: 'serving' })
await expect(launch()).resolves.toMatchObject({
outcome: 'ready',
adoptedInstance: { version: '1.5.0+ab', pid: 500 }
})
})
it('refuses to deploy or launch when an unrecorded orcad cannot be proven', async () => {
mocks.adopt.mockRejectedValueOnce(
Object.assign(new Error('cannot be identified'), { name: 'WslOrcadUnrecordedInstanceError' })
)
await expect(launch()).rejects.toMatchObject({ name: 'WslOrcadUnrecordedInstanceError' })
expect(mocks.deploy).not.toHaveBeenCalled()
expect(mocks.wake).not.toHaveBeenCalled()
})
it('returns a deferral rather than launching over live terminals', async () => {
mocks.deploy.mockResolvedValueOnce({
outcome: 'installed-not-activated',
fullVersion: '1.6.0+ef',
code: 'orcad_update_terminals_running',
reason: 'terminals are running'
})
await expect(launch()).resolves.toEqual({
outcome: 'deferred',
distro: 'Ubuntu-24.04',
candidateVersion: '1.6.0+ef',
code: 'orcad_update_terminals_running',
reason: 'terminals are running',
forceable: true
})
expect(mocks.probe).not.toHaveBeenCalled()
})
})
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)
expect(isWsl1KernelRelease('4.4.0-22621-Microsoft\n')).toBe(true)
expect(isWsl1KernelRelease('5.15.167.4-microsoft-standard-WSL2')).toBe(false)
expect(isWsl1KernelRelease('6.6.36.6-microsoft-standard-WSL2+')).toBe(false)
expect(isWsl1KernelRelease('6.10.0-custom')).toBe(false)
})
})
+193
View File
@@ -0,0 +1,193 @@
/**
* Deploys and starts orcad inside one WSL distro (design D1, D2b, D8), reusing the SSH host's
* install, activation fence/journal and version admission over the `wsl.exe` transport.
* No pairing here: the stdio bridge (C5) connects to the loopback port this reports.
*/
import { getAppEnvironment } from '../../shared/app-environment'
import { ORCAD_MANAGED_REMOTE_PORT } from '../../shared/orcad-managed-runtime'
import type { ServeReadiness } from '../server/serve-readiness'
import { recoverInterruptedOrcadActivation } from '../ssh/orcad-activation-recovery'
import { readOrcadActivationRecord } from '../ssh/orcad-activation-record-store'
import { materializeOrcadArtifact } from '../orcad/orcad-artifact-materializer'
import { OrcadHostUnsupportedError } from '../orcad/orcad-host-unavailable'
import type { OrcadHostTransport } from '../ssh/orcad-host-transport'
import { ORCAD_ACTIVATION_POLICY_REFUSED_CODE } from '../ssh/orcad-installed-activation'
import { orcadBoundPort } from '../ssh/orcad-managed-bound-port'
import {
isForceableOrcadDeferral,
managedOrcadDeployPolicy,
managedOrcadInstallDir,
managedOrcadSlot,
probeManagedOrcadReadiness
} from '../ssh/orcad-managed-runtime-context'
import { pruneManagedOrcadVersions } from '../ssh/orcad-managed-version-gc'
import { wakeStoppedManagedOrcad } from '../ssh/orcad-managed-wake'
import { resolveOrcadRemoteContext } from '../ssh/orcad-remote-context'
import { deployOrcad } from '../ssh/orcad-remote-deploy'
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 { 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 } from './wsl-orcad-host-transport'
import { adoptUnrecordedWslOrcad } from './wsl-orcad-instance-adoption'
export type WslOrcadLaunch =
| {
outcome: 'ready'
distro: string
/** The Lxss registration GUID for the deployment record's `distroId`; null if unreadable. */
distroId: string | null
/** The loopback port orcad bound inside the distro (another one when the preferred was taken). */
port: number
fullVersion: string
pid: number | null
readiness: ServeReadiness
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 }
}
| {
outcome: 'deferred'
distro: string
candidateVersion: string
code: string
reason: string
forceable: boolean
}
export type WslOrcadDeployOptions = {
/** The WSL environment's id; keys host-scoped state (wake dedupe). Defaults per distro. */
environmentId?: string
label?: string
force?: boolean
signal?: AbortSignal
/** Defaults to one created over `userData`; the app should pass its shared instance. */
holder?: WslDistroHolder
createTransport?: (target: WslOrcadHostTarget) => OrcadHostTransport
readRegistryId?: (distro: string) => Promise<string | null>
}
/** WSL 1 runs no Linux kernel, so the pinned Node and its pty cannot run there. */
export function isWsl1KernelRelease(release: string): boolean {
return /^\d+\.\d+\.\d+-\d+-Microsoft$/.test(release.trim())
}
export async function deployAndLaunchWslOrcad(
distro: string,
options: WslOrcadDeployOptions = {}
): Promise<WslOrcadLaunch> {
const name = distro.trim()
if (!name) {
throw new Error('A WSL distro name is required')
}
const target: WslOrcadHostTarget = {
kind: 'wsl',
id: options.environmentId ?? `wsl:${wslDistroHolderKey(name)}`,
distro: name,
label: options.label ?? name
}
const conn = (options.createTransport ?? defaultTransport)(target)
const { signal } = options
const kernel = await execCommand(conn, 'uname -r', { signal })
if (isWsl1KernelRelease(kernel)) {
throw new OrcadHostUnsupportedError(
`The WSL 1 distro ${name} is not supported by managed orcad. ` +
`Convert it with: wsl --set-version ${name} 2`
)
}
// Before the launch: without a holder WSL stops an idle distro and orcad with it.
const holder = options.holder ?? createWslDistroHolder({ userDataPath: userDataPath() })
const held = await holder.ensureHeld(name)
// Read alongside the deploy; a failed read only leaves the record without a GUID.
const distroId = (options.readRegistryId ?? readWslDistroRegistryId)(name).catch(() => null)
let context = await resolveOrcadRemoteContext(target, conn, signal)
const slot = managedOrcadSlot(context, ORCAD_MANAGED_REMOTE_PORT, signal)
const recovery = await recoverInterruptedOrcadActivation(slot)
if (recovery.outcome === 'pending' || recovery.outcome === 'refused') {
throw new Error(recovery.reason)
}
if (recovery.outcome === 'recovered') {
context = { ...context, activationRecord: await readOrcadActivationRecord(slot) }
}
// Before deploy or wake: an orcad the record does not name would keep the instance lock.
const adoption = await adoptUnrecordedWslOrcad(slot, context.activationRecord)
if (adoption.outcome === 'adopted') {
context = { ...context, activationRecord: adoption.record }
}
const localOrcadDir = await materializeOrcadArtifact(context.serverTarget, { signal })
const appVersion = getAppEnvironment().getVersion()
const deployed = await deployOrcad({
...slot,
conn,
localOrcadDir,
target: context.serverTarget,
...managedOrcadDeployPolicy(context, appVersion, options.force),
force: options.force,
appVersion
})
if (deployed.outcome === 'installed-not-activated') {
return {
outcome: 'deferred',
distro: name,
candidateVersion: deployed.fullVersion,
code: deployed.code,
reason: deployed.reason,
forceable:
isForceableOrcadDeferral(deployed.code) ||
deployed.code === ORCAD_ACTIVATION_POLICY_REFUSED_CODE
}
}
if (deployed.outcome === 'already-active') {
// A stopped distro ended the active slot with it; start it again only on proven exit.
const wake = await wakeStoppedManagedOrcad(slot)
if (wake.outcome === 'recovery-refused') {
throw new Error(wake.reason)
}
}
const readiness = await probeManagedOrcadReadiness(
context,
localOrcadDir,
deployed.fullVersion,
signal
)
await pruneManagedOrcadVersions({
slot,
serverTarget: context.serverTarget,
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 } }
: {})
}
}
function defaultTransport(target: WslOrcadHostTarget): OrcadHostTransport {
return createWslOrcadHostTransport({ target })
}
function userDataPath(): string {
return getAppEnvironment().getPath('userData')
}
@@ -172,4 +172,9 @@ describe('createWslOrcadHostTransport', () => {
expect(transport.usesSystemSshTransport()).toBe(false)
expect(transport.getConnectGeneration()).toBe(transport.getConnectGeneration())
})
// Regression: `nohup … &` dies with its `wsl.exe --exec` on a distro without systemd.
it('asks orcad launches to start in their own session', () => {
expect(transportWith([]).transport.orcadLaunchDetach).toBe('setsid')
})
})
+8 -1
View File
@@ -21,6 +21,11 @@ type WslOrcadHostTransportOptions = {
target: WslOrcadHostTarget
/** Test seam; production spawns through process-host. */
spawn?: PipedProcessSpawner
/**
* Tar over stdin by default: on win-lowspec it beat the guest's `/mnt` copy (0.97 s vs 1.6 s for
* 20 MB). `guest-mount` copies from `/mnt/<drive>` when the guest can see the source.
*/
uploadSource?: 'tar' | 'guest-mount'
}
/**
@@ -63,6 +68,8 @@ export function createWslOrcadHostTransport(
}
return channel
},
// MEASURED: `nohup … &` dies when its `wsl.exe --exec` exits on a distro without systemd.
orcadLaunchDetach: 'setsid',
usesSystemSshTransport: () => false,
// One transport, one wsl.exe lane: nothing underneath is ever replaced.
getConnectGeneration: () => 0,
@@ -73,7 +80,7 @@ export function createWslOrcadHostTransport(
uploadDirectoryIntoWsl(
transport,
localDir,
wslGuestSourcePath(localDir, target.distro),
options.uploadSource === 'guest-mount' ? wslGuestSourcePath(localDir, target.distro) : null,
remoteDir,
{ signal: fileOptions?.signal }
)
@@ -0,0 +1,260 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
emptyOrcadActivationRecord,
type OrcadActivationRecord
} from '../ssh/orcad-activation-record'
import type { OrcadSlotOptions } from '../ssh/orcad-recovery-slot'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
type FakeHost = {
lock: string | null
alive: Set<number>
pidFiles: Map<string, number>
record: unknown
transaction: unknown
stopTarget: unknown
readinessBuildHash: string
slotBuildHash: string
commands: string[]
writes: unknown[]
fenced: boolean
}
const host = vi.hoisted((): FakeHost => ({
lock: null,
alive: new Set(),
pidFiles: new Map(),
record: null,
transaction: null,
stopTarget: null,
readinessBuildHash: 'abcdefabcdefabcd',
slotBuildHash: 'abcdefabcdefabcd',
commands: [],
writes: [],
fenced: false
}))
vi.mock('../ssh/orcad-remote-record-file', () => ({
readBoundedOrcadRemoteRecord: async () =>
host.lock === null ? { state: 'absent' } : { state: 'present', raw: host.lock }
}))
vi.mock('../ssh/orcad-remote-runtime-control', () => ({
execOrcadRemote: async (_slot: unknown, command: string) => {
host.commands.push(command)
const kill = /kill -0 (\d+)/.exec(command)
if (kill) {
return host.alive.has(Number(kill[1])) ? 'LIVE\n' : 'DEAD\n'
}
const cat = /^cat '([^']+)'/.exec(command)
if (cat) {
return `${host.pidFiles.get(cat[1]) ?? ''}\n`
}
if (command.startsWith('head -c')) {
return `${JSON.stringify({
type: 'orca_server_ready',
runtimeId: 'r1',
health: { buildHash: host.readinessBuildHash, buildVersion: '0.1.0' }
})}\n`
}
return ''
}
}))
vi.mock('../ssh/orcad-managed-remote-stop', () => ({
readRemoteOrcadManagedStopTarget: async () => host.stopTarget
}))
vi.mock('../ssh/orcad-recovery-slot', () => ({
orcadSlotDir: (_slot: unknown, version: string) => `/home/u/.orca-remote/orcad-${version}`,
resolveOrcadSlotIdentity: async (_slot: unknown, version: string) => ({
version,
remoteDir: `/home/u/.orca-remote/orcad-${version}`,
buildHash: host.slotBuildHash
})
}))
vi.mock('../ssh/orcad-activation-gate', () => ({
evaluateOrcadActivation: (
readiness: { health?: { buildHash?: string } } | null,
expected: { buildHash: string }
) =>
readiness?.health?.buildHash === expected.buildHash
? { decision: 'accept' }
: { decision: 'reject', reason: 'its build does not match the installed slot.' }
}))
vi.mock('../ssh/orcad-activation-record-store', () => ({
readOrcadActivationRecord: async () => host.record,
writeOrcadActivationRecord: async (_slot: unknown, record: unknown) => {
host.writes.push(record)
}
}))
vi.mock('../ssh/orcad-activation-transaction-store', () => ({
readOrcadActivationTransaction: async () => host.transaction
}))
vi.mock('../ssh/orcad-activation-lock', () => ({
withOrcadActivationLock: async (
_slot: unknown,
run: () => Promise<unknown>,
held: () => unknown
) => {
if (host.fenced) {
return held()
}
return run()
}
}))
import { adoptUnrecordedWslOrcad } from './wsl-orcad-instance-adoption'
const OLD = '0.1.0+aaaaaaaaaaaa'
const NEW = '0.2.0+bbbbbbbbbbbb'
const slot: OrcadSlotOptions = {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every host call is mocked at its module.
conn: {} as OrcadSlotOptions['conn'],
host: getRemoteHostPlatform('linux-x64'),
remoteHome: '/home/u',
nodePath: '',
userDataDir: '/home/u/.orca',
bindHost: '127.0.0.1',
port: 6768
}
const NOW = new Date('2026-10-12T01:00:00.000Z')
function lockFor(pid: number, version: string): string {
return JSON.stringify({
pid,
startedAtMs: 1,
identity: '1000',
version,
acquiredAt: '2026-10-11T00:00:00.000Z',
nonce: 'n1',
role: 'orcad'
})
}
function stopTargetFor(pid: number, version: string) {
return {
state: 'ready',
context: {
version,
runtimeId: 'r1',
instance: { pid, startedAtMs: 1, nonce: 'n1', lockPath: '/home/u/.orca/orcad.lock' }
}
}
}
function record(overrides: Partial<OrcadActivationRecord> = {}): OrcadActivationRecord {
return { ...emptyOrcadActivationRecord(), ...overrides }
}
function adopt(current: OrcadActivationRecord) {
host.record = current
return adoptUnrecordedWslOrcad(slot, current, () => NOW)
}
describe('adopting an orcad the activation record does not name', () => {
beforeEach(() => {
host.lock = null
host.alive.clear()
host.pidFiles.clear()
host.transaction = null
host.stopTarget = null
host.readinessBuildHash = 'abcdefabcdefabcd'
host.slotBuildHash = 'abcdefabcdefabcd'
host.commands.length = 0
host.writes.length = 0
host.fenced = false
})
it('adopts a stale orcad a removed registration left running, never stopping it', async () => {
host.lock = lockFor(500, OLD)
host.alive.add(500)
host.stopTarget = stopTargetFor(500, OLD)
// The failed Remove left nothing active in the record.
const result = await adopt(record({ previous: OLD }))
expect(result).toMatchObject({ outcome: 'adopted', version: OLD, pid: 500 })
expect(host.writes).toEqual([
record({ active: OLD, previous: OLD, activatedAt: NOW.toISOString(), snapshot: null })
])
expect(host.commands).toContain(
`(umask 077 && printf '%s\\n' 500 > '/home/u/.orca-remote/orcad-${OLD}/.orcad-pid')`
)
expect(host.commands.some((command) => /\bkill\b(?! -0)/.test(command))).toBe(false)
})
it('records an orcad from a different version as active, keeping the recorded one as previous', async () => {
host.lock = lockFor(600, OLD)
host.alive.add(600)
host.stopTarget = stopTargetFor(600, OLD)
const result = await adopt(
record({ active: NEW, activeAppVersion: '1.5.0', activatedAt: '2026-10-10T00:00:00.000Z' })
)
expect(result).toMatchObject({ outcome: 'adopted', version: OLD, pid: 600 })
expect(host.writes).toEqual([
record({
active: OLD,
previous: NEW,
previousAppVersion: '1.5.0',
activatedAt: NOW.toISOString(),
snapshot: null
})
])
})
it('repoints only the pid file when the recorded slot is serving under another pid', async () => {
host.lock = lockFor(700, OLD)
host.alive.add(700)
host.pidFiles.set(`/home/u/.orca-remote/orcad-${OLD}/.orcad-pid`, 699)
host.stopTarget = stopTargetFor(700, OLD)
const current = record({ active: OLD })
await expect(adopt(current)).resolves.toMatchObject({ outcome: 'adopted', record: current })
expect(host.writes).toEqual([])
expect(host.commands.some((command) => command.includes("printf '%s\\n' 700"))).toBe(true)
})
it('leaves the recorded instance, a dead lock holder and a desktop lock alone', async () => {
host.lock = lockFor(800, OLD)
host.alive.add(800)
host.pidFiles.set(`/home/u/.orca-remote/orcad-${OLD}/.orcad-pid`, 800)
await expect(adopt(record({ active: OLD }))).resolves.toEqual({ outcome: 'none' })
host.alive.clear()
await expect(adopt(record())).resolves.toEqual({ outcome: 'none' })
host.lock = JSON.stringify({ ...JSON.parse(lockFor(800, OLD)), role: 'desktop' })
host.alive.add(800)
await expect(adopt(record())).resolves.toEqual({ outcome: 'none' })
expect(host.writes).toEqual([])
})
it('refuses, writing nothing, when the running orcad cannot be proven to be its slot', async () => {
host.lock = lockFor(900, OLD)
host.alive.add(900)
host.stopTarget = {
state: 'refused',
verdict: 'unverifiable',
code: 'orcad_managed_stop_instance_unverifiable',
reason: 'The instance lock does not name the orcad that published readiness.'
}
await expect(adopt(record())).rejects.toMatchObject({
name: 'WslOrcadUnrecordedInstanceError',
message: expect.stringContaining('cannot be identified')
})
host.stopTarget = stopTargetFor(900, OLD)
host.readinessBuildHash = '0000000000000000'
await expect(adopt(record())).rejects.toThrow(/does not match the installed slot/)
expect(host.writes).toEqual([])
})
it('refuses under a busy fence, a pending journal or a record that changed', async () => {
host.lock = lockFor(910, OLD)
host.alive.add(910)
host.stopTarget = stopTargetFor(910, OLD)
host.fenced = true
await expect(adopt(record())).rejects.toThrow(/Another Orca is changing/)
host.fenced = false
host.transaction = { transactionId: 't1' }
await expect(adopt(record())).rejects.toThrow(/interrupted activation is pending/)
host.transaction = null
host.record = record({ active: NEW })
await expect(adoptUnrecordedWslOrcad(slot, record(), () => NOW)).rejects.toThrow(
/record changed/
)
expect(host.writes).toEqual([])
})
})
+195
View File
@@ -0,0 +1,195 @@
/**
* An orcad still serving in a distro that its activation record does not name (a Remove that
* failed partway, say) is adopted, never killed: only it holds the data root's instance lock, so
* launching the recorded slot would fail, and stopping it would need a terminal census C4 has no
* channel to take. Adoption is proven first: the instance lock, the slot's readiness and the slot's
* installed build must all name the same process. Then the record and pid file are rewritten under
* the activation fence. A different version is then handled by the usual update gate.
*/
import { ORCAD_LOCK_FILE_NAME, parseOrcadInstanceLockRecord } from '../orcad/orcad-instance-lock'
import type { OrcadActivationRecord } from '../ssh/orcad-activation-record'
import {
readOrcadActivationRecord,
writeOrcadActivationRecord
} from '../ssh/orcad-activation-record-store'
import { evaluateOrcadActivation } from '../ssh/orcad-activation-gate'
import { withOrcadActivationLock } from '../ssh/orcad-activation-lock'
import { sameOrcadActivationRecord } from '../ssh/orcad-activation-transaction'
import { readOrcadActivationTransaction } from '../ssh/orcad-activation-transaction-store'
import { readRemoteOrcadManagedStopTarget } from '../ssh/orcad-managed-remote-stop'
import {
orcadSlotDir,
resolveOrcadSlotIdentity,
type OrcadSlotOptions
} from '../ssh/orcad-recovery-slot'
import { ORCAD_PID_FILENAME } from '../ssh/orcad-remote-launch'
import {
parseOrcadReadinessWaitOutput,
readOrcadReadinessNowCommand
} from '../ssh/orcad-remote-readiness-wait'
import { readBoundedOrcadRemoteRecord } from '../ssh/orcad-remote-record-file'
import { execOrcadRemote } from '../ssh/orcad-remote-runtime-control'
import {
ORCAD_INSTALL_MODEL,
remoteInstallDirName,
remoteInstallVersionDirRegex
} from '../ssh/remote-install-model'
import { shellEscape } from '../ssh/ssh-connection-utils'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
const LOCK_RECORD_MAX_BYTES = 64 * 1024
const UNRECORDED_ORCAD_CODE = 'wsl_orcad_unrecorded_instance_unverifiable'
export type WslOrcadAdoption =
| { outcome: 'none' }
| { outcome: 'adopted'; version: string; pid: number; record: OrcadActivationRecord }
export class WslOrcadUnrecordedInstanceError extends Error {
readonly code = UNRECORDED_ORCAD_CODE
constructor(message: string) {
super(message)
this.name = 'WslOrcadUnrecordedInstanceError'
}
}
type LockHolder = { pid: number; version: string }
async function readLiveLockHolder(slot: OrcadSlotOptions): Promise<LockHolder | null> {
const lockPath = joinRemotePath(slot.host, slot.userDataDir, ORCAD_LOCK_FILE_NAME)
const read = await readBoundedOrcadRemoteRecord(slot, lockPath, LOCK_RECORD_MAX_BYTES)
const lock = read.state === 'present' ? parseOrcadInstanceLockRecord(read.raw) : null
// A desktop holder is not an orcad; an absent or garbled lock names nobody to adopt.
if (!lock || lock.role === 'desktop') {
return null
}
const alive = await execOrcadRemote(
slot,
`if kill -0 ${String(lock.pid)} 2>/dev/null; then echo LIVE; else echo DEAD; fi`
)
return alive.trim().split('\n').pop() === 'LIVE' ? { pid: lock.pid, version: lock.version } : null
}
async function readSlotPid(slot: OrcadSlotOptions, version: string): Promise<number | null> {
const pidFile = joinRemotePath(slot.host, orcadSlotDir(slot, version), ORCAD_PID_FILENAME)
const raw = await execOrcadRemote(slot, `cat ${shellEscape(pidFile)} 2>/dev/null || true`)
const pid = Number.parseInt(raw.trim(), 10)
return Number.isSafeInteger(pid) && pid > 0 ? pid : null
}
/** The holder's slot must publish readiness for this pid, from the build installed there. */
async function proveHolderServesItsSlot(slot: OrcadSlotOptions, holder: LockHolder): Promise<void> {
const label = `An orcad (pid ${holder.pid}, ${holder.version}) is running in this distro but is not the recorded one`
const dirName = remoteInstallDirName(ORCAD_INSTALL_MODEL, holder.version)
if (!remoteInstallVersionDirRegex(ORCAD_INSTALL_MODEL).test(dirName)) {
throw new WslOrcadUnrecordedInstanceError(`${label}, and its version is not a slot name.`)
}
const target = await readRemoteOrcadManagedStopTarget(slot, holder.version)
if (target.state !== 'ready' || target.context.instance.pid !== holder.pid) {
throw new WslOrcadUnrecordedInstanceError(
`${label}, and it cannot be identified: ${target.state === 'refused' ? target.reason : 'its readiness names another process.'}`
)
}
const identity = await resolveOrcadSlotIdentity(slot, holder.version)
const readiness = parseOrcadReadinessWaitOutput(
slot.host,
await execOrcadRemote(slot, readOrcadReadinessNowCommand(slot.host, identity.remoteDir))
)
const verdict = evaluateOrcadActivation(
readiness.state === 'ready' ? readiness.readiness : null,
{
buildHash: identity.buildHash,
fullVersion: identity.version
}
)
if (verdict.decision === 'reject') {
throw new WslOrcadUnrecordedInstanceError(`${label}, and ${verdict.reason}`)
}
}
function adoptedRecord(
record: OrcadActivationRecord,
version: string,
now: Date
): OrcadActivationRecord {
if (record.active === version) {
return record
}
const { activeAppVersion, previousAppVersion: _previousAppVersion, ...rest } = record
return {
...rest,
active: version,
previous: record.active ?? record.previous,
...(record.active && activeAppVersion ? { previousAppVersion: activeAppVersion } : {}),
activatedAt: now.toISOString(),
// Its prelaunch snapshot belonged to another activation; rollback must not restore it here.
snapshot: null
}
}
/**
* Adopts the orcad holding the instance lock when the record or its slot's pid file does not
* name it. Anything unprovable refuses: a launch would only lose the instance lock to it.
*/
export async function adoptUnrecordedWslOrcad(
slot: OrcadSlotOptions,
record: OrcadActivationRecord,
now: () => Date = () => new Date()
): Promise<WslOrcadAdoption> {
const holder = await readLiveLockHolder(slot)
if (!holder) {
return { outcome: 'none' }
}
if (
record.active === holder.version &&
(await readSlotPid(slot, holder.version)) === holder.pid
) {
return { outcome: 'none' }
}
await proveHolderServesItsSlot(slot, holder)
return withOrcadActivationLock(
slot,
async () => {
const current = await readOrcadActivationRecord(slot)
if (!sameOrcadActivationRecord(current, record)) {
throw new WslOrcadUnrecordedInstanceError(
'The activation record changed while an unrecorded orcad was being adopted. Retry.'
)
}
if (await readOrcadActivationTransaction(slot)) {
throw new WslOrcadUnrecordedInstanceError(
'An interrupted activation is pending, so an unrecorded orcad cannot be adopted yet.'
)
}
const again = await readLiveLockHolder(slot)
if (again?.pid !== holder.pid || again.version !== holder.version) {
throw new WslOrcadUnrecordedInstanceError(
'The unrecorded orcad changed while it was being adopted. Retry.'
)
}
const pidFile = joinRemotePath(
slot.host,
orcadSlotDir(slot, holder.version),
ORCAD_PID_FILENAME
)
await execOrcadRemote(
slot,
`(umask 077 && printf '%s\\n' ${String(holder.pid)} > ${shellEscape(pidFile)})`
)
const next = adoptedRecord(current, holder.version, now())
if (next !== current) {
await writeOrcadActivationRecord(slot, next)
}
return {
outcome: 'adopted' as const,
version: holder.version,
pid: holder.pid,
record: next
}
},
() => {
throw new WslOrcadUnrecordedInstanceError(
'Another Orca is changing this distro’s server, so an unrecorded orcad cannot be adopted now.'
)
}
)
}
@@ -235,6 +235,19 @@ describe('agent CLI install-dir fallback', () => {
}
)
// Real host (WSL 2.7.10): the guest's ambient PATH reached a /mnt/c npm shim after the
// filtered walk had already rejected it, reporting a Windows codex as installed.
it.skipIf(process.platform === 'win32')(
'searches the PATH detection searched, not the ambient one',
() => {
const shimDir = '/mnt/c/Users/u/AppData/Roaming/npm'
stage(join(shimDir, 'codex'))
vi.stubEnv('PATH', [GUI_LAUNCH_PATH, shimDir].join(delimiter))
expect(detectCommandsInInstallDirs(['codex'])).toEqual(new Set(['codex']))
expect(detectCommandsInInstallDirs(['codex'], GUI_LAUNCH_PATH)).toEqual(new Set())
}
)
it('carries the system install dirs into the POSIX guest fallback prelude', () => {
const prelude = buildPosixFallbackPathPrelude()
const systemDirs = [
@@ -2,12 +2,19 @@ import path from 'node:path'
import { resolveCliCommands } from './node-cli-command-resolution'
// Why: detection may precede shell-PATH hydration, but the fallback stays bounded.
export function detectCommandsInInstallDirs(commands: readonly string[]): Set<string> {
export function detectCommandsInInstallDirs(
commands: readonly string[],
/** The PATH detection searched; defaults to this process's own. */
pathEnv?: string
): Set<string> {
if (commands.length === 0) {
return new Set()
}
try {
const resolvedCommands = resolveCliCommands(commands)
const resolvedCommands =
pathEnv === undefined
? resolveCliCommands(commands)
: resolveCliCommands(commands, { pathEnv })
return new Set(
commands.filter((command) => path.isAbsolute(resolvedCommands.get(command) ?? command))
)