mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user