From fc7dff8ce1958d6e9d9f2ba6e7262d1f9799b258 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:09:02 -0700 Subject: [PATCH] ci(wsl): real WSL2 lane for WSL via orcad (C10) (#27921) * ci(wsl): real WSL2 lane for orcad deploy, bridge, liveness and holder (WSL-via-orcad C10) * ci(wsl): upload the template's dot-directories; save the rootfs cache on failure * ci(wsl): update the runner's WSL to 3.0.1+ before asserting WSLInterop survives * test(wsl): let a released holder settle before asserting it exited * ci(wsl): install a pinned, cached WSL 3.0.1 MSI instead of wsl --update * test(wsl): keep the lane's survival seconds typed in its assertion message --- .github/workflows/wsl-orcad-e2e.yml | 220 ++++++++ .../wsl/wsl-orcad-real-distro-lane.test.ts | 475 ++++++++++++++++++ 2 files changed, 695 insertions(+) create mode 100644 .github/workflows/wsl-orcad-e2e.yml create mode 100644 src/main/wsl/wsl-orcad-real-distro-lane.test.ts diff --git a/.github/workflows/wsl-orcad-e2e.yml b/.github/workflows/wsl-orcad-e2e.yml new file mode 100644 index 00000000000..306204d7c46 --- /dev/null +++ b/.github/workflows/wsl-orcad-e2e.yml @@ -0,0 +1,220 @@ +name: WSL orcad E2E + +# WSL via orcad (step C10) on real WSL2 distros: a GitHub-hosted windows-2025 runner imports a +# pinned Ubuntu 24.04 rootfs twice (systemd off and on) and runs +# src/main/wsl/wsl-orcad-real-distro-lane.test.ts against both: deploy and launch, the wsl.exe +# stdio bridge, runtime RPC, liveness recovery, holder survival and teardown. Not a required check. +# Hosted WSL2 was proven in #27783: WSL 2.7.14, import 13-18 s, cold boot 8-14 s. +on: + pull_request: + paths: + - "src/main/wsl/**" + - "src/main/ssh/orcad-*" + - "src/main/ssh/remote-install-transport.ts" + - ".github/workflows/wsl-orcad-e2e.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: wsl-orcad-e2e-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + # wsl.exe prints UTF-16LE to pipes unless told otherwise. + WSL_UTF8: "1" + ROOTFS_URL: https://releases.ubuntu.com/24.04.4/ubuntu-24.04.4-wsl-amd64.wsl + ROOTFS_SHA256: 9b2f7730dc68227dd04a9f3e5eab86ad85caf556b8606ad94f1f29ff5c4fd3f5 + WSL_MSI_URL: https://github.com/microsoft/WSL/releases/download/3.0.1/wsl.3.0.1.0.x64.msi + WSL_MSI_SHA256: 28b1a0d013640a2ac95898ea705fa186e5b4ff767a1c1b49257161bc106599c6 + PLAIN_DISTRO: orca-lane-plain + SYSTEMD_DISTRO: orca-lane-systemd + +jobs: + # The distro is linux-x64-glibc; its node-pty slot only builds on Linux, and nothing else is needed. + template: + name: Build the linux-x64-glibc orcad template + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + - name: Build the template + run: | + pnpm build:orcad-prebuilds + pnpm build:orcad-prebuilds --require-slots linux-x64-glibc + pnpm build:orcad + node config/scripts/build-orcad-template.mjs --targets linux-x64-glibc + - uses: actions/upload-artifact@v7 + with: + name: wsl-orcad-template + path: out/orcad-template/ + # The template carries dot-directories (`.claude-plugin`) its manifest checksums. + include-hidden-files: true + retention-days: 1 + if-no-files-found: error + + wsl2: + name: WSL2 distros (systemd off and on) + needs: template + runs-on: windows-2025 + timeout-minutes: 45 + env: + ORCA_BACKGROUND_LAUNCH: "1" + ORCA_WSL_ORCAD_LANE_RECEIPTS: ${{ github.workspace }}\test-results\wsl-orcad-lane + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + # The image ships WSL 2.7.14, where stopping a systemd distro drops the VM-wide WSLInterop + # entry (microsoft/WSL#13885, fixed in 3.0.1). The lane asserts the fixed behavior, so it + # installs a pinned 3.0.1 MSI; `wsl --update` asks the GitHub API and gets rate-limited (403). + - name: Restore the pinned WSL package + id: wsl-msi-cache + uses: actions/cache/restore@v5 + with: + path: ${{ runner.temp }}/wsl-msi + key: wsl-orcad-msi-${{ env.WSL_MSI_SHA256 }} + - name: Install WSL 3.0.1 + timeout-minutes: 8 + run: | + "ImageOS=$env:ImageOS ImageVersion=$env:ImageVersion" + wsl.exe --version + $dir = Join-Path $env:RUNNER_TEMP 'wsl-msi' + New-Item -ItemType Directory -Force $dir | Out-Null + $msi = Join-Path $dir 'wsl.msi' + if (-not (Test-Path $msi)) { & "$env:WINDIR\System32\curl.exe" --fail --location --silent --show-error --output $msi $env:WSL_MSI_URL } + if ((Get-FileHash $msi -Algorithm SHA256).Hash.ToLowerInvariant() -ne $env:WSL_MSI_SHA256) { + Remove-Item $msi; throw 'WSL package checksum mismatch' + } + $install = Start-Process msiexec.exe -ArgumentList @('/i', $msi, '/qn', '/norestart', '/l*v', (Join-Path $env:RUNNER_TEMP 'wsl-msi.log')) -Wait -PassThru + if ($install.ExitCode -notin 0, 3010) { throw "WSL install failed: $($install.ExitCode)" } + wsl.exe --shutdown + $version = [version](((wsl.exe --version) -join "`n") -replace '(?s)^\D*?(\d+\.\d+\.\d+).*$', '$1') + wsl.exe --version + if ($version -lt [version]'3.0.1') { throw "WSL $version is older than 3.0.1" } + - name: Save the pinned WSL package + if: steps.wsl-msi-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: ${{ runner.temp }}/wsl-msi + key: wsl-orcad-msi-${{ env.WSL_MSI_SHA256 }} + - name: Restore the pinned rootfs + id: rootfs-cache + uses: actions/cache/restore@v5 + with: + path: ${{ runner.temp }}/wsl-rootfs + key: wsl-orcad-rootfs-${{ env.ROOTFS_SHA256 }} + # Runs beside the dependency install; the lane waits for it below. + - name: Import both distros as WSL2 + id: import + background: true + timeout-minutes: 10 + run: | + $cache = Join-Path $env:RUNNER_TEMP 'wsl-rootfs' + New-Item -ItemType Directory -Force $cache | Out-Null + $rootfs = Join-Path $cache 'ubuntu.wsl' + if (-not (Test-Path $rootfs)) { Invoke-WebRequest $env:ROOTFS_URL -OutFile $rootfs } + if ((Get-FileHash $rootfs -Algorithm SHA256).Hash.ToLowerInvariant() -ne $env:ROOTFS_SHA256) { + Remove-Item $rootfs; throw 'rootfs checksum mismatch' + } + foreach ($d in @(@{ Name = $env:PLAIN_DISTRO; Systemd = 'false' }, @{ Name = $env:SYSTEMD_DISTRO; Systemd = 'true' })) { + $sw = [Diagnostics.Stopwatch]::StartNew() + # No WSL1 fallback: the lane exists to prove WSL2. + wsl.exe --import $d.Name (Join-Path $env:RUNNER_TEMP $d.Name) $rootfs --version 2 + if ($LASTEXITCODE -ne 0) { throw "WSL2 import of $($d.Name) failed: $LASTEXITCODE" } + "$($d.Name) import_ms=$($sw.ElapsedMilliseconds)" + if (-not ((wsl.exe --list --verbose) -match "$($d.Name)\s+\S+\s+2\s*$")) { throw "$($d.Name) is not a WSL2 distro" } + # A non-root default user, as on a real install; git for the lane's repo. + $setup = "set -e; id orca >/dev/null 2>&1 || useradd -m -s /bin/bash orca; " + + "printf '[boot]\nsystemd=$($d.Systemd)\n[user]\ndefault=orca\n' > /etc/wsl.conf; " + + "command -v git >/dev/null || (apt-get update -qq && apt-get install -y -qq git)" + wsl.exe -d $d.Name -u root --exec sh -c $setup + if ($LASTEXITCODE -ne 0) { throw "provisioning $($d.Name) failed: $LASTEXITCODE" } + wsl.exe --terminate $d.Name + $sw.Restart() + wsl.exe -d $d.Name --exec sh -c 'echo "user=$(id -un) pid1=$(ps -p 1 -o comm=) kernel=$(uname -r)"' + if ($LASTEXITCODE -ne 0) { throw "first boot of $($d.Name) failed: $LASTEXITCODE" } + "$($d.Name) boot_ms=$($sw.ElapsedMilliseconds)" + wsl.exe --terminate $d.Name + } + wsl.exe --list --verbose + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + - uses: actions/download-artifact@v8 + with: + name: wsl-orcad-template + path: ${{ runner.temp }}/orcad-template + - wait: import + # Saved even when the lane fails; the import step verified its checksum. + - name: Save the pinned rootfs + if: steps.rootfs-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: ${{ runner.temp }}/wsl-rootfs + key: wsl-orcad-rootfs-${{ env.ROOTFS_SHA256 }} + - name: "Phase 1: deploy, RPC, kill orcad, terminate distro" + timeout-minutes: 20 + env: + ORCA_RUN_WSL_ORCAD_LANE: "1" + ORCA_WSL_ORCAD_LANE_PHASE: launch + ORCA_ORCAD_TEMPLATE_PATH: ${{ runner.temp }}/orcad-template + run: | + New-Item -ItemType Directory -Force $env:ORCA_WSL_ORCAD_LANE_RECEIPTS | Out-Null + node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/wsl/wsl-orcad-real-distro-lane.test.ts --reporter=verbose 2>&1 | Tee-Object (Join-Path $env:ORCA_WSL_ORCAD_LANE_RECEIPTS 'launch.log') + if ($global:LASTEXITCODE -ne 0) { throw "launch phase failed: $global:LASTEXITCODE" } + # A new process: the holders must have kept both distros Running since phase 1 exited. + - name: "Phase 2: holders survive, reconnect, teardown" + timeout-minutes: 10 + env: + ORCA_RUN_WSL_ORCAD_LANE: "1" + ORCA_WSL_ORCAD_LANE_PHASE: survive + ORCA_ORCAD_TEMPLATE_PATH: ${{ runner.temp }}/orcad-template + run: | + node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/wsl/wsl-orcad-real-distro-lane.test.ts --reporter=verbose 2>&1 | Tee-Object (Join-Path $env:ORCA_WSL_ORCAD_LANE_RECEIPTS 'survive.log') + if ($global:LASTEXITCODE -ne 0) { throw "survive phase failed: $global:LASTEXITCODE" } + # Belt and braces for the runner: a missing VM-wide interop entry is restored, then fails. + - name: WSLInterop is intact + if: ${{ !cancelled() && steps.import.outcome == 'success' }} + timeout-minutes: 3 + run: | + $state = wsl.exe -d $env:PLAIN_DISTRO -u root --exec sh -c 'if [ -e /proc/sys/fs/binfmt_misc/WSLInterop ]; then echo present; else echo :WSLInterop:M::MZ::/init:P > /proc/sys/fs/binfmt_misc/register && echo restored; fi' + "WSLInterop: $state" + wsl.exe --terminate $env:PLAIN_DISTRO + if ("$state".Trim() -ne 'present') { throw "WSLInterop was missing ($state)" } + - name: Collect orcad logs + if: failure() + timeout-minutes: 3 + run: | + $out = $env:ORCA_WSL_ORCAD_LANE_RECEIPTS + New-Item -ItemType Directory -Force $out | Out-Null + foreach ($d in @($env:PLAIN_DISTRO, $env:SYSTEMD_DISTRO)) { + wsl.exe -d $d --exec sh -c 'for f in $(find "$HOME/.orca-remote" -name "*.log*" -type f 2>/dev/null); do echo "=== $f"; tail -n 400 "$f"; done' > (Join-Path $out "$d-orcad-logs.txt") 2>&1 + } + wsl.exe --list --verbose + - name: Summary + if: always() + run: | + $dir = $env:ORCA_WSL_ORCAD_LANE_RECEIPTS + $lines = @("### WSL orcad lane — $env:ImageOS $env:ImageVersion", '') + foreach ($phase in 'launch', 'survive') { + $file = Join-Path $dir "$phase.json" + if (Test-Path $file) { $lines += "#### $phase", '```json', (Get-Content $file -Raw), '```' } + } + $lines | Add-Content $env:GITHUB_STEP_SUMMARY + - uses: actions/upload-artifact@v7 + if: always() + with: + name: wsl-orcad-lane-receipts + path: test-results/wsl-orcad-lane/ + retention-days: 7 + if-no-files-found: ignore diff --git a/src/main/wsl/wsl-orcad-real-distro-lane.test.ts b/src/main/wsl/wsl-orcad-real-distro-lane.test.ts new file mode 100644 index 00000000000..fbf8c993c4e --- /dev/null +++ b/src/main/wsl/wsl-orcad-real-distro-lane.test.ts @@ -0,0 +1,475 @@ +// WSL via orcad against real WSL2 distros (step C10): deploy and launch (C4), connect over the +// wsl.exe stdio bridge (C5), drive runtime RPC through it, and prove liveness (C6) on one +// systemd-off and one systemd-on distro. Two phases, each its own process, so the holder is +// proven to outlive the process that spawned it: +// launch - deploy, RPC, kill orcad => relaunch + census, `wsl --terminate` => terminals exited +// survive - >= 60 s after launch exited: distros still Running, holders adopted live, reconnect, +// teardown leaves no bridge or holder processes, WSLInterop intact +// .github/workflows/wsl-orcad-e2e.yml imports the distros and runs both phases. +// +// Run: ORCA_RUN_WSL_ORCAD_LANE=1 ORCA_WSL_ORCAD_LANE_PHASE=launch|survive pnpm test +import { randomBytes } from 'node:crypto' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) + +import { runProcess } from '@orca/process-host' +import { setAppEnvironment } from '../../shared/app-environment' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' +import { pairedRuntimeConnectionFromOffer } from '../../shared/paired-runtime-connection' +import { parsePairingCode } from '../../shared/pairing' +import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' +import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' +import { requestOrcadTerminalCensus } from '../ssh/orcad-terminal-census-client' +import { execCommand } from '../ssh/ssh-relay-exec-command' +import { readConfirmedRunningWslDistros } from '../wsl-background-distro-gate' +import { readWindowsProcessTableFresh } from '../windows/windows-process-table' +import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' +import { createWslDistroHolder, type WslDistroHolder } from './wsl-distro-holder' +import { WSL_DISTRO_HOLDER_MARKER } from './wsl-distro-holder-command' +import { resolveWslExecutablePath } from './wsl-executable-path' +import { wslDistroHolderKey } from './wsl-distro-holder-record' +import { WslOrcadConnectionManager, type WslOrcadConnection } from './wsl-orcad-connection' +import type { WslOrcadConnectionStatus } from './wsl-orcad-connection-status' +import { defaultWslOrcadEnvironmentId } from './wsl-orcad-deploy' +import { createWslOrcadHostTransport } from './wsl-orcad-host-transport' +import type { WslOrcadLivenessSupervisor } from './wsl-orcad-liveness-supervisor' +import { createWslOrcadLivenessSupervisor } from './wsl-orcad-liveness-wiring' + +const RUN = process.env.ORCA_RUN_WSL_ORCAD_LANE === '1' +const PHASE = process.env.ORCA_WSL_ORCAD_LANE_PHASE ?? 'launch' +const PLAIN = process.env.ORCA_WSL_ORCAD_LANE_PLAIN_DISTRO ?? 'orca-lane-plain' +const SYSTEMD = process.env.ORCA_WSL_ORCAD_LANE_SYSTEMD_DISTRO ?? 'orca-lane-systemd' +const DISTROS = [ + { name: PLAIN, systemd: false }, + { name: SYSTEMD, systemd: true } +] +const WORK_DIR = process.env.ORCA_WSL_ORCAD_LANE_DIR ?? join(tmpdir(), 'orca-wsl-orcad-lane') +const USER_DATA = join(WORK_DIR, 'userData') +const STATE_FILE = join(WORK_DIR, 'launch-state.json') +const RECEIPT_DIR = process.env.ORCA_WSL_ORCAD_LANE_RECEIPTS ?? join(WORK_DIR, 'receipts') +const HOLDER_SURVIVAL_MS = 60_000 +const DISTRO_TIMEOUT_MS = 12 * 60_000 +const BRIDGE_PATTERN = '[o]rcadStdioBridge' +const WSL_INTEROP = '/proc/sys/fs/binfmt_misc/WSLInterop' + +type LaunchState = { + launchEndedAtMs: number + orcadPids: Record +} +type Receipt = Record + +const receipt: Receipt = { phase: PHASE } +const interopChecks: Receipt[] = [] +receipt.wslInterop = interopChecks +const statuses = new Map() +let visibleWork = true +let holder: WslDistroHolder +let manager: WslOrcadConnectionManager +let supervisor: WslOrcadLivenessSupervisor | null = null + +function installLaneAppEnvironment(): void { + const { version } = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) + setAppEnvironment({ + getPath: (name) => (name === 'userData' ? USER_DATA : tmpdir()), + getAppPath: () => process.cwd(), + getVersion: () => version, + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) +} + +function statusLog(distro: string): WslOrcadConnectionStatus[] { + const key = wslDistroHolderKey(distro) + const log = statuses.get(key) ?? [] + statuses.set(key, log) + return log +} + +function transportFor(distro: string) { + return createWslOrcadHostTransport({ + target: { + kind: 'wsl', + id: defaultWslOrcadEnvironmentId(distro), + distro, + label: distro + } + }) +} + +/** As the distro's default user, through the same transport orcad's deploy uses. */ +function guest(distro: string, command: string): Promise { + return execCommand(transportFor(distro), command, { timeoutMs: 60_000 }) +} + +async function wslExe(args: string[], timeoutMs = 60_000) { + const result = await runProcess({ + program: resolveWslExecutablePath(), + args, + cwd: resolveWslInteropSpawnCwd(), + env: { ...process.env, WSL_UTF8: '1' }, + timeoutMs + }) + return { + code: result.code, + output: `${result.stdout}${result.stderr}`.trim() + } +} + +function asRoot(distro: string, script: string) { + return wslExe(buildWslExecArgs(distro, ['sh', '-c', script], { user: 'root' })) +} + +/** WSL < 3.0.1 lost this VM-wide entry when a systemd distro stopped (microsoft/WSL#13885). */ +async function assertWslInteropIntact(viaDistro: string, label: string): Promise { + const probe = await asRoot( + viaDistro, + `if [ -e ${WSL_INTEROP} ]; then echo present; else ` + + `echo ':WSLInterop:M::MZ::/init:P' > /proc/sys/fs/binfmt_misc/register && echo restored; fi` + ) + interopChecks.push({ label, viaDistro, result: probe.output }) + // Restored above so later jobs on this runner are not poisoned; the lane still fails. + expect(probe.output, `WSLInterop ${label}`).toBe('present') + const ver = await guest(viaDistro, '/mnt/c/Windows/System32/cmd.exe /c ver') + expect(ver).toMatch(/Windows/u) +} + +async function waitFor( + what: string, + read: () => T | null | undefined | Promise, + timeoutMs: number, + intervalMs = 100 +): Promise { + const deadline = performance.now() + timeoutMs + for (;;) { + const value = await read() + if (value !== null && value !== undefined) { + return value + } + if (performance.now() > deadline) { + throw new Error(`Timed out after ${timeoutMs} ms waiting for ${what}`) + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } +} + +function waitForStatus( + distro: string, + since: number, + state: S, + timeoutMs: number +): Promise> { + return waitFor( + `${distro} to report ${state} (saw ${JSON.stringify(statusLog(distro).slice(since))})`, + () => + statusLog(distro) + .slice(since) + .find((status): status is Extract => { + return status.state === state + }), + timeoutMs + ) +} + +function pairedConnection(connection: WslOrcadConnection) { + const offer = parsePairingCode(connection.pairingOffer) + if (!offer) { + throw new Error(`Unparseable pairing offer for ${connection.distro}`) + } + return pairedRuntimeConnectionFromOffer(offer) +} + +async function rpc(connection: WslOrcadConnection, method: string, params?: unknown) { + const response = await sendRemoteRuntimeRequest( + pairedConnection(connection), + method, + params, + 30_000, + undefined, + undefined, + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES + ) + if (!response.ok) { + throw new Error(`${method} failed: ${response.error.code}: ${response.error.message}`) + } + return response.result +} + +async function runningDistros(): Promise { + const running = await readConfirmedRunningWslDistros() + if (running.status !== 'confirmed') { + throw new Error('wsl --list --running was unavailable') + } + return running.distros.map(wslDistroHolderKey) +} + +/** Windows-side wsl.exe children still carrying a bridge or a holder for this lane's distros. */ +async function laneWindowsProcesses(): Promise { + const names = DISTROS.map((d) => d.name.toLowerCase()) + return (await readWindowsProcessTableFresh()) + .filter((row) => /^wsl(host)?\.exe$/iu.test(row.name)) + .map((row) => row.command) + .filter((command) => names.some((name) => command.toLowerCase().includes(name))) + .filter( + (command) => + command.includes('orcadStdioBridge') || command.includes(WSL_DISTRO_HOLDER_MARKER) + ) +} + +/** A closed bridge's guest half ends on stdin EOF, a moment after the Windows side. */ +async function expectNoGuestBridges(distro: string): Promise { + const count = async () => + Number.parseInt(await guest(distro, `pgrep -fc '${BRIDGE_PATTERN}' || true`), 10) || 0 + const left = await waitFor( + `bridges in ${distro} to exit`, + async () => ((await count()) === 0 ? 0 : null), + 15_000, + 500 + ).catch(count) + expect(left, `bridges left in ${distro}`).toBe(0) +} + +function createLaneManager(): void { + holder = createWslDistroHolder({ + userDataPath: USER_DATA, + onHolderExit: (distro, exit) => supervisor?.noteHolderExit(distro, exit) + }) + manager = new WslOrcadConnectionManager({ + holder, + onChange: (distro, _connection, status) => { + statusLog(distro).push(status) + console.log(`[lane] ${distro} ${JSON.stringify(status)}`) + } + }) +} + +/** status, repo add on a fresh `git init`, terminal create, and its output read back. */ +async function proveRuntimeRpc(connection: WslOrcadConnection, distro: string): Promise { + const status = await rpc<{ runtimeId?: string }>(connection, 'status.get') + expect(status.runtimeId).toEqual(expect.any(String)) + const repoPath = ( + await guest( + distro, + 'set -e; d="$HOME/wsl-orcad-lane-repo"; rm -rf "$d"; mkdir -p "$d"; cd "$d"; ' + + 'git init -q -b main; git -c user.email=lane@orca.test -c user.name=lane ' + + 'commit -q --allow-empty -m seed; printf %s "$d"' + ) + ).trim() + const added = await rpc<{ repo: { id: string } }>(connection, 'repo.add', { + path: repoPath + }) + expect(added.repo.id).toEqual(expect.any(String)) + const created = await rpc<{ terminal: { handle: string } }>(connection, 'terminal.create', { + worktree: `path:${repoPath}` + }) + const handle = created.terminal.handle + expect(handle).toEqual(expect.any(String)) + const nonce = randomBytes(6).toString('hex') + // printf splits the token so the echoed input line never matches it. + await rpc(connection, 'terminal.send', { + terminal: handle, + text: `printf 'WSL_LANE_%s\\n' ${nonce}`, + enter: true + }) + await waitFor( + `terminal output token in ${distro}`, + async () => { + const read = await rpc<{ terminal: { tail?: unknown[] } }>(connection, 'terminal.read', { + terminal: handle + }) + return (read.terminal.tail ?? []).map(String).join('\n').includes(`WSL_LANE_${nonce}`) + ? true + : null + }, + 60_000, + 500 + ) + return { + runtimeId: status.runtimeId, + repoId: added.repo.id, + terminal: handle + } +} + +async function launchPhase(distro: string, systemd: boolean): Promise { + const result: Receipt = {} + visibleWork = true + if (!supervisor) { + throw new Error('supervisor missing') + } + let started = performance.now() + const connection = await supervisor.connect(distro) + result.connectMs = Math.round(performance.now() - started) + result.orcadVersion = connection.fullVersion + expect(connection.pid).toEqual(expect.any(Number)) + const pid1 = (await guest(distro, 'ps -p 1 -o comm=')).trim() + result.pid1 = pid1 + if (systemd) { + expect(pid1).toBe('systemd') + } else { + expect(pid1).not.toBe('systemd') + } + expect(await runningDistros()).toContain(wslDistroHolderKey(distro)) + result.rpc = await proveRuntimeRpc(connection, distro) + + // orcad killed: the watch sees it, the verdict is orcad-exited, and recovery relaunches it. + let since = statusLog(distro).length + started = performance.now() + await guest(distro, `kill -9 ${connection.pid}; true`) + const recovering = await waitForStatus(distro, since, 'reconnecting', 30_000) + expect(recovering.reason).toBe('orcad-exited') + result.killDetectMs = Math.round(performance.now() - started) + const ready = await waitForStatus(distro, since, 'ready', 180_000) + result.killRecoverMs = Math.round(performance.now() - started) + expect(ready.census).not.toBeNull() + result.census = ready.census + const relaunched = manager.get(distro) + if (!relaunched) { + throw new Error(`${distro} reported ready without a connection`) + } + expect(relaunched.pid).toEqual(expect.any(Number)) + expect(relaunched.pid).not.toBe(connection.pid) + await rpc(relaunched, 'status.get') + + // `wsl --terminate`: nothing user-visible, so the verdict stands and nothing reboots it. + visibleWork = false + since = statusLog(distro).length + started = performance.now() + const terminated = await wslExe(['--terminate', distro]) + expect(terminated.code, terminated.output).toBe(0) + const stopped = await waitForStatus(distro, since, 'distro-stopped', 30_000) + result.terminateDetectMs = Math.round(performance.now() - started) + expect(stopped).toMatchObject({ terminals: 'exited', recovering: false }) + expect(await runningDistros()).not.toContain(wslDistroHolderKey(distro)) + if (systemd) { + await assertWslInteropIntact(PLAIN, `after terminating ${distro}`) + } + + // A user connect is the one path allowed to boot it again. + visibleWork = true + started = performance.now() + const rebooted = await supervisor.connect(distro) + result.rebootConnectMs = Math.round(performance.now() - started) + await rpc(rebooted, 'status.get') + if (systemd) { + await assertWslInteropIntact(distro, `after rebooting ${distro}`) + } + result.orcadPid = rebooted.pid + return result +} + +async function survivePhase(): Promise { + const state: LaunchState = JSON.parse(readFileSync(STATE_FILE, 'utf8')) + const waitMs = state.launchEndedAtMs + HOLDER_SURVIVAL_MS - Date.now() + if (waitMs > 0) { + await new Promise((resolve) => setTimeout(resolve, waitMs)) + } + const secondsSinceLaunchExited = Math.round((Date.now() - state.launchEndedAtMs) / 1000) + receipt.secondsSinceLaunchExited = secondsSinceLaunchExited + // Only the holders keep them up: no other wsl.exe client survived the launch process. + const running = await runningDistros() + for (const { name } of DISTROS) { + expect(running, `${name} Running ${secondsSinceLaunchExited} s later`).toContain( + wslDistroHolderKey(name) + ) + } + const adopted = await holder.adoptHolders() + receipt.adopted = adopted + for (const { name } of DISTROS) { + expect(adopted).toContainEqual({ distro: name, state: 'live' }) + } + for (const { name } of DISTROS) { + const connection = await manager.ensure(name) + await rpc(connection, 'status.get') + receipt[`${name}.census`] = await requestOrcadTerminalCensus(pairedConnection(connection), 0) + } + + // Teardown: bridges closed, holders released, distros terminated. + await manager.dispose() + for (const { name } of DISTROS) { + await expectNoGuestBridges(name) + } + const released: Receipt = {} + receipt.released = released + for (const { name } of DISTROS) { + // A holder caught mid-exit can read `unverifiable` (no command line yet no exit); it must settle. + released[name] = await holder.release(name) + await waitFor( + `the ${name} holder to exit`, + async () => ((await holder.holderState(name)) === 'exited' ? true : null), + 10_000, + 250 + ) + } + // The systemd distro stops while the plain one still runs, so the interop entry is shared. + expect((await wslExe(['--terminate', SYSTEMD])).code).toBe(0) + await assertWslInteropIntact(PLAIN, `after final terminate of ${SYSTEMD}`) + expect((await wslExe(['--terminate', PLAIN])).code).toBe(0) + const leftovers = await waitFor( + 'lane wsl.exe processes to exit', + async () => { + const rows = await laneWindowsProcesses() + return rows.length === 0 ? rows : null + }, + 15_000, + 500 + ).catch(async () => laneWindowsProcesses()) + expect(leftovers, 'wsl.exe bridge/holder processes after teardown').toEqual([]) +} + +describe.runIf(RUN && process.platform === 'win32')('WSL orcad on real WSL2 distros', () => { + beforeAll(async () => { + mkdirSync(USER_DATA, { recursive: true }) + mkdirSync(RECEIPT_DIR, { recursive: true }) + installLaneAppEnvironment() + createLaneManager() + receipt.wslVersion = (await wslExe(['--version'])).output + }) + + afterAll(async () => { + await supervisor?.dispose().catch(() => {}) + await manager?.dispose().catch(() => {}) + writeFileSync(join(RECEIPT_DIR, `${PHASE}.json`), `${JSON.stringify(receipt, null, 2)}\n`) + }) + + it.runIf(PHASE === 'launch')( + 'deploys, serves RPC, recovers a killed orcad and reports a terminated distro', + async () => { + supervisor = createWslOrcadLivenessSupervisor({ + manager, + holder, + hasUserVisibleWork: () => visibleWork + }) + const pids: LaunchState['orcadPids'] = {} + for (const { name, systemd } of DISTROS) { + const result = await launchPhase(name, systemd) + receipt[name] = result + pids[name] = typeof result.orcadPid === 'number' ? result.orcadPid : null + } + // Bridges and watches go; the holders must not. + await supervisor.dispose() + supervisor = null + await manager.dispose() + for (const { name } of DISTROS) { + await expectNoGuestBridges(name) + } + const state: LaunchState = { + launchEndedAtMs: Date.now(), + orcadPids: pids + } + writeFileSync(STATE_FILE, JSON.stringify(state)) + }, + DISTROS.length * DISTRO_TIMEOUT_MS + ) + + it.runIf(PHASE === 'survive')( + 'keeps both distros held after the launching process exited, then tears down cleanly', + survivePhase, + DISTRO_TIMEOUT_MS + ) +})