diff --git a/src/main/wsl/wsl-orcad-deploy.test.ts b/src/main/wsl/wsl-orcad-deploy.test.ts index df3c3e9317a..c5004e674e7 100644 --- a/src/main/wsl/wsl-orcad-deploy.test.ts +++ b/src/main/wsl/wsl-orcad-deploy.test.ts @@ -69,7 +69,7 @@ vi.mock('../managed-server/lifecycle/orcad-managed-runtime-context', async (impo probeManagedOrcadReadiness: mocks.probe })) -import { deployAndLaunchWslOrcad, isWsl1KernelRelease } from './wsl-orcad-deploy' +import { deployAndLaunchWslOrcad } from './wsl-orcad-deploy' import { resetOrcadUpdateFailuresForTests } from '../managed-server/lifecycle/orcad-managed-update-failure-memory' beforeEach(() => resetOrcadUpdateFailuresForTests()) @@ -837,13 +837,3 @@ describe('deployAndLaunchWslOrcad across an app update (E1)', () => { expect(mocks.deploy.mock.calls[0][0].census.liveSessions).toBeNull() }) }) - -describe('isWsl1KernelRelease', () => { - it('tells the WSL 1 syscall layer from a WSL 2 kernel, stock or custom', () => { - expect(isWsl1KernelRelease('4.4.0-19041-Microsoft')).toBe(true) - 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) - }) -}) diff --git a/src/main/wsl/wsl-orcad-deploy.ts b/src/main/wsl/wsl-orcad-deploy.ts index 6dd8268151e..9f9241278d1 100644 --- a/src/main/wsl/wsl-orcad-deploy.ts +++ b/src/main/wsl/wsl-orcad-deploy.ts @@ -27,7 +27,6 @@ import { pruneWslOrcadVersionsUntilClean } from './wsl-orcad-version-gc-memo' import { wakeAlreadyActiveOrcad } from '../ssh/orcad-already-active-wake' import { OrcadActiveReadinessError } from '../ssh/orcad-active-readiness' import { WslDistroNotRunningError } from './wsl-orcad-connection-errors' -import { resolveOrcadRemoteContext } from '../managed-server/install/orcad-remote-context' import { deployOrcad } from '../ssh/orcad-remote-deploy' import { isWslMoveUnderwayOnHost } from '../persistence/moving-wsl-projects/wsl-move-running-moves' import { ORCAD_PID_FILENAME } from '../managed-server/install/orcad-remote-host-support' @@ -37,10 +36,10 @@ import { joinRemotePath } from '../ssh/ssh-remote-platform' import { readLocalFullVersion } from '../managed-server/install/remote-versioned-install' import { createWslDistroHolder, type WslDistroHolder } from './wsl-distro-holder' import { wslDistroHolderKey } from './wsl-distro-holder-record' -import { refuseMissingWslDistro, WslDistroVersion1Error } from './wsl-distro-unsupported' import { readWslDistroRegistryIdShared } from './wsl-distro-registry-id' import type { WslOrcadHostTarget } from './wsl-orcad-host-target' import { createWslOrcadHostTransport, type WslOrcadHostTransport } from './wsl-orcad-host-transport' +import { holdWslDistroWithContext } from './wsl-orcad-held-context' import { readWslOrcadIncumbent, type WslOrcadIncumbent } from './wsl-orcad-incumbent' import { adoptUnrecordedWslOrcad } from './wsl-orcad-instance-adoption' import { @@ -98,11 +97,6 @@ export function defaultWslOrcadEnvironmentId(distro: string): string { return `wsl:${wslDistroHolderKey(distro)}` } -/** 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 = {} @@ -134,16 +128,13 @@ async function launchOverTransport( const { signal } = options let ownBuildVersion: string | null = null const conn = withOwnBuildLaunchEnv(transport, () => ownBuildVersion) - const kernel = await execCommand(conn, 'uname -r', { signal }).catch(refuseMissingWslDistro(name)) - if (isWsl1KernelRelease(kernel)) { - throw new WslDistroVersion1Error(name) - } // Acquire a holder before launch or WSL can idle-stop orcad's distro. const holder = options.holder ?? createWslDistroHolder({ userDataPath: userDataPath() }) - const held = await holder.ensureHeld(name) // Read GUID alongside deploy; failure leaves the record without one. const distroId = (options.readRegistryId ?? readWslDistroRegistryIdShared)(name).catch(() => null) - let context = await resolveOrcadRemoteContext(target, conn, signal) + const first = await holdWslDistroWithContext(name, target, conn, holder, signal) + const held = first.held + let context = first.context const slot = managedOrcadSlot(context, ORCAD_MANAGED_REMOTE_PORT, signal) const recovery = await recoverInterruptedOrcadActivation(slot) if (recovery.outcome === 'pending' || recovery.outcome === 'refused') { diff --git a/src/main/wsl/wsl-orcad-held-context.test.ts b/src/main/wsl/wsl-orcad-held-context.test.ts new file mode 100644 index 00000000000..814393f121a --- /dev/null +++ b/src/main/wsl/wsl-orcad-held-context.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcadHostTransport } from '../ssh/orcad-host-transport' +import type { WslDistroHolder } from './wsl-distro-holder' +import type { WslOrcadHostTarget } from './wsl-orcad-host-target' + +const mocks = vi.hoisted(() => { + const state: { spawned: string[]; refused: string[]; probeStep: (() => void) | null } = { + spawned: [], + refused: [], + probeStep: null + } + return state +}) + +// Like the real execCommand: an aborted signal refuses before anything is spawned. +vi.mock('../ssh/remote-exec/remote-exec-command', () => ({ + execCommand: vi.fn( + async (_conn: unknown, command: string, options?: { signal?: AbortSignal }) => { + if (options?.signal?.aborted) { + mocks.refused.push(command) + throw options.signal.reason + } + mocks.spawned.push(command) + return command === 'uname -r' ? '5.15.167.4-microsoft-standard-WSL2\n' : '' + } + ) +})) +// Like the real context resolver: several probes in series, each passed the caller's signal. +vi.mock('../managed-server/install/orcad-remote-context', async () => { + const { execCommand } = await import('../ssh/remote-exec/remote-exec-command') + return { + resolveOrcadRemoteContext: async ( + _target: unknown, + conn: OrcadHostTransport, + signal?: AbortSignal + ) => { + for (const command of ['uname -sm', 'echo $HOME', 'ldd --version']) { + await new Promise((resolve) => { + mocks.probeStep = resolve + }) + await execCommand(conn, command, { signal }) + } + return { remoteHome: '/home/u' } + } + } +}) + +import { holdWslDistroWithContext, isWsl1KernelRelease } from './wsl-orcad-held-context' + +const target: WslOrcadHostTarget = { kind: 'wsl', id: 'wsl:u', distro: 'U', label: 'U' } +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every host call is mocked at its module. +const conn = {} as OrcadHostTransport + +function holderRejectingOn(gate: Promise): WslDistroHolder { + return { + ensureHeld: vi.fn(async () => { + await gate + throw new Error('holder exited before the distro started') + }), + adoptHolders: vi.fn(), + release: vi.fn(), + releaseAll: vi.fn(), + holderState: vi.fn() + } +} + +beforeEach(() => { + mocks.spawned.length = 0 + mocks.refused.length = 0 + mocks.probeStep = null +}) + +describe('holdWslDistroWithContext', () => { + it('probes the context while the hold is pending, and resolves only once the distro is held', async () => { + let finishHold: () => void = () => {} + const holdDone = new Promise((resolve) => { + finishHold = resolve + }) + const hold: WslDistroHolder = { + ...holderRejectingOn(holdDone), + ensureHeld: vi.fn(async (distro: string) => { + await holdDone + return { + outcome: 'spawned' as const, + record: { version: 1 as const, distro, pid: 9, creationTimeMs: 1, startedAtMs: 1 } + } + }) + } + let settled = false + const held = holdWslDistroWithContext('U', target, conn, hold, undefined).finally(() => { + settled = true + }) + for (let step = 0; step < 3; step++) { + await vi.waitFor(() => expect(mocks.probeStep).not.toBeNull()) + const next = mocks.probeStep + mocks.probeStep = null + next?.() + } + await vi.waitFor(() => + expect(mocks.spawned).toEqual(['uname -r', 'uname -sm', 'echo $HOME', 'ldd --version']) + ) + // The deploy awaits this before recovery, which may launch orcad. + expect(settled).toBe(false) + + finishHold() + + await expect(held).resolves.toMatchObject({ + held: { outcome: 'spawned' }, + context: { remoteHome: '/home/u' } + }) + }) + + it('stops the context probes once the hold fails, so nothing can boot the distro again', async () => { + let failHold: () => void = () => {} + const holdFails = new Promise((resolve) => { + failHold = resolve + }) + const held = holdWslDistroWithContext( + 'U', + target, + conn, + holderRejectingOn(holdFails), + undefined + ) + await vi.waitFor(() => expect(mocks.probeStep).not.toBeNull()) + mocks.probeStep?.() + await vi.waitFor(() => expect(mocks.spawned).toEqual(['uname -r', 'uname -sm'])) + + failHold() + await expect(held).rejects.toThrow('holder exited') + mocks.probeStep?.() + await vi.waitFor(() => expect(mocks.refused).toEqual(['echo $HOME'])) + + expect(mocks.spawned).toEqual(['uname -r', 'uname -sm']) + }) +}) + +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) + }) +}) diff --git a/src/main/wsl/wsl-orcad-held-context.ts b/src/main/wsl/wsl-orcad-held-context.ts new file mode 100644 index 00000000000..513810edc18 --- /dev/null +++ b/src/main/wsl/wsl-orcad-held-context.ts @@ -0,0 +1,52 @@ +import { resolveOrcadRemoteContext } from '../managed-server/install/orcad-remote-context' +import type { OrcadHostTransport } from '../ssh/orcad-host-transport' +import { execCommand } from '../ssh/remote-exec/remote-exec-command' +import type { WslDistroHolder } from './wsl-distro-holder' +import { refuseMissingWslDistro, WslDistroVersion1Error } from './wsl-distro-unsupported' +import type { WslOrcadHostTarget } from './wsl-orcad-host-target' + +/** 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()) +} + +/** + * Refuses WSL 1, then acquires the holder alongside the read-only context probes: the kernel probe + * already started the distro, so the holder spawns nothing extra, and it is held before the caller + * may launch orcad (recovery included) or WSL could idle-stop its distro. + */ +export async function holdWslDistroWithContext( + name: string, + target: WslOrcadHostTarget, + conn: OrcadHostTransport, + holder: WslDistroHolder, + signal: AbortSignal | undefined +) { + const kernel = await execCommand(conn, 'uname -r', { signal }).catch(refuseMissingWslDistro(name)) + if (isWsl1KernelRelease(kernel)) { + throw new WslDistroVersion1Error(name) + } + // Once the hold fails, stop the probes: after dispose they fall back to fresh wsl.exe execs, + // which would boot a distro that just went away (E9). + const probes = new AbortController() + if (signal?.aborted) { + probes.abort(signal.reason) + } + const stopProbes = (): void => probes.abort(signal?.reason) + signal?.addEventListener('abort', stopProbes, { once: true }) + try { + const [held, context] = await Promise.all([ + holder.ensureHeld(name).catch((error: unknown) => { + probes.abort(error) + throw error + }), + resolveOrcadRemoteContext(target, conn, probes.signal) + ]) + return { held, context } + } catch (error) { + probes.abort(error) + throw error + } finally { + signal?.removeEventListener('abort', stopProbes) + } +}