mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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
This commit is contained in:
@@ -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
|
||||
@@ -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 <this file>
|
||||
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<string, number | null>
|
||||
}
|
||||
type Receipt = Record<string, unknown>
|
||||
|
||||
const receipt: Receipt = { phase: PHASE }
|
||||
const interopChecks: Receipt[] = []
|
||||
receipt.wslInterop = interopChecks
|
||||
const statuses = new Map<string, WslOrcadConnectionStatus[]>()
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<T>(
|
||||
what: string,
|
||||
read: () => T | null | undefined | Promise<T | null | undefined>,
|
||||
timeoutMs: number,
|
||||
intervalMs = 100
|
||||
): Promise<T> {
|
||||
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<S extends WslOrcadConnectionStatus['state']>(
|
||||
distro: string,
|
||||
since: number,
|
||||
state: S,
|
||||
timeoutMs: number
|
||||
): Promise<Extract<WslOrcadConnectionStatus, { state: S }>> {
|
||||
return waitFor(
|
||||
`${distro} to report ${state} (saw ${JSON.stringify(statusLog(distro).slice(since))})`,
|
||||
() =>
|
||||
statusLog(distro)
|
||||
.slice(since)
|
||||
.find((status): status is Extract<WslOrcadConnectionStatus, { state: S }> => {
|
||||
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<T>(connection: WslOrcadConnection, method: string, params?: unknown) {
|
||||
const response = await sendRemoteRuntimeRequest<T>(
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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<Receipt> {
|
||||
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<Receipt> {
|
||||
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<void> {
|
||||
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
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user