From db46593f55f5d7543f34383cfb17d2e0feca5acc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:09:13 -0700 Subject: [PATCH] ci: record durable WSL lifecycle operations (#32603) --- .github/workflows/ssh-windows-hosts.yml | 1 + .github/workflows/wsl-orcad-e2e.yml | 35 +++++++---- config/ci/windows-lifecycle-receipt.ps1 | 10 ++++ .../preview-ssh/windows-ssh-capability.ps1 | 12 ++-- .../invoke-wsl-lifecycle-command.ps1 | 39 ++++++++++++ .../test-wsl-lifecycle-command.ps1 | 59 +++++++++++++++++++ 6 files changed, 135 insertions(+), 21 deletions(-) create mode 100644 config/ci/windows-lifecycle-receipt.ps1 create mode 100644 config/ci/wsl-lifecycle/invoke-wsl-lifecycle-command.ps1 create mode 100644 config/ci/wsl-lifecycle/test-wsl-lifecycle-command.ps1 diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index 282dc3785fe..6bcbe30aa08 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -57,6 +57,7 @@ on: - 'tests/e2e/helpers/compiled-orca-cli.ts' - 'tests/e2e/helpers/windows-host-orcad-processes.ts' - 'config/ci/windows-ssh-provider/**' + - 'config/ci/windows-lifecycle-receipt.ps1' - '.github/workflows/ssh-windows-hosts.yml' - '.github/actions/prepare-orcad-prebuilds/**' - '.github/actions/restore-pnpm-verification/**' diff --git a/.github/workflows/wsl-orcad-e2e.yml b/.github/workflows/wsl-orcad-e2e.yml index 41c821c53e0..68f5f6f616b 100644 --- a/.github/workflows/wsl-orcad-e2e.yml +++ b/.github/workflows/wsl-orcad-e2e.yml @@ -35,6 +35,8 @@ on: - '!**/*.test.tsx' - 'src/main/wsl/wsl-orcad-real-distro-lane.test.ts' - '.github/workflows/wsl-orcad-e2e.yml' + - 'config/ci/wsl-lifecycle/**' + - 'config/ci/windows-lifecycle-receipt.ps1' workflow_dispatch: inputs: qualification: @@ -237,12 +239,17 @@ jobs: with: name: wsl-orcad-rootfs-${{ matrix.family }} path: ${{ runner.temp }}/wsl-rootfs + - name: Verify WSL lifecycle receipts + run: ./config/ci/wsl-lifecycle/test-wsl-lifecycle-command.ps1 # Runs beside the dependency install; the lane waits for it below. - name: Import the distros as WSL2 id: import background: true timeout-minutes: 10 run: | + . config/ci/wsl-lifecycle/invoke-wsl-lifecycle-command.ps1 + New-Item -ItemType Directory -Force $env:ORCA_WSL_ORCAD_LANE_RECEIPTS | Out-Null + $lifecycle = Join-Path $env:ORCA_WSL_ORCAD_LANE_RECEIPTS 'import-lifecycle.json' $cache = Join-Path $env:RUNNER_TEMP 'wsl-rootfs' New-Item -ItemType Directory -Force $cache | Out-Null if ('${{ matrix.family != 'ubuntu-24.04' }}' -eq 'true') { @@ -265,10 +272,9 @@ jobs: foreach ($d in $distros) { $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" } + Invoke-WslLifecycleCommand -Receipt $lifecycle -Stage import -Distro $d.Name -Arguments @('--import', $d.Name, (Join-Path $env:RUNNER_TEMP $d.Name), $rootfs, '--version', '2') "$($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" } + if (-not ((Invoke-WslLifecycleCommand -Receipt $lifecycle -Stage verify-version -Distro $d.Name -Arguments @('--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. # The env exports go to the file a login bash reads first; Fedora's skel ships .bash_profile. $setup = "set -e; $install; id orca >/dev/null 2>&1 || useradd -m -s /bin/bash orca; " + @@ -276,16 +282,14 @@ jobs: "rc=/home/orca/.profile; for f in /home/orca/.bash_profile /home/orca/.bash_login; do " + "if [ -f `$f ]; then rc=`$f; break; fi; done; " + "printf 'export ORCA_BACKGROUND_LAUNCH=1\nexport USERPROFILE=`$HOME\nexport APPDATA=`$HOME/.config\nexport LOCALAPPDATA=`$HOME/.local/share\nexport XDG_CONFIG_HOME=`$HOME/.config\nexport XDG_DATA_HOME=`$HOME/.local/share\nexport XDG_CACHE_HOME=`$HOME/.cache\nexport XDG_STATE_HOME=`$HOME/.local/state\nexport CLAUDE_CONFIG_DIR=`$HOME/.claude\nexport CODEX_HOME=`$HOME/.codex\n' >> `$rc" - 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 + Invoke-WslLifecycleCommand -Receipt $lifecycle -Stage provision -Distro $d.Name -Arguments @('-d', $d.Name, '-u', 'root', '--exec', 'sh', '-c', $setup) + Invoke-WslLifecycleCommand -Receipt $lifecycle -Stage terminate -Distro $d.Name -Arguments @('--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" } + Invoke-WslLifecycleCommand -Receipt $lifecycle -Stage first-boot -Distro $d.Name -Arguments @('-d', $d.Name, '--exec', 'sh', '-c', 'echo "user=$(id -un) pid1=$(ps -p 1 -o comm=) kernel=$(uname -r)"') "$($d.Name) boot_ms=$($sw.ElapsedMilliseconds)" - wsl.exe --terminate $d.Name + Invoke-WslLifecycleCommand -Receipt $lifecycle -Stage terminate -Distro $d.Name -Arguments @('--terminate', $d.Name) } - wsl.exe --list --verbose + Invoke-WslLifecycleCommand -Receipt $lifecycle -Stage list -Arguments @('--list', '--verbose') - uses: ./.github/actions/install-node-dependencies with: native-runtime: node @@ -350,9 +354,14 @@ jobs: 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 + . config/ci/wsl-lifecycle/invoke-wsl-lifecycle-command.ps1 + $lifecycle = Join-Path $env:ORCA_WSL_ORCAD_LANE_RECEIPTS 'interop-lifecycle.json' + try { + $state = Invoke-WslLifecycleCommand -Receipt $lifecycle -Stage interop-check -Distro $env:PLAIN_DISTRO -Arguments @('-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" + } finally { + Invoke-WslLifecycleCommand -Receipt $lifecycle -Stage terminate -Distro $env:PLAIN_DISTRO -Arguments @('--terminate', $env:PLAIN_DISTRO) + } if ("$state".Trim() -ne 'present') { throw "WSLInterop was missing ($state)" } - name: Collect orcad logs if: failure() diff --git a/config/ci/windows-lifecycle-receipt.ps1 b/config/ci/windows-lifecycle-receipt.ps1 new file mode 100644 index 00000000000..a81f6ea26dc --- /dev/null +++ b/config/ci/windows-lifecycle-receipt.ps1 @@ -0,0 +1,10 @@ +function Write-WindowsLifecycleReceipt([hashtable]$Report,[string]$Receipt) { + try { + $bytes=[Text.UTF8Encoding]::new($false).GetBytes(($Report | ConvertTo-Json -Depth 6)) + $temporary="$Receipt.pending" + $stream=[IO.FileStream]::new($temporary,[IO.FileMode]::Create,[IO.FileAccess]::Write,[IO.FileShare]::Read) + try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)} finally {$stream.Dispose()} + [IO.File]::Move($temporary,$Receipt,$true) + return $true + } catch {return $false} +} diff --git a/config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 b/config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 index df0bbaa707e..04225ff0a7c 100644 --- a/config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 +++ b/config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 @@ -1,3 +1,5 @@ +. (Join-Path $PSScriptRoot '../../windows-lifecycle-receipt.ps1') + function Assert-IsolatedWindowsSshCi([ValidateSet('arm64','x64')][string]$Arch) { $os=@{arm64='Arm64';x64='X64'}[$Arch] if($env:GITHUB_ACTIONS -ne 'true' -or $env:ORCA_ISOLATED_SSH_CI -ne '1' -or [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -ne $os){throw "Requires isolated native $Arch GitHub runner"} @@ -21,14 +23,8 @@ function Assert-WindowsSshStockShell { function Write-WindowsSshReceiptStage([hashtable]$Report,[string]$Receipt,[string]$Stage) { $timestamp=[DateTime]::UtcNow.ToString('o') $Report.stages+=@{stage=$Stage;utc=$timestamp} - try { - $bytes=[Text.UTF8Encoding]::new($false).GetBytes(($Report | ConvertTo-Json -Depth 6)) - $temporary="$Receipt.pending" - $stream=[IO.FileStream]::new($temporary,[IO.FileMode]::Create,[IO.FileAccess]::Write,[IO.FileShare]::Read) - try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)} finally {$stream.Dispose()} - [IO.File]::Move($temporary,$Receipt,$true) - $written=$true - } catch {$written=$false;Write-Warning 'Progress receipt could not be updated; cleanup must still run'} + $written=Write-WindowsLifecycleReceipt $Report $Receipt + if(-not $written){Write-Warning 'Progress receipt could not be updated; cleanup must still run'} Write-Host "Native SSH stage: $Stage ($timestamp)" return $written } diff --git a/config/ci/wsl-lifecycle/invoke-wsl-lifecycle-command.ps1 b/config/ci/wsl-lifecycle/invoke-wsl-lifecycle-command.ps1 new file mode 100644 index 00000000000..8d3a0a65201 --- /dev/null +++ b/config/ci/wsl-lifecycle/invoke-wsl-lifecycle-command.ps1 @@ -0,0 +1,39 @@ +. (Join-Path $PSScriptRoot '../windows-lifecycle-receipt.ps1') +$script:wslLifecycleReports=@{} + +function Save-WslLifecycleReceipt([hashtable]$Report,[string]$Receipt) { + if([Text.Encoding]::UTF8.GetByteCount(($Report | ConvertTo-Json -Depth 6)) -gt 16384 -or -not (Write-WindowsLifecycleReceipt $Report $Receipt)){ + Write-Warning 'WSL lifecycle receipt unavailable; preserve native command and cleanup behavior' + } +} + +function Invoke-WslLifecycleCommand { + param( + [Parameter(Mandatory=$true)][string]$Receipt, + [Parameter(Mandatory=$true)][ValidatePattern('^[a-z][a-z0-9-]{0,63}$')][string]$Stage, + [ValidateLength(0,128)][string]$Distro='', + [Parameter(Mandatory=$true)][string[]]$Arguments + ) + if(-not $script:wslLifecycleReports.ContainsKey($Receipt)){ + $script:wslLifecycleReports[$Receipt]=@{version=1;sourceSha=$env:GITHUB_SHA;runId=$env:GITHUB_RUN_ID;runAttempt=$env:GITHUB_RUN_ATTEMPT;imageVersion=$env:ImageVersion;operations=@()} + } + $report=$script:wslLifecycleReports[$Receipt] + $operation=@{stage=$Stage;distro=$Distro;status='running';startedAt=[DateTime]::UtcNow.ToString('o');elapsedMs=0;exitCode=$null} + if($report.operations.Count -lt 32){$report.operations+=@($operation)}else{$report.truncated=$true} + Save-WslLifecycleReceipt $report $Receipt + Write-Host "WSL lifecycle: $Stage $Distro started" + $watch=[Diagnostics.Stopwatch]::StartNew() + try { + & wsl.exe @Arguments + $operation.exitCode=$LASTEXITCODE + if($LASTEXITCODE -ne 0){throw "WSL lifecycle $Stage failed for $Distro with exit $LASTEXITCODE"} + $operation.status='passed' + } catch { + $operation.status='failed' + throw + } finally { + $watch.Stop();$operation.elapsedMs=$watch.ElapsedMilliseconds;$operation.finishedAt=[DateTime]::UtcNow.ToString('o') + Save-WslLifecycleReceipt $report $Receipt + Write-Host "WSL lifecycle: $Stage $Distro $($operation.status) $($operation.elapsedMs)ms" + } +} diff --git a/config/ci/wsl-lifecycle/test-wsl-lifecycle-command.ps1 b/config/ci/wsl-lifecycle/test-wsl-lifecycle-command.ps1 new file mode 100644 index 00000000000..9963f72daa9 --- /dev/null +++ b/config/ci/wsl-lifecycle/test-wsl-lifecycle-command.ps1 @@ -0,0 +1,59 @@ +$ErrorActionPreference='Stop' +. (Join-Path $PSScriptRoot 'invoke-wsl-lifecycle-command.ps1') +$root=Join-Path ([IO.Path]::GetTempPath()) ('orca-wsl-lifecycle-'+[Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $root | Out-Null +$script:activeReceipt=$null +$script:observedRunning=$false +$script:failStart=$false +$originalExit=$env:ORCA_WSL_LIFECYCLE_TEST_EXIT + +# Replace WSL servicing only; the real native process proves argv, stdout and exit handling. +function wsl.exe { + param([Parameter(ValueFromRemainingArguments=$true)][string[]]$NativeArguments) + if($script:activeReceipt){ + $before=Get-Content -LiteralPath $script:activeReceipt -Raw | ConvertFrom-Json + if($before.operations[-1].status -ne 'running' -or $before.operations[-1].exitCode -ne $null){throw 'Operation was not durable before native launch'} + $script:observedRunning=$true + } + if($script:failStart){throw 'Injected launch refusal'} + & node -e 'process.stdout.write(JSON.stringify(process.argv.slice(1)));process.exit(Number(process.env.ORCA_WSL_LIFECYCLE_TEST_EXIT||0))' -- @NativeArguments + $global:LASTEXITCODE=$LASTEXITCODE +} + +try { + $receipt=Join-Path $root 'success.json';$script:activeReceipt=$receipt + $arguments=@('--import','name with spaces','quote"inside','literal $HOME','line1'+"`n"+'line2','日本語') + $output=Invoke-WslLifecycleCommand -Receipt $receipt -Stage import -Distro 'orca-lane-plain' -Arguments $arguments + $actual=ConvertFrom-Json $output + if(($actual | ConvertTo-Json -Compress) -ne ($arguments | ConvertTo-Json -Compress)){throw 'Native argv or stdout changed'} + $completed=Get-Content -LiteralPath $receipt -Raw | ConvertFrom-Json + if(-not $script:observedRunning -or $completed.version -ne 1 -or $completed.operations.Count -ne 1 -or $completed.operations[0].status -ne 'passed' -or $completed.operations[0].exitCode -ne 0 -or -not $completed.operations[0].finishedAt -or $completed.operations[0].elapsedMs -lt 0){throw 'Successful lifecycle receipt incomplete'} + if((Get-Content -LiteralPath $receipt -Raw) -match 'literal \$HOME|NativeArguments'){throw 'Receipt exposed command arguments'} + + $receipt=Join-Path $root 'failed.json';$script:activeReceipt=$receipt;$env:ORCA_WSL_LIFECYCLE_TEST_EXIT='7' + $failure=$null + try {Invoke-WslLifecycleCommand -Receipt $receipt -Stage provision -Distro 'orca-lane-plain' -Arguments @('--exec','sh') | Out-Null}catch{$failure=$_} + $failed=Get-Content -LiteralPath $receipt -Raw | ConvertFrom-Json + if(-not $failure -or $failure.Exception.Message -notmatch 'exit 7' -or $failed.operations[0].status -ne 'failed' -or $failed.operations[0].exitCode -ne 7){throw 'Native failure was suppressed or not recorded'} + + $receipt=Join-Path $root 'launch-refused.json';$script:activeReceipt=$receipt;$script:failStart=$true + $failure=$null + try {Invoke-WslLifecycleCommand -Receipt $receipt -Stage terminate -Arguments @('--terminate','orca-lane-plain') | Out-Null}catch{$failure=$_} + $refused=Get-Content -LiteralPath $receipt -Raw | ConvertFrom-Json + if(-not $failure -or $failure.Exception.Message -ne 'Injected launch refusal' -or $refused.operations[0].status -ne 'failed' -or $refused.operations[0].exitCode -ne $null){throw 'Launch refusal was replaced or misreported'} + + $script:activeReceipt=$null;$script:failStart=$false;$env:ORCA_WSL_LIFECYCLE_TEST_EXIT='0' + $missingParent=Join-Path $root 'absent/cleanup.json' + $output=Invoke-WslLifecycleCommand -Receipt $missingParent -Stage terminate -Arguments @('--terminate','orca-lane-plain') -WarningAction SilentlyContinue + if((ConvertFrom-Json $output)[0] -ne '--terminate'){throw 'Receipt failure suppressed cleanup'} + + $receipt=Join-Path $root 'bounded.json' + foreach($iteration in 1..34){Invoke-WslLifecycleCommand -Receipt $receipt -Stage list -Arguments @('--list') | Out-Null} + $bounded=Get-Content -LiteralPath $receipt -Raw | ConvertFrom-Json + if($bounded.operations.Count -ne 32 -or -not $bounded.truncated -or (Get-Item -LiteralPath $receipt).Length -gt 16384){throw 'Lifecycle receipt was not bounded or truncation was hidden'} + Write-Host 'WSL lifecycle receipt checks passed: durable starts, native argv/stdout, exit failures, launch refusal, cleanup on receipt failure, bounded metadata' +} finally { + $env:ORCA_WSL_LIFECYCLE_TEST_EXIT=$originalExit + Remove-Item Function:wsl.exe -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue +}