diff --git a/.github/actions/prepare-native-runtime/action.yml b/.github/actions/prepare-native-runtime/action.yml index 64712b2aae7..76f9954b7e2 100644 --- a/.github/actions/prepare-native-runtime/action.yml +++ b/.github/actions/prepare-native-runtime/action.yml @@ -54,7 +54,7 @@ runs: env: NATIVE_RUNTIME: ${{ inputs.native-runtime }} NODE_VERSION: ${{ inputs.node-version }} - NATIVE_SOURCE_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.github/actions/prepare-native-runtime/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/scripts/node-pty-job-ownership.cjs', 'config/scripts/windows-pe-machine.cjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', 'config/scripts/windows-process-tree-creation-time.cjs', 'config/scripts/install-electron-package-binary.mjs', 'config/scripts/electron-platform-path.mjs', 'config/scripts/zip-extractor-command.mjs', 'src/shared/zip-extractor-command.ts', 'config/scripts/shared-electron-dist-cache.mjs', 'config/scripts/space-sharing-copy.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json', 'native/windows-registry/index.js', 'native/windows-security/src/addon.cc', 'native/windows-security/binding.gyp', 'native/windows-security/package.json', 'native/windows-security/index.js') }} + NATIVE_SOURCE_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.github/actions/prepare-native-runtime/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/initialize-windows-native-toolchain.mjs', 'config/scripts/capture-windows-native-environment.cmd', 'config/scripts/rebuild-native-deps.mjs', 'config/scripts/node-pty-job-ownership.cjs', 'config/scripts/windows-pe-machine.cjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', 'config/scripts/windows-process-tree-creation-time.cjs', 'config/scripts/install-electron-package-binary.mjs', 'config/scripts/electron-platform-path.mjs', 'config/scripts/zip-extractor-command.mjs', 'src/shared/zip-extractor-command.ts', 'config/scripts/shared-electron-dist-cache.mjs', 'config/scripts/space-sharing-copy.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json', 'native/windows-registry/index.js', 'native/windows-security/src/addon.cc', 'native/windows-security/binding.gyp', 'native/windows-security/package.json', 'native/windows-security/index.js') }} run: | scope="${ImageOS:-$RUNNER_OS}" if [ -r /etc/os-release ]; then @@ -120,4 +120,9 @@ runs: shell: bash env: NATIVE_RUNTIME: ${{ inputs.native-runtime }} - run: node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME" + run: | + if [ "$RUNNER_OS" = Windows ] && [ "$NATIVE_RUNTIME" = node ]; then + node config/scripts/initialize-windows-native-toolchain.mjs + else + node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME" + fi diff --git a/.github/workflows/ci-cache-warmup-platform-jobs.yml b/.github/workflows/ci-cache-warmup-platform-jobs.yml index b4a7be987d0..07fb93918b8 100644 --- a/.github/workflows/ci-cache-warmup-platform-jobs.yml +++ b/.github/workflows/ci-cache-warmup-platform-jobs.yml @@ -54,6 +54,7 @@ jobs: run: node config/scripts/ensure-native-runtime.mjs --check-only - name: Cache Windows CLI launcher + if: runner.arch == 'X64' uses: actions/cache@v5 with: path: | @@ -65,6 +66,7 @@ jobs: key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'native/windows-agent-hook/**', 'config/scripts/build-windows-cli-launcher.mjs', 'config/scripts/build-windows-agent-hook.mjs', 'config/scripts/windows-rust-executable-build.mjs', 'resources/build/icon.ico', 'package.json') }} - name: Build the Windows native executables + if: runner.arch == 'X64' shell: pwsh run: | if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { @@ -76,6 +78,12 @@ jobs: node config/scripts/build-windows-cli-launcher.mjs if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Warm the Windows ARM server launcher + if: runner.arch == 'ARM64' + uses: ./.github/actions/prepare-windows-cli-launcher + with: + arches: arm64 + warm-linux-package-fixtures: concurrency: group: ci-cache-warmup-linux-package-fixtures-${{ github.event.pull_request.number || github.ref }} diff --git a/.github/workflows/ci-cache-warmup-platforms.yml b/.github/workflows/ci-cache-warmup-platforms.yml index 8f49c2a63aa..e8f37a09c78 100644 --- a/.github/workflows/ci-cache-warmup-platforms.yml +++ b/.github/workflows/ci-cache-warmup-platforms.yml @@ -10,6 +10,7 @@ on: - '.github/actions/install-node-dependencies/**' - '.github/actions/restore-pnpm-verification/**' - '.github/actions/prepare-native-runtime/**' + - '.github/actions/prepare-windows-cli-launcher/**' - '.github/actions/prepare-git-compatibility/**' - '.github/actions/prepare-linux-package-fixture/**' - 'config/docker/headless-serve-shutdown/**' @@ -21,6 +22,8 @@ on: - '.npmrc' - '.pnpmfile.cjs' - 'config/scripts/ensure-native-runtime.mjs' + - 'config/scripts/initialize-windows-native-toolchain.mjs' + - 'config/scripts/capture-windows-native-environment.cmd' - 'config/scripts/rebuild-native-deps.mjs' - 'config/scripts/node-pty-job-ownership.cjs' - 'config/scripts/windows-pe-machine.cjs' diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml index b44598f7ac6..8487d95ef34 100644 --- a/.github/workflows/ci-cache-warmup.yml +++ b/.github/workflows/ci-cache-warmup.yml @@ -14,6 +14,7 @@ on: - '.github/actions/install-node-dependencies/**' - '.github/actions/restore-pnpm-verification/**' - '.github/actions/prepare-native-runtime/**' + - '.github/actions/prepare-windows-cli-launcher/**' - '.github/actions/prepare-git-compatibility/**' - '.github/actions/prepare-headless-compiler/**' - 'config/scripts/headless-detector-compiler-cache.mjs' @@ -30,6 +31,8 @@ on: - '.npmrc' - '.pnpmfile.cjs' - 'config/scripts/ensure-native-runtime.mjs' + - 'config/scripts/initialize-windows-native-toolchain.mjs' + - 'config/scripts/capture-windows-native-environment.cmd' - 'config/scripts/rebuild-native-deps.mjs' - 'config/scripts/node-pty-job-ownership.cjs' - 'config/scripts/windows-pe-machine.cjs' diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index 4a3b910c620..4b5bb1ce5f2 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -62,12 +62,23 @@ on: description: Comma-separated cell ids from src/main/ssh/ssh-windows-host-cells.ts; empty runs the default set (orcad-cli-* cells run only when named). required: false default: '' + preparation_mode: + description: Compare inbox capability preparation overlapping builds or running after them. + type: choice + options: [overlap, serial] + required: false + default: overlap + arm_inbox_only: + description: Run only the native ARM inbox lane for a preparation timing comparison. + type: boolean + required: false + default: false permissions: contents: read concurrency: - group: ssh-windows-hosts-${{ github.event.pull_request.number || github.ref }} + group: ssh-windows-hosts-${{ github.event.pull_request.number || github.ref }}${{ github.event_name == 'workflow_dispatch' && inputs.arm_inbox_only && format('-{0}', inputs.preparation_mode || 'overlap') || '' }} cancel-in-progress: true jobs: @@ -77,21 +88,10 @@ jobs: strategy: fail-fast: false matrix: - include: - - arch: x64 - runner: windows-2022 - server: inbox - - arch: x64 - runner: windows-2022 - server: preview - archive: OpenSSH-Win64.zip - - arch: arm64 - runner: windows-11-arm - server: inbox - - arch: arm64 - runner: windows-11-arm - server: preview - archive: OpenSSH-ARM64.zip + include: >- + ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.arm_inbox_only && + '[{"arch":"arm64","runner":"windows-11-arm","server":"inbox"}]' || + '[{"arch":"x64","runner":"windows-2022","server":"inbox"},{"arch":"x64","runner":"windows-2022","server":"preview","archive":"OpenSSH-Win64.zip"},{"arch":"arm64","runner":"windows-11-arm","server":"inbox"},{"arch":"arm64","runner":"windows-11-arm","server":"preview","archive":"OpenSSH-ARM64.zip"}]') }} runs-on: ${{ matrix.runner }} # Installing the inbox capability alone can take several minutes on a fresh image; the CLI matrix # cells launch the e2e app several times each. @@ -99,6 +99,7 @@ jobs: env: ORCA_BACKGROUND_LAUNCH: '1' ORCA_ISOLATED_SSH_CI: '1' + INBOX_PREPARATION_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.preparation_mode == 'serial' && 'serial' || 'overlap' }} steps: - uses: actions/checkout@v6 with: @@ -127,11 +128,11 @@ jobs: background: true shell: pwsh run: | - if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64'){ + if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64' -and $env:INBOX_PREPARATION_MODE -eq 'overlap'){ $receipts=Join-Path $pwd '.build/ssh-windows-host-receipts' New-Item -ItemType Directory -Force -Path $receipts | Out-Null . config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 - Initialize-WindowsInboxSshCapability -Arch '${{ matrix.arch }}' -Receipt (Join-Path $receipts 'inbox-capability-preparation.json') + Initialize-WindowsInboxSshCapability -Arch '${{ matrix.arch }}' -Receipt (Join-Path $receipts 'inbox-capability-preparation.json') -PreparationMode $env:INBOX_PREPARATION_MODE } - uses: ./.github/actions/install-node-dependencies id: dependencies @@ -164,6 +165,14 @@ jobs: pnpm build:orcad-prebuilds --require-slots "win32-${{ matrix.arch }}" node config/scripts/build-orcad-template.mjs --targets "win32-${{ matrix.arch }}" - wait: inbox-capability + - name: Prepare the Windows inbox SSH capability after the builds + if: ${{ matrix.server == 'inbox' && matrix.arch == 'arm64' && env.INBOX_PREPARATION_MODE == 'serial' }} + shell: pwsh + run: | + $receipts=Join-Path $pwd '.build/ssh-windows-host-receipts' + New-Item -ItemType Directory -Force -Path $receipts | Out-Null + . config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 + Initialize-WindowsInboxSshCapability -Arch '${{ matrix.arch }}' -Receipt (Join-Path $receipts 'inbox-capability-preparation.json') -PreparationMode $env:INBOX_PREPARATION_MODE - name: Run the Windows host cells against a private ${{ matrix.server }} sshd shell: pwsh timeout-minutes: ${{ contains(github.event.inputs.cells || '', 'orcad-cli') && 130 || 50 }} diff --git a/.github/workflows/windows-native-preparation-benchmark.yml b/.github/workflows/windows-native-preparation-benchmark.yml new file mode 100644 index 00000000000..477fff42c56 --- /dev/null +++ b/.github/workflows/windows-native-preparation-benchmark.yml @@ -0,0 +1,64 @@ +name: Windows native preparation comparison + +on: + workflow_dispatch: + inputs: + source_sha: + description: Exact commit containing this benchmark and the Windows compiler candidate. + required: true + type: string + +permissions: + contents: read + +concurrency: + group: windows-native-preparation-comparison-${{ github.ref }} + cancel-in-progress: false + +env: + ORCA_BACKGROUND_LAUNCH: '1' + +jobs: + compare: + name: Native preparation comparison (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + fail-fast: false + max-parallel: 2 + matrix: + os: [windows-2022, windows-11-arm] + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.source_sha }} + persist-credentials: false + - name: Record measurement start and validate source + shell: bash + env: + SOURCE_SHA: ${{ inputs.source_sha }} + run: | + [[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]] + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + mkdir -p .build/windows-native-benchmark + node -e 'require("node:fs").writeFileSync(".build/windows-native-benchmark/job-start.json", JSON.stringify({startedAt:new Date().toISOString()}))' + - uses: ./.github/actions/install-node-dependencies + with: + node-version: '24.21.0' + native-runtime: none + cache-pnpm-store: 'false' + cache-pnpm-verification: 'false' + - name: Compare isolated cold and same-build warm preparation + shell: bash + env: + SOURCE_SHA: ${{ inputs.source_sha }} + BENCHMARK_RUNNER: ${{ matrix.os }} + run: node config/scripts/benchmark-windows-native-preparation.mjs + - uses: actions/upload-artifact@v7 + if: always() + with: + name: windows-native-preparation-${{ matrix.os }}-${{ github.run_id }}-${{ github.run_attempt }} + path: .build/windows-native-benchmark/ + include-hidden-files: true + if-no-files-found: error + retention-days: 14 diff --git a/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 b/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 index 5b86447c109..56961bbd78c 100644 --- a/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 +++ b/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 @@ -168,7 +168,7 @@ try { $report.nativeInputs=$verified Write-Stage 'preview-native-input-verification-complete' } else { - Install-WindowsInboxSshCapability $Arch $report {param($stage) Write-Stage $stage} + Install-WindowsInboxSshCapability $Arch $report {param($stage) Write-Stage $stage} ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Receipt))) $verified=@() foreach($binary in @('sshd.exe','ssh.exe','ssh-keygen.exe','sftp.exe','sftp-server.exe')){ $path=Join-Path $sshDir $binary diff --git a/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 b/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 index 088534f7be5..9de1a4765d0 100644 --- a/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 +++ b/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 @@ -38,22 +38,52 @@ try { function Assert-IsolatedWindowsSshCi([string]$Arch){if($script:refuseCapabilityHost){throw 'Injected host refusal'}} function Assert-WindowsSshGlobalServerDormant([string]$Server){$script:globalChecks++;if($script:globalChecks -eq $script:refuseGlobalCheck){throw 'Injected global server refusal'}} function Assert-WindowsSshStockShell {$script:shellChecks++;if($script:shellChecks -eq $script:refuseShellCheck){throw 'Injected shell refusal'}} -function Get-WindowsCapability([switch]$Online,[string]$Name){$script:capabilityQueries++;return @{State=$script:capabilityState}} -function Add-WindowsCapability([switch]$Online,[string]$Name){$script:capabilityAdds++;if($script:failCapabilityInstall){throw 'Injected capability install failure'}} +function Assert-CapabilityCommand([switch]$Online,[string]$Name,[string]$LogPath,[object]$ErrorAction,[string]$Operation){ + if(-not $Online -or $Name -ne 'OpenSSH.Server~~~~0.0.1.0' -or $ErrorAction -ne 'Stop' -or [IO.Path]::GetDirectoryName($LogPath) -ne $script:capabilityLogs -or [IO.Path]::GetFileName($LogPath) -notlike "inbox-capability-$Operation-*.log"){throw 'Capability command changed its online identity, error handling or receipt log boundary'} + $script:capabilityLogPaths.Add($LogPath) + [IO.File]::WriteAllText($LogPath,"$Operation fixture") +} +function Get-WindowsCapability([switch]$Online,[string]$Name,[string]$LogPath,[object]$ErrorAction){ + $script:capabilityQueries++;Assert-CapabilityCommand -Online:$Online -Name $Name -LogPath $LogPath -ErrorAction $ErrorAction -Operation 'query' + if($script:failCapabilityQuery){Write-Error 'Injected capability query failure' -ErrorAction $ErrorAction} + return @{State=$script:capabilityState} +} +function Add-WindowsCapability([switch]$Online,[string]$Name,[string]$LogPath,[object]$ErrorAction){ + $script:capabilityAdds++;Assert-CapabilityCommand -Online:$Online -Name $Name -LogPath $LogPath -ErrorAction $ErrorAction -Operation 'install' + if($script:activeCapabilityReceipt){ + $script:installObservedReceipt=Get-Content -LiteralPath $script:activeCapabilityReceipt -Raw | ConvertFrom-Json + if($script:installObservedReceipt.inboxCapabilityInitialState -ne 'NotPresent' -or $script:installObservedReceipt.inboxCapabilityOperations[0].status -ne 'passed' -or $script:installObservedReceipt.inboxCapabilityOperations[1].status -ne 'running'){throw 'Initial capability state was not durably recorded before installation'} + } + if($script:failCapabilityInstall){Write-Error 'Injected capability install failure' -ErrorAction $ErrorAction} + return @{Path='C:\';Online=$true;RestartNeeded=$script:capabilityRestartNeeded} +} function Reset-CapabilityControl([string]$State){ $script:capabilityState=$State;$script:capabilityQueries=0;$script:capabilityAdds=0 $script:globalChecks=0;$script:shellChecks=0;$script:refuseGlobalCheck=0;$script:refuseShellCheck=0 - $script:refuseCapabilityHost=$false;$script:failCapabilityInstall=$false + $script:refuseCapabilityHost=$false;$script:failCapabilityInstall=$false;$script:failCapabilityQuery=$false + $script:activeCapabilityReceipt=$null;$script:installObservedReceipt=$null;$script:capabilityRestartNeeded=$false $script:capabilityStages=[Collections.Generic.List[string]]::new() + $script:capabilityLogPaths=[Collections.Generic.List[string]]::new() } +$script:capabilityLogs=Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString('N')) +try { foreach($state in @('Installed','NotPresent')){ Reset-CapabilityControl $state $capabilityReport=@{} - Install-WindowsInboxSshCapability 'x64' $capabilityReport {param($name) $script:capabilityStages.Add($name)} + Install-WindowsInboxSshCapability 'x64' $capabilityReport {param($name) $script:capabilityStages.Add($name)} $script:capabilityLogs $expectedAdds=if($state -eq 'Installed'){0}else{1} - if($capabilityReport.inboxCapabilityInitialState -ne $state -or $script:capabilityAdds -ne $expectedAdds -or $script:globalChecks -ne 2 -or $script:shellChecks -ne 2 -or ($script:capabilityStages -join ',') -ne 'inbox-capability-start,inbox-capability-complete'){throw 'Shared capability preparation did not preserve the install and guard boundaries'} + $expectedStages=@('inbox-capability-start','inbox-capability-query-start','inbox-capability-query-finished') + if($expectedAdds){$expectedStages+=@('inbox-capability-install-start','inbox-capability-install-finished')} + $expectedStages+=@('inbox-capability-guards-start','inbox-capability-complete') + if($capabilityReport.inboxCapabilityInitialState -ne $state -or $script:capabilityQueries -ne 1 -or $script:capabilityAdds -ne $expectedAdds -or $script:globalChecks -ne 2 -or $script:shellChecks -ne 2 -or ($script:capabilityStages -join ',') -ne ($expectedStages -join ',')){throw 'Shared capability preparation did not preserve the install and guard boundaries'} + if($capabilityReport.inboxCapabilityOperations.Count -ne 1+$expectedAdds){throw 'Capability operations missing'} + foreach($operation in $capabilityReport.inboxCapabilityOperations){ + if($operation.status -ne 'passed' -or $operation.elapsedMs -isnot [long] -or $operation.elapsedMs -lt 0 -or -not [IO.File]::Exists((Join-Path $script:capabilityLogs $operation.log))){throw 'Capability operation did not retain its timing, verdict or diagnostic log'} + } + if($expectedAdds -and ($capabilityReport.inboxCapabilityInstallResult.online -ne $true -or $capabilityReport.inboxCapabilityInstallResult.path -ne 'C:\' -or $capabilityReport.inboxCapabilityInstallResult.restartNeeded -ne $false)){throw 'Capability install result omitted'} + if(-not $expectedAdds -and $capabilityReport.ContainsKey('inboxCapabilityInstallResult')){throw 'Installed capability invented an install result'} } -foreach($fault in @('host','global-before','shell-before','global-after','shell-after','install')){ +foreach($fault in @('host','global-before','shell-before','global-after','shell-after','query','install')){ Reset-CapabilityControl 'NotPresent' switch($fault){ 'host' {$script:refuseCapabilityHost=$true} @@ -61,26 +91,45 @@ foreach($fault in @('host','global-before','shell-before','global-after','shell- 'shell-before' {$script:refuseShellCheck=1} 'global-after' {$script:refuseGlobalCheck=2} 'shell-after' {$script:refuseShellCheck=2} + 'query' {$script:failCapabilityQuery=$true} 'install' {$script:failCapabilityInstall=$true} } $rejected=$false - try {Install-WindowsInboxSshCapability 'x64' @{} {param($name) $script:capabilityStages.Add($name)}} catch {$rejected=$true} + $faultReport=@{} + try {Install-WindowsInboxSshCapability 'x64' $faultReport {param($name) $script:capabilityStages.Add($name)} $script:capabilityLogs} catch {$rejected=$true} if(-not $rejected -or $script:capabilityStages.Contains('inbox-capability-complete')){throw "Capability fault did not fail closed: $fault"} if($fault -in @('host','global-before','shell-before') -and $script:capabilityAdds){throw 'Capability mutation preceded its host guards'} + if($fault -eq 'query' -and $script:capabilityAdds){throw 'Failed query proceeded to installation'} + if($fault -in @('query','install') -and ($faultReport.inboxCapabilityOperations[-1].status -ne 'failed' -or $faultReport.inboxCapabilityOperations[-1].elapsedMs -isnot [long])){throw 'Failed servicing operation lost its verdict or duration'} } +Reset-CapabilityControl 'NotPresent' +foreach($invocation in 1..2){Install-WindowsInboxSshCapability 'x64' @{} {param($name) $script:capabilityStages.Add($name)} $script:capabilityLogs} +if($script:capabilityLogPaths.Count -ne 4 -or ($script:capabilityLogPaths | Select-Object -Unique).Count -ne 4){throw 'Separate servicing operations or invocations reused a diagnostic log'} +} finally {Remove-Item -LiteralPath $script:capabilityLogs -Recurse -Force -ErrorAction SilentlyContinue} $capabilityRoot=Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString('N')) try { New-Item -ItemType Directory -Path $capabilityRoot | Out-Null $capabilityReceipt=Join-Path $capabilityRoot 'capability.json' + $script:capabilityLogs=$capabilityRoot Reset-CapabilityControl 'Installed' Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt $completed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json - if($completed.status -ne 'passed' -or $completed.inboxCapabilityInitialState -ne 'Installed'){throw 'Capability success receipt missing its observed initial state'} - Reset-CapabilityControl 'NotPresent';$script:failCapabilityInstall=$true + if($completed.status -ne 'passed' -or $completed.inboxCapabilityInitialState -ne 'Installed' -or $completed.preparationMode -ne 'overlap'){throw 'Capability success receipt missing its observed initial state or default mode'} + Reset-CapabilityControl 'NotPresent';$script:capabilityLogs=$capabilityRoot;$script:activeCapabilityReceipt=$capabilityReceipt;$script:capabilityRestartNeeded=$true + Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt 'serial' + $installed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json + if($installed.status -ne 'passed' -or $installed.preparationMode -ne 'serial' -or $installed.inboxCapabilityInstallResult.restartNeeded -ne $true -or -not $script:installObservedReceipt){throw 'Serial preparation lost its mode, restart result or pre-install receipt'} + Reset-CapabilityControl 'NotPresent';$script:activeCapabilityReceipt=$capabilityReceipt;$script:failCapabilityInstall=$true $rejected=$false try {Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt} catch {$rejected=$true} $failed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json if(-not $rejected -or $failed.status -ne 'failed' -or $failed.inboxCapabilityInitialState -ne 'NotPresent' -or $failed.error -ne 'Injected capability install failure'){throw 'Failed capability preparation masqueraded as a passing receipt'} + if($failed.inboxCapabilityOperations[-1].status -ne 'failed' -or -not $failed.stages.stage.Contains('inbox-capability-install-finished')){throw 'Failed preparation omitted its servicing result'} + Reset-CapabilityControl 'NotPresent';$script:failCapabilityQuery=$true + $rejected=$false + try {Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt} catch {$rejected=$true} + $failedQuery=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json + if(-not $rejected -or $failedQuery.status -ne 'failed' -or $failedQuery.inboxCapabilityOperations[0].status -ne 'failed' -or $failedQuery.inboxCapabilityOperations.Count -ne 1 -or $script:capabilityAdds -or $failedQuery.inboxCapabilityInitialState){throw 'Failed query receipt invented a capability state or proceeded to installation'} } finally {Remove-Item -LiteralPath $capabilityRoot -Recurse -Force -ErrorAction SilentlyContinue} & { param($ProofAst) 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 d2bdc9096a3..df0bbaa707e 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 @@ -33,26 +33,48 @@ function Write-WindowsSshReceiptStage([hashtable]$Report,[string]$Receipt,[strin return $written } -function Install-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[hashtable]$Report,[scriptblock]$Stage) { +function Install-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[hashtable]$Report,[scriptblock]$Stage,[Parameter(Mandatory=$true)][string]$LogDirectory) { Assert-IsolatedWindowsSshCi $Arch Assert-WindowsSshGlobalServerDormant 'inbox' Assert-WindowsSshStockShell & $Stage 'inbox-capability-start' - $capability=Get-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' - $Report.inboxCapabilityInitialState=[string]$capability.State - if($capability.State -ne 'Installed'){Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' | Out-Null} + New-Item -ItemType Directory -Force -Path $LogDirectory | Out-Null + $invocation=[Guid]::NewGuid().ToString('N') + $query=@{operation='query';status='running';log="inbox-capability-query-$invocation.log"} + $Report.inboxCapabilityOperations=@($query) + & $Stage 'inbox-capability-query-start' + $watch=[Diagnostics.Stopwatch]::StartNew() + try { + $capability=Get-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' -LogPath (Join-Path $LogDirectory $query.log) -ErrorAction Stop + $Report.inboxCapabilityInitialState=[string]$capability.State + $query.status='passed' + } catch {$query.status='failed';$query.error=$_.Exception.Message;throw} + finally {$watch.Stop();$query.elapsedMs=$watch.ElapsedMilliseconds;& $Stage 'inbox-capability-query-finished'} + if($capability.State -ne 'Installed'){ + $install=@{operation='install';status='running';log="inbox-capability-install-$invocation.log"} + $Report.inboxCapabilityOperations+=@($install) + & $Stage 'inbox-capability-install-start' + $watch=[Diagnostics.Stopwatch]::StartNew() + try { + $result=Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' -LogPath (Join-Path $LogDirectory $install.log) -ErrorAction Stop + $Report.inboxCapabilityInstallResult=@{path=$result.Path;online=$result.Online;restartNeeded=$result.RestartNeeded} + $install.status='passed' + } catch {$install.status='failed';$install.error=$_.Exception.Message;throw} + finally {$watch.Stop();$install.elapsedMs=$watch.ElapsedMilliseconds;& $Stage 'inbox-capability-install-finished'} + } + & $Stage 'inbox-capability-guards-start' Assert-WindowsSshGlobalServerDormant 'inbox' Assert-WindowsSshStockShell & $Stage 'inbox-capability-complete' } -function Initialize-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[string]$Receipt) { - $report=@{scope='Windows inbox OpenSSH.Server capability preparation only';arch=$Arch;sourceSha=$env:GITHUB_SHA;runId=$env:GITHUB_RUN_ID;runAttempt=$env:GITHUB_RUN_ATTEMPT;runnerName=$env:RUNNER_NAME;imageVersion=$env:ImageVersion;status='running';stages=@();globalBootstrapCleanup='Disposable CI VM destruction is the boundary; no global sshd is started'} +function Initialize-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[string]$Receipt,[ValidateSet('overlap','serial')][string]$PreparationMode='overlap') { + $report=@{scope='Windows inbox OpenSSH.Server capability preparation only';arch=$Arch;sourceSha=$env:GITHUB_SHA;runId=$env:GITHUB_RUN_ID;runAttempt=$env:GITHUB_RUN_ATTEMPT;runnerName=$env:RUNNER_NAME;imageVersion=$env:ImageVersion;preparationMode=$PreparationMode;status='running';stages=@();globalBootstrapCleanup='Disposable CI VM destruction is the boundary; no global sshd is started'} try { if(-not (Write-WindowsSshReceiptStage $report $Receipt 'preparation-start')){throw 'Initial progress receipt unavailable; refuse provisioning'} Install-WindowsInboxSshCapability $Arch $report {param($name) if(-not (Write-WindowsSshReceiptStage $report $Receipt $name)){throw 'Capability preparation progress receipt unavailable'} - } + } ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Receipt))) $report.status='passed' } catch {$report.status='failed';$report.error=$_.Exception.Message;throw} finally { diff --git a/config/scripts/benchmark-windows-native-preparation.mjs b/config/scripts/benchmark-windows-native-preparation.mjs new file mode 100644 index 00000000000..336e6fbbd26 --- /dev/null +++ b/config/scripts/benchmark-windows-native-preparation.mjs @@ -0,0 +1,518 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, relative, resolve, sep } from 'node:path' +import { performance } from 'node:perf_hooks' +import { pathToFileURL } from 'node:url' +import { runProcess } from '@orca/process-host' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' +import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' +import { hasWindowsCompilerSelection } from './initialize-windows-native-toolchain.mjs' +import { + compilerEnvironmentKey, + environmentReceipt, + hash +} from './windows-native-benchmark-observer.mjs' + +const addonNames = [ + 'node-pty', + '@orca/windows-registry', + '@orca/windows-security', + '@vscode/windows-process-tree' +] +const candidate = 'initialize-windows-native-toolchain.mjs' +const control = 'ensure-native-runtime.mjs' +const unchangedFiles = [ + `.github/actions/prepare-native-runtime/action.yml`, + `config/scripts/${control}`, + `config/scripts/${candidate}`, + 'config/scripts/capture-windows-native-environment.cmd', + 'config/scripts/windows-native-benchmark-observer.mjs', + 'config/scripts/benchmark-windows-native-preparation.mjs' +] + +export function validateObservedPreparation(receipt, variant, warm = false) { + assert.equal( + receipt.parentBefore.hash, + receipt.parentAfter.hash, + 'Outer compiler environment changed' + ) + const calls = (phase) => receipt.calls.filter((call) => call.phase === phase) + const captured = variant !== 'control' && !warm + assert.equal(calls('discovery').length, captured ? 1 : 0, 'Unexpected compiler discovery count') + assert.equal(calls('capture').length, captured ? 1 : 0, 'Unexpected developer command count') + assert.equal( + calls('probe').length, + variant === 'control' ? 0 : 1, + 'Missing helper preflight observation' + ) + for (const call of receipt.calls) { + assert( + !call.error && !call.timedOut && !call.outputTruncated, + 'Incomplete compiler observation' + ) + } + if (variant === 'control') { + assert.equal(calls('addon-build').length, warm ? 0 : 4, 'Expected all four cold addon builds') + assert( + receipt.calls.every((call) => call.code === 0), + 'Control preparation failed' + ) + return + } + const [prepare] = calls('preparation') + assert.equal(calls('preparation').length, 1, 'Missing unchanged preparation child') + assert.equal(prepare.code, 0, 'Native preparation failed') + assert.equal(calls('probe')[0].code, warm ? 0 : 1, 'Unexpected helper cold/warm probe') + if (warm || variant === 'fallback') { + assert.equal( + prepare.environment.hash, + receipt.parentBefore.hash, + 'Fallback/warm changed compiler selection' + ) + assert.deepEqual(prepare.changedNames, [], 'Fallback/warm altered child environment') + } else { + assert.equal(calls('discovery')[0].code, 0) + assert.equal(calls('capture')[0].code, 0) + assert(prepare.changedNames.length > 0, 'Captured environment was not applied') + assert( + prepare.changedNames.every((name) => compilerEnvironmentKey.test(name)), + 'Unrelated environment changed' + ) + assert(prepare.environment.compiler.vcinstalldir, 'Prepared child lacks compiler selection') + } + if (variant === 'fallback' && !warm) { + assert.equal(calls('discovery')[0].code, 0) + assert.equal(calls('capture')[0].code, 7, 'Fixed failure fixture did not run') + } +} + +export function assertNativeColdProbe(result) { + assert.equal(result.code, 1, 'Cold fixture unexpectedly passed') + for (const name of addonNames) { + assert(result.stderr.includes(`${name}:`), `Cold fixture did not fail ${name}`) + } +} + +export function assertNoHeaderDownloads(result) { + assert( + !/gyp http (GET|200|download)/i.test(`${result.stdout}\n${result.stderr}`), + 'Timed preparation downloaded headers/libraries' + ) +} + +function digestFiles(root, paths) { + return Object.fromEntries( + paths + .filter((path) => existsSync(join(root, path))) + .map((path) => [path, hash(readFileSync(join(root, path)))]) + ) +} + +function treeDigest(root) { + const digest = createHash('sha256') + const files = [] + const visit = (path) => { + for (const name of readdirSync(path).sort()) { + const child = join(path, name) + if (statSync(child).isDirectory()) { + visit(child) + } else { + files.push(child) + } + } + } + visit(root) + for (const file of files) { + digest.update(relative(root, file)).update('\0').update(readFileSync(file)) + } + return { sha256: digest.digest('hex'), files: files.length } +} + +function nativeDirectories(snapshot) { + const require = createRequire(join(snapshot, 'package.json')) + const root = realpathSync(snapshot) + return addonNames.map((name) => { + const directory = realpathSync(dirname(require.resolve(`${name}/package.json`))) + assert(directory.startsWith(`${root}${sep}`), `Addon escapes its isolated copy: ${name}`) + return { name, directory } + }) +} + +function binaryReceipt(snapshot) { + const require = createRequire(join(snapshot, 'package.json')) + const paths = [ + join(dirname(require.resolve('node-pty/package.json')), 'build/Release/conpty.node'), + join(dirname(require.resolve('node-pty/package.json')), 'build/Release/conpty/conpty.dll'), + join(dirname(require.resolve('node-pty/package.json')), 'build/Release/conpty/OpenConsole.exe'), + join(snapshot, 'native/windows-registry/build/Release/orca_windows_registry.node'), + join(snapshot, 'native/windows-security/build/Release/orca_windows_security.node'), + join( + dirname(require.resolve('@vscode/windows-process-tree/package.json')), + 'build/Release/windows_process_tree.node' + ) + ] + return paths.map((path) => { + assert(existsSync(path), `Missing prepared artifact: ${path}`) + return { + path: relative(snapshot, path), + bytes: statSync(path).size, + sha256: hash(readFileSync(path)) + } + }) +} + +export async function benchmarkWindowsNativePreparation( + root = resolve(import.meta.dirname, '../..') +) { + assert.equal(process.platform, 'win32', 'This hosted comparison requires Windows') + assert(['x64', 'arm64'].includes(process.arch), 'Unsupported Windows runner') + assert.equal(process.versions.node, '24.21.0', 'Comparison requires pinned Node') + assert( + !hasWindowsCompilerSelection(process.env), + 'Explicit compiler selection prevents a qualified automatic-capture comparison' + ) + assert(/^[0-9a-f]{40}$/.test(process.env.SOURCE_SHA ?? ''), 'SOURCE_SHA must be an exact commit') + const output = join(root, '.build/windows-native-benchmark') + mkdirSync(output, { recursive: true }) + const temporary = mkdtempSync( + join(process.env.RUNNER_TEMP || tmpdir(), 'orca-native-comparison-') + ) + const archive = join(temporary, 'source.tar') + const headers = join(temporary, 'headers') + const pnpm = resolvePnpmCliInvocation() + const environment = { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + npm_config_devdir: headers, + npm_package_config_node_gyp_devdir: headers + } + const parentBefore = environmentReceipt(process.env) + const githubEnvironmentBefore = existsSync(process.env.GITHUB_ENV ?? '') + ? hash(readFileSync(process.env.GITHUB_ENV)) + : null + const report = { + schemaVersion: 1, + startedAt: new Date().toISOString(), + qualification: 'incomplete', + firstRecordedJobStep: JSON.parse(readFileSync(join(output, 'job-start.json'), 'utf8')), + phases: [], + samples: [], + scope: { + warm: 'Same-build warm preparation; no Actions native-cache restore/save is performed.', + metric: + 'Cold addon preparation with identical diagnostic preload; installs, header downloads, standalone qualification probes and artifact upload are recorded separately; the helper preflight remains included.', + occupancy: + 'Use complete hosted job started_at/completed_at, including setup and upload/post steps, for actual runner occupancy. This artifact only measures its explicit window.', + excluded: + 'No Node-to-Electron, cross-target template, exact-key restore, or CI workload gain is claimed.' + } + } + const writeReport = () => + writeFileSync(join(output, 'receipt.json'), `${JSON.stringify(report, null, 2)}\n`) + const command = async (label, program, args, cwd = root, env = environment, accepted = [0]) => { + const startedAt = new Date().toISOString() + const start = performance.now() + const result = await runProcess({ + program, + args, + cwd, + env, + timeoutMs: 1_200_000, + maxOutputBytes: 64 * 1024 * 1024, + terminationBarrier: true + }) + const elapsedMs = performance.now() - start + const stdoutFile = `${label}.stdout.log` + const stderrFile = `${label}.stderr.log` + writeFileSync(join(output, stdoutFile), result.stdout) + writeFileSync(join(output, stderrFile), result.stderr) + report.phases.push({ + label, + program, + args, + startedAt, + finishedAt: new Date().toISOString(), + durationMs: elapsedMs, + code: result.code, + signal: result.signal, + timedOut: result.timedOut, + outputTruncated: result.outputTruncated, + stdoutFile, + stderrFile, + stdoutSha256: hash(result.stdout), + stderrSha256: hash(result.stderr) + }) + writeReport() + assert( + !result.timedOut && + !result.signal && + !result.outputTruncated && + accepted.includes(result.code), + `${label} failed: ${result.stderr.slice(-4000)}` + ) + return { ...result, durationMs: elapsedMs } + } + const pnpmCommand = (label, args, cwd) => + command(label, pnpm.command, [...pnpm.prefixArgs, ...args], cwd) + try { + const head = (await command('source-head', 'git', ['rev-parse', 'HEAD'])).stdout.trim() + assert.equal(head, process.env.SOURCE_SHA, 'Checkout differs from frozen source') + await command('source-clean', 'git', ['diff', '--exit-code', 'HEAD', '--']) + const tree = (await command('source-tree', 'git', ['rev-parse', 'HEAD^{tree}'])).stdout.trim() + const pnpmVersion = (await pnpmCommand('pnpm-version', ['--version'], root)).stdout.trim() + assert.equal(pnpmVersion, '12.8.1', 'Comparison requires pinned pnpm') + const action = readFileSync( + join(root, '.github/actions/prepare-native-runtime/action.yml'), + 'utf8' + ) + const hashExpression = action.match(/NATIVE_SOURCE_HASH: (.+)/)?.[1] + assert(hashExpression, 'Missing production source identity') + const inputs = [...hashExpression.matchAll(/'([^']+)'/g)].map((match) => match[1]) + assert( + inputs.includes(`config/scripts/${candidate}`), + 'Frozen source lacks the candidate native-cache fingerprint' + ) + const requiredScriptHashes = digestFiles(root, unchangedFiles) + assert.equal( + Object.keys(requiredScriptHashes).length, + unchangedFiles.length, + 'Frozen source lacks required scripts' + ) + const sourceInputs = digestFiles(root, inputs) + report.identity = { + sourceSha: head, + tree, + runtime: 'node', + node: process.versions.node, + nodeAbi: process.versions.modules, + arch: process.arch, + pnpm: pnpmVersion, + runner: process.env.BENCHMARK_RUNNER, + imageOS: process.env.ImageOS, + imageVersion: process.env.ImageVersion, + workflowRef: process.env.GITHUB_WORKFLOW_REF, + workflowSha: process.env.GITHUB_WORKFLOW_SHA, + runId: process.env.GITHUB_RUN_ID, + runAttempt: process.env.GITHUB_RUN_ATTEMPT, + sourceInputs, + requiredScriptHashes, + sourceInputDigest: hash(JSON.stringify(sourceInputs)), + productionHashExpression: hashExpression, + parentCompilerEnvironment: parentBefore.compiler + } + await command('archive-source', 'git', ['archive', '--format=tar', '--output', archive, 'HEAD']) + report.identity.archiveSha256 = hash(readFileSync(archive)) + const require = createRequire(join(root, 'package.json')) + const nodeGyp = process.env.npm_config_node_gyp || require.resolve('node-gyp/bin/node-gyp.js') + report.identity.nodeGyp = JSON.parse( + readFileSync(resolve(dirname(nodeGyp), '..', 'package.json'), 'utf8') + ).version + report.identity.nodeGypEntrySha256 = hash(readFileSync(nodeGyp)) + await command('download-headers-before-measurement', process.execPath, [ + nodeGyp, + 'install', + `--target=${process.versions.node}`, + `--arch=${process.arch}`, + `--devdir=${headers}` + ]) + const headersBefore = treeDigest(headers) + report.identity.headers = headersBefore + const physicalDirectories = new Set() + const schedule = buildCounterbalancedSchedule(2, 'control', 'candidate').flat() + report.schedule = schedule + for (const [index, variant] of [...schedule, 'fallback'].entries()) { + const label = `sample-${String(index + 1).padStart(2, '0')}` + const snapshot = join(temporary, label) + mkdirSync(snapshot) + await command(`${label}-extract`, 'tar', ['-xf', archive, '-C', snapshot]) + await pnpmCommand( + `${label}-install`, + [ + 'install', + '--offline', + '--frozen-lockfile', + '--ignore-scripts', + '--package-import-method=copy', + '--side-effects-cache=false' + ], + snapshot + ) + await pnpmCommand(`${label}-internal-packages`, ['run', 'build:packages'], snapshot) + const directories = nativeDirectories(snapshot) + for (const { directory } of directories) { + assert( + !physicalDirectories.has(directory.toLowerCase()), + 'Native copies share a build directory' + ) + physicalDirectories.add(directory.toLowerCase()) + rmSync(join(directory, 'build'), { recursive: true, force: true }) + } + const initial = await command( + `${label}-cold-probe`, + process.execPath, + [join(snapshot, 'config/scripts', control), '--check-only'], + snapshot, + environment, + [1] + ) + assertNativeColdProbe(initial) + const snapshotScriptHashes = digestFiles(snapshot, unchangedFiles) + assert.deepEqual( + snapshotScriptHashes, + requiredScriptHashes, + 'Ordinary snapshot source/config drift' + ) + const originalCapture = readFileSync( + join(snapshot, 'config/scripts/capture-windows-native-environment.cmd') + ) + if (variant === 'fallback') { + writeFileSync( + join(snapshot, 'config/scripts/capture-windows-native-environment.cmd'), + '@echo off\r\nexit /b 7\r\n' + ) + } + const sample = { + label, + variant, + sourceHashesBeforeInjection: snapshotScriptHashes, + originalCaptureSha256: hash(originalCapture), + addonDirectories: directories, + measurements: [] + } + report.samples.push(sample) + for (const warm of [false, true]) { + const stage = `${label}-${warm ? 'warm' : 'cold'}` + const traceFile = join(output, `${stage}.trace.json`) + const script = join(snapshot, 'config/scripts', variant === 'control' ? control : candidate) + const args = [ + '--import', + pathToFileURL(join(snapshot, 'config/scripts/windows-native-benchmark-observer.mjs')) + .href, + script, + ...(variant === 'control' ? ['--runtime=node'] : []) + ] + const result = await command(stage, process.execPath, args, snapshot, { + ...environment, + ORCA_NATIVE_BENCHMARK_TRACE: traceFile + }) + assertNoHeaderDownloads(result) + const receipt = JSON.parse(readFileSync(traceFile, 'utf8')) + validateObservedPreparation(receipt, variant, warm) + if (variant === 'fallback' && !warm) { + assert(result.stderr.includes('retaining node-gyp discovery'), 'Fallback warning missing') + } + const final = await command( + `${stage}-final-probe`, + process.execPath, + [join(snapshot, 'config/scripts', control), '--check-only'], + snapshot + ) + assert.equal(final.code, 0) + const calls = receipt.calls + const milliseconds = (phase) => + calls + .filter((call) => call.phase === phase) + .reduce((total, call) => total + call.durationMs, 0) + sample.measurements.push({ + warm, + totalPreparationMs: result.durationMs, + helperDiscoveryMs: milliseconds('discovery'), + helperCaptureMs: milliseconds('capture'), + helperPreprobeMs: milliseconds('probe'), + unchangedPreparationChildMs: variant === 'control' ? null : milliseconds('preparation'), + helperOutsidePreparationChildMs: + variant === 'control' ? null : result.durationMs - milliseconds('preparation'), + traceFile: relative(output, traceFile), + traceSha256: hash(readFileSync(traceFile)), + binaries: binaryReceipt(snapshot) + }) + assert.deepEqual( + treeDigest(headers), + headersBefore, + 'Timed preparation changed prefilled headers/libraries' + ) + assert.equal( + environmentReceipt(process.env).hash, + parentBefore.hash, + 'Parent environment changed' + ) + assert.equal( + existsSync(process.env.GITHUB_ENV ?? '') + ? hash(readFileSync(process.env.GITHUB_ENV)) + : null, + githubEnvironmentBefore, + 'Compiler setup wrote GITHUB_ENV' + ) + writeReport() + } + sample.isolatedCopyWindowMs = report.phases + .filter((phase) => phase.label.startsWith(`${label}-`)) + .reduce((total, phase) => total + phase.durationMs, 0) + sample.injectedCaptureSha256 = + variant === 'fallback' + ? hash( + readFileSync(join(snapshot, 'config/scripts/capture-windows-native-environment.cmd')) + ) + : null + } + report.comparison = Object.fromEntries( + [false, true].map((warm) => { + const timings = (variant) => + report.samples + .filter((sample) => sample.variant === variant) + .map( + (sample) => + sample.measurements.find((measurement) => measurement.warm === warm) + .totalPreparationMs + ) + const baseline = timings('control') + const proposed = timings('candidate') + return [ + warm ? 'sameBuildWarm' : 'cold', + { + control: summarizeBenchmarkSamples(baseline), + candidate: summarizeBenchmarkSamples(proposed), + pairDeltaMs: baseline.map((value, index) => proposed[index] - value), + role: 'Two counterbalanced pairs are descriptive; no significance or ordinary CI hit-rate estimate.' + } + ] + }) + ) + report.parentEnvironment = { + before: parentBefore, + after: environmentReceipt(process.env), + githubEnvironmentBefore, + githubEnvironmentAfter: githubEnvironmentBefore + } + report.qualification = 'passed' + } catch (error) { + report.failure = error.message + report.qualification = 'failed' + throw error + } finally { + rmSync(temporary, { recursive: true, force: true }) + report.finishedAt = new Date().toISOString() + report.explicitWindowMs = Date.parse(report.finishedAt) - Date.parse(report.startedAt) + report.explicitJobWindowMs = + Date.parse(report.finishedAt) - Date.parse(report.firstRecordedJobStep.startedAt) + writeReport() + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await benchmarkWindowsNativePreparation() +} diff --git a/config/scripts/capture-windows-native-environment.cmd b/config/scripts/capture-windows-native-environment.cmd new file mode 100644 index 00000000000..52dab775b3f --- /dev/null +++ b/config/scripts/capture-windows-native-environment.cmd @@ -0,0 +1,6 @@ +@echo off +chcp 65001 >nul +call "%ORCA_WINDOWS_VSDEVCMD%" -no_logo -arch=%ORCA_WINDOWS_NATIVE_ARCH% -host_arch=%ORCA_WINDOWS_NATIVE_ARCH% +if errorlevel 1 exit /b 1 +echo ORCA_WINDOWS_NATIVE_ENVIRONMENT +set diff --git a/config/scripts/initialize-windows-native-toolchain.mjs b/config/scripts/initialize-windows-native-toolchain.mjs new file mode 100644 index 00000000000..86be338d549 --- /dev/null +++ b/config/scripts/initialize-windows-native-toolchain.mjs @@ -0,0 +1,159 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { runProcessSync } from '@orca/process-host' + +const ENVIRONMENT_MARKER = 'ORCA_WINDOWS_NATIVE_ENVIRONMENT' +const compilerEnvironmentKey = + /^(PATH|INCLUDE|LIB|LIBPATH|VCINSTALLDIR|VSINSTALLDIR|VisualStudioVersion|VSCMD_.+|VCTools.+|WindowsSDK.+|UniversalCRT.+|UCRTVersion)$/i + +export function parseWindowsCompilerEnvironment(output, arch) { + const lines = output.split(/\r?\n/) + const marker = lines.indexOf(ENVIRONMENT_MARKER) + if (marker === -1) { + throw new Error('Developer environment receipt is missing') + } + const environment = {} + for (const line of lines.slice(marker + 1)) { + const separator = line.indexOf('=') + if (separator <= 0) { + continue + } + const name = line.slice(0, separator) + if (compilerEnvironmentKey.test(name)) { + environment[name] = line.slice(separator + 1) + } + } + const value = (name) => { + const key = Object.keys(environment).find((key) => key.toLowerCase() === name.toLowerCase()) + return key ? environment[key] : undefined + } + if (!value('VCINSTALLDIR') || !value('VSCMD_VER') || !value('WindowsSDKVersion')) { + throw new Error('Developer environment lacks the compiler or Windows SDK') + } + if ( + !/^\d+\.\d+\./.test(value('VSCMD_VER')) || + !/^\d+\.\d+\.\d+\.\d+\\?$/.test(value('WindowsSDKVersion')) + ) { + throw new Error('Developer environment has an invalid compiler or Windows SDK version') + } + const target = value('VSCMD_ARG_TGT_ARCH')?.toLowerCase() + if (!(target === arch || (arch === 'x64' && target === 'amd64'))) { + throw new Error('Developer environment targets a different architecture') + } + return environment +} + +export function hasWindowsCompilerSelection(environment) { + return Object.entries(environment).some( + ([name, value]) => + /^(VCINSTALLDIR|GYP_MSVS_VERSION|npm_config_msvs_version|npm_package_config_node_gyp_msvs_version)$/i.test( + name + ) && value + ) +} + +export function mergeWindowsCompilerEnvironment(environment, compilerEnvironment) { + const compilerKeys = new Set(Object.keys(compilerEnvironment).map((key) => key.toLowerCase())) + return { + ...Object.fromEntries( + Object.entries(environment).filter(([key]) => !compilerKeys.has(key.toLowerCase())) + ), + ...compilerEnvironment + } +} + +export function initializeWindowsNativeToolchain() { + if (process.platform !== 'win32') { + return + } + if (hasWindowsCompilerSelection(process.env)) { + return + } + try { + if (!['x64', 'arm64'].includes(process.arch)) { + throw new Error('Windows compiler setup requires a supported runner architecture') + } + const programFiles = Object.entries(process.env) + .filter(([key]) => /^(ProgramFiles|ProgramFiles\(x86\))$/i.test(key)) + .map(([, value]) => value) + const vswhere = programFiles + .map((directory) => join(directory, 'Microsoft Visual Studio', 'Installer', 'vswhere.exe')) + .find(existsSync) + if (!vswhere) { + throw new Error('Visual Studio discovery tool is unavailable') + } + const discovery = runProcessSync({ + program: vswhere, + args: [ + '-latest', + '-products', + '*', + '-utf8', + '-requires', + process.arch === 'arm64' + ? 'Microsoft.VisualStudio.Component.VC.Tools.ARM64' + : 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64', + '-property', + 'installationPath' + ], + stdio: 'pipe', + timeoutMs: 30_000 + }) + const installations = discovery.stdout.trim().split(/\r?\n/).filter(Boolean) + if (discovery.code !== 0 || installations.length !== 1) { + throw new Error('Visual Studio discovery did not select one compiler') + } + const devcmd = join(installations[0], 'Common7', 'Tools', 'VsDevCmd.bat') + if (!existsSync(devcmd)) { + throw new Error('Visual Studio developer command is unavailable') + } + // process-host owns the fixed batch runner's quoting and hidden console. + const result = runProcessSync({ + program: join(import.meta.dirname, 'capture-windows-native-environment.cmd'), + args: [], + env: { + ...process.env, + ORCA_WINDOWS_VSDEVCMD: devcmd, + ORCA_WINDOWS_NATIVE_ARCH: process.arch + }, + stdio: 'pipe', + timeoutMs: 60_000 + }) + if (result.code !== 0) { + throw new Error('Visual Studio developer command failed') + } + const environment = parseWindowsCompilerEnvironment(result.stdout, process.arch) + console.log(`[native-runtime] Initialized Windows ${process.arch} compiler environment`) + return environment + } catch { + console.warn( + '[native-runtime] Compiler environment setup unavailable; retaining node-gyp discovery' + ) + } +} + +export function prepareWindowsNodeRuntime(runProcess = runProcessSync) { + const script = join(import.meta.dirname, 'ensure-native-runtime.mjs') + const check = runProcess({ + program: process.execPath, + args: [script, '--check-only'], + stdio: 'pipe', + timeoutMs: null + }) + const compilerEnvironment = check.code === 0 ? undefined : initializeWindowsNativeToolchain() + const result = runProcess({ + program: process.execPath, + args: [script, '--runtime=node'], + env: compilerEnvironment + ? mergeWindowsCompilerEnvironment(process.env, compilerEnvironment) + : process.env, + stdio: 'inherit', + timeoutMs: null + }) + return result.code ?? 1 +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exitCode = prepareWindowsNodeRuntime() +} diff --git a/config/scripts/initialize-windows-native-toolchain.test.mjs b/config/scripts/initialize-windows-native-toolchain.test.mjs new file mode 100644 index 00000000000..d9765d6523d --- /dev/null +++ b/config/scripts/initialize-windows-native-toolchain.test.mjs @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest' +import { + hasWindowsCompilerSelection, + mergeWindowsCompilerEnvironment, + parseWindowsCompilerEnvironment, + prepareWindowsNodeRuntime +} from './initialize-windows-native-toolchain.mjs' + +const receipt = [ + 'Developer Command Prompt banner, not an environment variable', + 'TOKEN=should-not-be-exported', + 'ORCA_WINDOWS_NATIVE_ENVIRONMENT', + 'VCINSTALLDIR=C:\\Visual Studio\\VC\\', + 'VSCMD_VER=18.10.12224.181', + 'WindowsSDKVersion=10.0.28000.0\\', + 'VSCMD_ARG_TGT_ARCH=arm64', + 'Path=C:\\工具;C:\\Program Files\\Visual Studio', + 'INCLUDE=C:\\SDK=Custom\\Include', + 'TOKEN=should-not-be-exported', + '=C:=C:\\', + '' +].join('\r\n') + +describe('Windows compiler environment receipt', () => { + it('keeps compiler paths and equals signs while excluding unrelated variables and banners', () => { + expect(parseWindowsCompilerEnvironment(receipt, 'arm64')).toEqual({ + VCINSTALLDIR: 'C:\\Visual Studio\\VC\\', + VSCMD_VER: '18.10.12224.181', + WindowsSDKVersion: '10.0.28000.0\\', + VSCMD_ARG_TGT_ARCH: 'arm64', + Path: 'C:\\工具;C:\\Program Files\\Visual Studio', + INCLUDE: 'C:\\SDK=Custom\\Include' + }) + }) + + it.each([ + receipt.replace('ORCA_WINDOWS_NATIVE_ENVIRONMENT', 'missing'), + receipt.replace('VCINSTALLDIR=', 'unrelated='), + receipt.replace('VSCMD_VER=', 'unrelated='), + receipt.replace('WindowsSDKVersion=', 'unrelated='), + receipt.replace('10.0.28000.0\\', 'unavailable'), + receipt.replace('18.10.12224.181', 'unavailable'), + receipt.replace('VSCMD_ARG_TGT_ARCH=arm64', 'VSCMD_ARG_TGT_ARCH=x64') + ])('rejects incomplete, invalid or wrong-architecture setup before use', (output) => { + expect(() => parseWindowsCompilerEnvironment(output, 'arm64')).toThrow() + }) +}) + +describe('Windows native compiler environment scope', () => { + it('replaces path keys case-insensitively without changing the parent environment', () => { + const original = { PATH: 'original-path', LIB: 'original-lib', TOKEN: 'unchanged' } + expect( + mergeWindowsCompilerEnvironment(original, { Path: 'compiler-path', Lib: 'compiler-lib' }) + ).toEqual({ Path: 'compiler-path', Lib: 'compiler-lib', TOKEN: 'unchanged' }) + expect(original).toEqual({ PATH: 'original-path', LIB: 'original-lib', TOKEN: 'unchanged' }) + }) + + it.each([ + 'VCINSTALLDIR', + 'GYP_MSVS_VERSION', + 'npm_config_msvs_version', + 'npm_package_config_node_gyp_msvs_version' + ])('retains an existing compiler selection through %s', (name) => { + expect(hasWindowsCompilerSelection({ [name.toLowerCase()]: 'requested' })).toBe(true) + expect(hasWindowsCompilerSelection({ [name]: '' })).toBe(false) + }) + + it('uses the original preparation and environment after a passing warm probe', () => { + const runProcess = vi.fn(() => ({ code: 0 })) + expect(prepareWindowsNodeRuntime(runProcess)).toBe(0) + expect(runProcess).toHaveBeenCalledTimes(2) + expect(runProcess).toHaveBeenLastCalledWith( + expect.objectContaining({ + args: [expect.stringContaining('ensure-native-runtime.mjs'), '--runtime=node'], + env: process.env, + stdio: 'inherit' + }) + ) + }) + + it('propagates native preparation failure after a passing warm probe', () => { + const runProcess = vi.fn().mockReturnValueOnce({ code: 0 }).mockReturnValueOnce({ code: 9 }) + expect(prepareWindowsNodeRuntime(runProcess)).toBe(9) + }) +}) diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index 9e66329e65d..cd06384f996 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -41,6 +41,8 @@ const ALWAYS_FILES = new Set([ 'config/scripts/workspace-source-exports.mjs', 'config/scripts/workspace-source-exports.test.mjs', 'config/scripts/headless-detector-compiler-cache.mjs', + 'config/scripts/initialize-windows-native-toolchain.mjs', + 'config/scripts/capture-windows-native-environment.cmd', 'config/scripts/node-server-qualification.mjs', // The source graph sees process-host source, not the build that emits the dist servers load. 'src/packages/process-host/.gitignore', diff --git a/config/scripts/orcad-windows-prebuild-workflow.test.mjs b/config/scripts/orcad-windows-prebuild-workflow.test.mjs index 97f54cf3bd7..26cb30088fc 100644 --- a/config/scripts/orcad-windows-prebuild-workflow.test.mjs +++ b/config/scripts/orcad-windows-prebuild-workflow.test.mjs @@ -59,6 +59,8 @@ function context(os, arch, event, ref, template = false) { }, success: () => true, fromJSON: JSON.parse, + format: (template, ...values) => + template.replace(/\{(\d+)\}/g, (_match, index) => String(values[Number(index)])), contains: (values, value) => values.includes(value) } } @@ -173,6 +175,38 @@ describe('SSH Windows consumers of qualified server slots', () => { const sshWorkflow = parse(readFileSync('.github/workflows/ssh-windows-hosts.yml', 'utf8')) const sshSteps = sshWorkflow.jobs.hosts.steps const sshPrepare = sshSteps.find((step) => step.uses === prepare.uses) + const inboxBackground = sshSteps.find((step) => step.id === 'inbox-capability') + const inboxSerial = sshSteps.find((step) => step.name?.endsWith('after the builds')) + const allLanes = [ + { arch: 'x64', runner: 'windows-2022', server: 'inbox' }, + { arch: 'x64', runner: 'windows-2022', server: 'preview', archive: 'OpenSSH-Win64.zip' }, + { arch: 'arm64', runner: 'windows-11-arm', server: 'inbox' }, + { arch: 'arm64', runner: 'windows-11-arm', server: 'preview', archive: 'OpenSSH-ARM64.zip' } + ] + + function sshContext(event, inputs = {}) { + const ctx = context('Windows', 'ARM64', event, 'refs/heads/main') + ctx.github.event = { inputs, pull_request: { number: undefined } } + ctx.inputs = inputs + ctx.env = { + INBOX_PREPARATION_MODE: sshValue(sshWorkflow.jobs.hosts.env.INBOX_PREPARATION_MODE, ctx) + } + return ctx + } + + function sshValue(value, ctx) { + return evaluate(value.slice(3, -2).trim(), ctx) + } + + function backgroundPreparationRuns(ctx) { + const condition = inboxBackground.run + .match(/^\s*if\((.*)\)\{\s*$/m)[1] + .replace(/'\$\{\{ matrix\.(arch|server) \}\}'/g, (_match, name) => `matrix.${name}`) + .replaceAll('$env:INBOX_PREPARATION_MODE', 'env.INBOX_PREPARATION_MODE') + .replaceAll(' -eq ', ' === ') + .replaceAll(' -and ', ' && ') + return evaluate(condition, ctx) + } it.each(['pull_request', 'workflow_dispatch'])( 'restores only PR slots and keeps manual %s qualification fresh', @@ -188,13 +222,8 @@ describe('SSH Windows consumers of qualified server slots', () => { it('keeps both architectures, both sshd versions and the existing build order', () => { expect( - sshWorkflow.jobs.hosts.strategy.matrix.include.map(({ arch, server }) => [arch, server]) - ).toEqual([ - ['x64', 'inbox'], - ['x64', 'preview'], - ['arm64', 'inbox'], - ['arm64', 'preview'] - ]) + sshValue(sshWorkflow.jobs.hosts.strategy.matrix.include, sshContext('pull_request')) + ).toEqual(allLanes) const addon = sshSteps.findIndex((step) => step.run?.includes('build-windows-process-tree-addon.mjs') ) @@ -215,6 +244,92 @@ describe('SSH Windows consumers of qualified server slots', () => { 'config/scripts/orcad-windows-prebuild-cache.mjs' ) }) + + it.each([ + ['pull_request', {}, 'overlap', false], + ['pull_request', { preparation_mode: 'serial', arm_inbox_only: true }, 'overlap', false], + ['push', { preparation_mode: 'serial', arm_inbox_only: true }, 'overlap', false], + ['workflow_dispatch', {}, 'overlap', false], + ['workflow_dispatch', { preparation_mode: 'overlap', arm_inbox_only: false }, 'overlap', false], + ['workflow_dispatch', { preparation_mode: 'serial', arm_inbox_only: false }, 'serial', false], + ['workflow_dispatch', { preparation_mode: 'overlap', arm_inbox_only: true }, 'overlap', true], + ['workflow_dispatch', { preparation_mode: 'serial', arm_inbox_only: true }, 'serial', true] + ])('%s inputs=%j mode=%s ARM-only=%s', (event, inputs, mode, armOnly) => { + const ctx = sshContext(event, inputs) + const lanes = sshValue(sshWorkflow.jobs.hosts.strategy.matrix.include, ctx) + expect(lanes).toEqual(armOnly ? [allLanes[2]] : allLanes) + expect(ctx.env.INBOX_PREPARATION_MODE).toBe(mode) + for (const matrix of lanes) { + ctx.matrix = matrix + const inboxArm = matrix.server === 'inbox' && matrix.arch === 'arm64' + expect(backgroundPreparationRuns(ctx)).toBe(inboxArm && mode === 'overlap') + expect(sshValue(inboxSerial.if, ctx)).toBe(inboxArm && mode === 'serial') + } + }) + + it('defaults to the existing overlap schedule and keeps serial after the successful wait', () => { + expect(sshWorkflow.on.workflow_dispatch.inputs.preparation_mode).toMatchObject({ + type: 'choice', + default: 'overlap', + options: ['overlap', 'serial'] + }) + expect(sshWorkflow.on.workflow_dispatch.inputs.arm_inbox_only).toMatchObject({ + type: 'boolean', + default: false + }) + const dependencies = sshSteps.findIndex((step) => step.id === 'dependencies') + const template = sshSteps.findIndex((step) => step.run?.includes('build-orcad-template.mjs')) + const wait = sshSteps.findIndex((step) => step.wait === 'inbox-capability') + const serial = sshSteps.indexOf(inboxSerial) + const hosts = sshSteps.findIndex((step) => step.name?.startsWith('Run the Windows host cells')) + expect(inboxBackground.background).toBe(true) + expect(inboxBackground.if).toBeUndefined() + expect(sshSteps.indexOf(inboxBackground)).toBeLessThan(dependencies) + expect(template).toBeLessThan(wait) + expect(serial).toBe(wait + 1) + expect(hosts).toBe(serial + 1) + expect(inboxSerial.background).toBeUndefined() + for (const preparation of [inboxBackground, inboxSerial]) { + expect(preparation['continue-on-error']).toBeUndefined() + expect(preparation.run).toContain( + '. config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1' + ) + expect(preparation.run).toContain( + "-Receipt (Join-Path $receipts 'inbox-capability-preparation.json') -PreparationMode $env:INBOX_PREPARATION_MODE" + ) + } + expect(sshSteps[hosts].run).toContain('-InboxPreparationReceipt $preparation') + expect(sshWorkflow.jobs.hosts.env.ORCA_BACKGROUND_LAUNCH).toBe('1') + expect(sshWorkflow.jobs.hosts.env.ORCA_ISOLATED_SSH_CI).toBe('1') + for (const event of ['pull_request', 'workflow_dispatch']) { + for (const preparation_mode of ['overlap', 'serial']) { + for (const [cells, jobMinutes, hostMinutes] of [ + ['', 75, 50], + ['orcad-cli-managed', 160, 130] + ]) { + const ctx = sshContext(event, { preparation_mode, arm_inbox_only: true, cells }) + expect(sshValue(sshWorkflow.jobs.hosts['timeout-minutes'], ctx)).toBe(jobMinutes) + expect(sshValue(sshSteps[hosts]['timeout-minutes'], ctx)).toBe(hostMinutes) + } + } + } + }) + + it('isolates manual comparison modes while preserving ordinary cancellation groups', () => { + const group = (ctx) => + sshWorkflow.concurrency.group.replace(/\$\{\{(.*?)\}\}/g, (_match, expression) => + String(evaluate(expression.trim(), ctx)) + ) + const regular = sshContext('pull_request') + regular.github.event.pull_request = { number: 28669 } + expect(group(regular)).toBe('ssh-windows-hosts-28669') + expect(group(sshContext('workflow_dispatch'))).toBe('ssh-windows-hosts-refs/heads/main') + for (const mode of ['overlap', 'serial']) { + expect( + group(sshContext('workflow_dispatch', { arm_inbox_only: true, preparation_mode: mode })) + ).toBe(`ssh-windows-hosts-refs/heads/main-${mode}`) + } + }) }) const commandMocks = ` diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 29fb7490431..ff383bdaa7b 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -267,6 +267,8 @@ const MANAGED_HOOK_PREFIXES = [ const NATIVE_RUNTIME_PREFIXES = [ 'config/scripts/ensure-native-runtime', + 'config/scripts/initialize-windows-native-toolchain', + 'config/scripts/capture-windows-native-environment', 'config/scripts/rebuild-native-deps', 'config/scripts/node-pty-job-ownership', 'config/scripts/windows-pe-machine', @@ -288,6 +290,8 @@ const NATIVE_CACHE_FILES = new Set([ '.npmrc', '.pnpmfile.cjs', 'config/scripts/ensure-native-runtime.mjs', + 'config/scripts/initialize-windows-native-toolchain.mjs', + 'config/scripts/capture-windows-native-environment.cmd', 'config/scripts/rebuild-native-deps.mjs', 'config/scripts/node-pty-job-ownership.cjs', 'config/scripts/windows-pe-machine.cjs', diff --git a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs index afd6af518d1..3d6958389e0 100644 --- a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs +++ b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs @@ -2,10 +2,11 @@ import { readdirSync, readFileSync } from 'node:fs' import path from 'node:path' import { describe, expect, it } from 'vitest' import { hasNodeModulesBinSpawn } from './windows-bin-spawn-predicate.mjs' +import { hasWindowsNativeBatchReferenceViolation } from './windows-native-batch-reference-policy' /** - * Bare JS scripts cannot use the TS runProcess chokepoint; resolve package bins - * under process.execPath instead (see oxc-cli-invocation.mjs). The list only shrinks. + * Resolve package bins under process.execPath (see oxc-cli-invocation.mjs). + * The legacy list only shrinks; reviewed native compiler references use process-host. * The literal check catches quoted .cmd/.bat paths, excluding drive letters and * templates; comment-only lines are dropped, but trailing comments fail closed. * The AST check catches spawn/spawnSync/execFile/execFileSync first arguments @@ -13,10 +14,10 @@ import { hasNodeModulesBinSpawn } from './windows-bin-spawn-predicate.mjs' * local variable initializers and ternaries. It does not follow imports, function * returns, assignments, destructuring, aliased spawn names or computed members. * Names are matched without scope/import resolution; shadowed names and non-path - * join/resolve calls can fail closed. Paths are joined textually, without reducing - * dot segments. Neither check proves a Windows branch or an actual unsafe spawn. + * join/resolve calls can fail closed. Package paths fold dot segments. + * Neither check proves a Windows branch or an actual unsafe spawn. */ -const WINDOWS_SHIM_LITERAL = /['"][\w./\\-]*\.(?:cmd|bat)['"]/i +const WINDOWS_SHIM_LITERAL = /['"][\w./\\-]*\.(?:cmd|bat)['"]/gi const SCANNED_ROOTS = ['config/scripts', 'tests/tools'] @@ -83,7 +84,13 @@ describe('windows batch shim spawn boundary', () => { ) const offenders = scripts.filter((relativePath) => { const contents = readFileSync(path.join(repoRoot, relativePath), 'utf8') - return WINDOWS_SHIM_LITERAL.test(codeText(contents)) || hasNodeModulesBinSpawn(contents) + const literals = [...codeText(contents).matchAll(WINDOWS_SHIM_LITERAL)].map((match) => + match[0].slice(1, -1) + ) + return ( + hasWindowsNativeBatchReferenceViolation(relativePath, contents, literals) || + hasNodeModulesBinSpawn(contents) + ) }) it('scans a plausible number of scripts', () => { @@ -95,7 +102,7 @@ describe('windows batch shim spawn boundary', () => { const unlisted = offenders.filter((name) => !WINDOWS_SHIM_SPAWN_ALLOWLIST.includes(name)) expect( unlisted, - 'Node cannot spawn a Windows batch shim without a shell. Resolve the real executable — see oxc-cli-invocation.mjs.' + 'Resolve package shims to their real executable. Fixed native compiler batch runners must pass the scoped process-host policy.' ).toEqual([]) }) diff --git a/config/scripts/windows-native-batch-reference-policy.test.ts b/config/scripts/windows-native-batch-reference-policy.test.ts new file mode 100644 index 00000000000..72bcb34079b --- /dev/null +++ b/config/scripts/windows-native-batch-reference-policy.test.ts @@ -0,0 +1,166 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { hasWindowsNativeBatchReferenceViolation } from './windows-native-batch-reference-policy' + +const initializer = 'config/scripts/initialize-windows-native-toolchain.mjs' +const benchmark = 'config/scripts/benchmark-windows-native-preparation.mjs' +const observer = 'config/scripts/windows-native-benchmark-observer.mjs' +const metadataCallers = [ + 'config/scripts/node-server-change-scope.mjs', + 'config/scripts/pr-code-change-scope.mjs', + 'config/scripts/windows-native-toolchain-routing.test.mjs' +] +const capture = 'capture-windows-native-environment.cmd' +const initializerSource = ` + import { runProcessSync as captureEnvironment } from '@orca/process-host' + const devcmd = join(installation, 'Common7', 'Tools', 'VsDevCmd.bat') + existsSync(devcmd) + captureEnvironment({ + program: join(import.meta.dirname, '${capture}'), + env: { ORCA_WINDOWS_VSDEVCMD: devcmd } + }) +` +const benchmarkSource = ` + import { runProcess } from '@orca/process-host' + const source = 'config/scripts/${capture}' + await runProcess({ program: process.execPath, args: [source] }) +` + +function hasViolation(relativePath: string, contents: string): boolean { + const literals = [...contents.matchAll(/['"][\w./\\-]*\.(?:cmd|bat)['"]/gi)].map((match) => + match[0].slice(1, -1) + ) + return hasWindowsNativeBatchReferenceViolation(relativePath, contents, literals) +} + +describe('reviewed Windows native batch references', () => { + it.each([...metadataCallers, initializer, benchmark, observer])( + 'accepts the current reviewed source in %s', + (relativePath) => { + const root = path.resolve(import.meta.dirname, '..', '..') + expect(hasViolation(relativePath, readFileSync(path.join(root, relativePath), 'utf8'))).toBe( + false + ) + } + ) + + it.each(metadataCallers)('allows the fixed source path as metadata in %s', (relativePath) => { + expect(hasViolation(relativePath, `const source = 'config/scripts/${capture}'`)).toBe(false) + }) + + it('accepts the process-host executor through its imported alias', () => { + expect(hasViolation(initializer, initializerSource)).toBe(false) + }) + + it('accepts the benchmark executor', () => { + expect(hasViolation(benchmark, benchmarkSource)).toBe(false) + }) + + it.each([ + 'config/scripts/other-native-preparation.mjs', + 'tests/tools/initialize-windows-native-toolchain.mjs', + `config/scripts/nested/${path.basename(initializer)}` + ])('rejects the fixed runner in another caller: %s', (relativePath) => { + expect(hasViolation(relativePath, initializerSource)).toBe(true) + }) + + it.each([ + 'elsewhere/capture-windows-native-environment.cmd', + './capture-windows-native-environment.cmd', + 'CAPTURE-WINDOWS-NATIVE-ENVIRONMENT.cmd', + 'other.cmd', + 'other.bat' + ])('rejects another batch path in a reviewed executor: %s', (literal) => { + expect(hasViolation(initializer, `${initializerSource}\nconst other = '${literal}'`)).toBe(true) + }) + + it.each(metadataCallers)('rejects other batch metadata in %s', (relativePath) => { + expect( + hasViolation(relativePath, `const sources = ['config/scripts/${capture}', 'other.cmd']`) + ).toBe(true) + expect(hasViolation(relativePath, `const command = 'VsDevCmd.bat'`)).toBe(true) + }) + + it.each([ + [initializer, initializerSource], + [benchmark, benchmarkSource], + [observer, `require('@orca/process-host'); const capture = '${capture}'`] + ])('rejects a missing process-host load in %s', (relativePath, contents) => { + expect(hasViolation(relativePath, contents.replace('@orca/process-host', 'other-runner'))).toBe( + true + ) + }) + + it('rejects an unused process-host import beside an unverified executor', () => { + expect( + hasViolation(initializer, initializerSource.replace('captureEnvironment({', 'otherRunner({')) + ).toBe(true) + }) + + it('rejects another executor for the fixed capture script beside a valid call', () => { + expect( + hasViolation( + initializer, + `${initializerSource}\notherRunner({ program: join(import.meta.dirname, '${capture}') })` + ) + ).toBe(true) + }) + + it.each([ + "import { spawnSync as execute } from 'node:child_process'", + "import * as cp from 'child_process'", + "await import('node:child_process')", + "const builtins = 'node:child_process'; process.getBuiltinModule(builtins)", + "const require = createRequire(import.meta.url); const cp = require('child_process')" + ])('rejects raw child-process module loads: %s', (load) => { + expect(hasViolation(initializer, `${initializerSource}\n${load}`)).toBe(true) + }) + + it.each([ + "spawn('program', [])", + "cp.spawnSync('program', [])", + "cp['execFile']('program', [])", + "execFileSync('program', [])", + "exec('program')", + "execSync('program')", + "fork('program')" + ])('rejects raw process calls: %s', (call) => { + expect(hasViolation(initializer, `${initializerSource}\n${call}`)).toBe(true) + }) + + it.each([ + "runProcess({ program: 'node_modules/.bin/pnpm' })", + "const bin = join(root, 'node_modules', '.bin', 'pnpm'); runProcess({ program: bin })", + "runProcess({ program: join(root, 'node_modules', 'tools', '..', '.bin', 'pnpm') })", + "const spec = { 'program': 'root\\\\node_modules\\\\.bin\\\\pnpm' }; runProcess(spec)", + "const program = join(root, 'node_modules', '.bin', 'pnpm'); runProcess({ program })", + "runProcess({ ['program']: join(root, 'node_modules', '.bin', 'pnpm') })" + ])('rejects package-bin programs through process-host: %s', (call) => { + expect(hasViolation(benchmark, `${benchmarkSource}\n${call}`)).toBe(true) + }) + + it.each(metadataCallers)('rejects process execution added to metadata in %s', (relativePath) => { + expect( + hasViolation( + relativePath, + `const capture = 'config/scripts/${capture}'; runProcess({ program: capture })` + ) + ).toBe(true) + expect( + hasViolation( + relativePath, + `import { runProcess as execute } from '@orca/process-host'; const capture = 'config/scripts/${capture}'; execute({ program: capture })` + ) + ).toBe(true) + }) + + it.each([ + "captureEnvironment({ program: 'VsDevCmd.bat' })", + 'captureEnvironment({ program: devcmd })', + 'const program = devcmd; captureEnvironment({ program })', + "const devcmd = join(other, 'VsDevCmd.bat')" + ])('keeps the discovered developer command as metadata: %s', (extra) => { + expect(hasViolation(initializer, `${initializerSource}\n${extra}`)).toBe(true) + }) +}) diff --git a/config/scripts/windows-native-batch-reference-policy.ts b/config/scripts/windows-native-batch-reference-policy.ts new file mode 100644 index 00000000000..60dcc4b8ca6 --- /dev/null +++ b/config/scripts/windows-native-batch-reference-policy.ts @@ -0,0 +1,229 @@ +import ts from 'typescript-api' +import { + collectModuleSpecifiers, + literalString, + propertyName +} from './static-module-specifiers.mjs' +import { hasNodeModulesBinSpawn } from './windows-bin-spawn-predicate.mjs' + +const references = new Map([ + [ + 'config/scripts/node-server-change-scope.mjs', + 'config/scripts/capture-windows-native-environment.cmd' + ], + [ + 'config/scripts/pr-code-change-scope.mjs', + 'config/scripts/capture-windows-native-environment.cmd' + ], + [ + 'config/scripts/windows-native-toolchain-routing.test.mjs', + 'config/scripts/capture-windows-native-environment.cmd' + ], + [ + 'config/scripts/windows-native-benchmark-observer.mjs', + 'capture-windows-native-environment.cmd' + ], + [ + 'config/scripts/initialize-windows-native-toolchain.mjs', + 'capture-windows-native-environment.cmd' + ], + [ + 'config/scripts/benchmark-windows-native-preparation.mjs', + 'config/scripts/capture-windows-native-environment.cmd' + ] +]) +const executors = new Map([ + ['config/scripts/initialize-windows-native-toolchain.mjs', 'runProcessSync'], + ['config/scripts/benchmark-windows-native-preparation.mjs', 'runProcess'] +]) +const initializer = 'config/scripts/initialize-windows-native-toolchain.mjs' +const observer = 'config/scripts/windows-native-benchmark-observer.mjs' +const rawProcessMethods = new Set([ + 'spawn', + 'spawnSync', + 'exec', + 'execSync', + 'execFile', + 'execFileSync', + 'fork' +]) +const processHostMethods = new Set(['runProcess', 'runProcessSync', 'spawnProcess']) + +function callName(node: ts.Expression): string { + return ts.isIdentifier(node) ? node.text : (propertyName(node) ?? '') +} + +function isDeveloperCommandMetadata(node: ts.StringLiteral): boolean { + const call = node.parent + const declaration = call.parent + return ( + ts.isCallExpression(call) && + callName(call.expression) === 'join' && + call.arguments.at(-1) === node && + ts.isVariableDeclaration(declaration) && + ts.isIdentifier(declaration.name) && + declaration.name.text === 'devcmd' + ) +} + +function isDeveloperCommandUse(node: ts.Identifier): boolean { + const parent = node.parent + return ( + (ts.isVariableDeclaration(parent) && parent.name === node) || + (ts.isCallExpression(parent) && + callName(parent.expression) === 'existsSync' && + parent.arguments[0] === node) || + (ts.isPropertyAssignment(parent) && + parent.initializer === node && + ts.isIdentifier(parent.name) && + parent.name.text === 'ORCA_WINDOWS_VSDEVCMD') + ) +} + +function isProcessHostCapture(node: ts.StringLiteral, importedMethods: Set): boolean { + const path = node.parent + if ( + !ts.isCallExpression(path) || + callName(path.expression) !== 'join' || + path.arguments.length !== 2 || + path.arguments[0].getText() !== 'import.meta.dirname' || + path.arguments[1] !== node + ) { + return false + } + const program = path.parent + if ( + !ts.isPropertyAssignment(program) || + !ts.isIdentifier(program.name) || + program.name.text !== 'program' + ) { + return false + } + const spec = program.parent + const call = spec.parent + return ( + ts.isObjectLiteralExpression(spec) && + ts.isCallExpression(call) && + call.arguments[0] === spec && + ts.isIdentifier(call.expression) && + importedMethods.has(call.expression.text) + ) +} + +export function hasWindowsNativeBatchReferenceViolation( + relativePath: string, + contents: string, + literals: readonly string[] +): boolean { + const capture = references.get(relativePath) + if (!capture) { + return literals.length > 0 + } + if ( + literals.some( + (literal) => + literal !== capture && !(relativePath === initializer && literal === 'VsDevCmd.bat') + ) + ) { + return true + } + const modules = collectModuleSpecifiers(relativePath, contents) + if (modules.some((module) => module === 'child_process' || module === 'node:child_process')) { + return true + } + const expectedMethod = executors.get(relativePath) + if ((expectedMethod || relativePath === observer) && !modules.includes('@orca/process-host')) { + return true + } + if (!expectedMethod && relativePath !== observer && modules.includes('@orca/process-host')) { + return true + } + const source = ts.createSourceFile(relativePath, contents, ts.ScriptTarget.Latest, true) + const importedMethods = new Set() + const calls: ts.CallExpression[] = [] + const programs: ts.Expression[] = [] + const captureReferences: ts.StringLiteral[] = [] + let violation = false + let developerCommandDeclarations = 0 + function visit(node: ts.Node): void { + if ( + ts.isImportDeclaration(node) && + ts.isStringLiteral(node.moduleSpecifier) && + node.moduleSpecifier.text === '@orca/process-host' + ) { + const bindings = node.importClause?.namedBindings + if (bindings && ts.isNamedImports(bindings)) { + for (const binding of bindings.elements) { + if ((binding.propertyName ?? binding.name).text === expectedMethod) { + importedMethods.add(binding.name.text) + } + } + } + } + if (ts.isCallExpression(node)) { + calls.push(node) + const method = callName(node.expression) + if (rawProcessMethods.has(method)) { + violation = true + } + if (!expectedMethod && relativePath !== observer && processHostMethods.has(method)) { + violation = true + } + } + if ( + ts.isPropertyAssignment(node) && + ((ts.isIdentifier(node.name) && node.name.text === 'program') || + (ts.isStringLiteral(node.name) && node.name.text === 'program') || + (ts.isComputedPropertyName(node.name) && literalString(node.name.expression) === 'program')) + ) { + programs.push(node.initializer) + } + if (ts.isShorthandPropertyAssignment(node) && node.name.text === 'program') { + programs.push(node.name) + } + if (relativePath === initializer) { + if (ts.isStringLiteral(node) && node.text === capture) { + captureReferences.push(node) + } + if ( + ts.isStringLiteral(node) && + node.text === 'VsDevCmd.bat' && + !isDeveloperCommandMetadata(node) + ) { + violation = true + } + if (ts.isIdentifier(node) && node.text === 'devcmd') { + if (!isDeveloperCommandUse(node)) { + violation = true + } + if (ts.isVariableDeclaration(node.parent) && node.parent.name === node) { + developerCommandDeclarations += 1 + } + } + } + ts.forEachChild(node, visit) + } + visit(source) + if ( + expectedMethod && + !calls.some( + (call) => ts.isIdentifier(call.expression) && importedMethods.has(call.expression.text) + ) + ) { + violation = true + } + if (relativePath === initializer && developerCommandDeclarations !== 1) { + violation = true + } + if (captureReferences.some((node) => !isProcessHostCapture(node, importedMethods))) { + violation = true + } + // Reuse package-path folding for process-host's named program option. + return ( + violation || + hasNodeModulesBinSpawn(contents) || + programs.some((program) => + hasNodeModulesBinSpawn(`${contents}\nspawnSync(${program.getText(source)})`) + ) + ) +} diff --git a/config/scripts/windows-native-benchmark-observer.mjs b/config/scripts/windows-native-benchmark-observer.mjs new file mode 100644 index 00000000000..8694f5b7706 --- /dev/null +++ b/config/scripts/windows-native-benchmark-observer.mjs @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { basename } from 'node:path' +import { performance } from 'node:perf_hooks' + +export const compilerEnvironmentKey = + /^(PATH|INCLUDE|LIB|LIBPATH|VCINSTALLDIR|VSINSTALLDIR|VisualStudioVersion|VSCMD_.+|VCTools.+|WindowsSDK.+|UniversalCRT.+|UCRTVersion)$/i +export const hash = (value) => createHash('sha256').update(value).digest('hex') + +export function environmentReceipt(environment) { + const entries = Object.entries(environment) + .filter(([, value]) => value !== undefined) + .map(([name, value]) => [name.toLowerCase(), String(value)]) + .sort(([left], [right]) => left.localeCompare(right)) + assert.equal( + new Set(entries.map(([name]) => name)).size, + entries.length, + 'Duplicate environment keys' + ) + return { + hash: hash(JSON.stringify(entries)), + versions: Object.fromEntries( + entries.filter( + ([name, value]) => + /^(visualstudioversion|vscmd_ver|windowssdkversion|vctoolsversion|vscmd_arg_tgt_arch)$/.test( + name + ) && /^(?:[\d.]+\\?|x64|amd64|arm64)$/.test(value) + ) + ), + compiler: Object.fromEntries( + entries + .filter(([name]) => compilerEnvironmentKey.test(name)) + .map(([name, value]) => [name, hash(value)]) + ) + } +} + +export function changedEnvironmentNames(before, after) { + const normalize = (environment) => + new Map(Object.entries(environment).map(([name, value]) => [name.toLowerCase(), value])) + const original = normalize(before) + const prepared = normalize(after) + return [...new Set([...original.keys(), ...prepared.keys()])] + .filter((name) => original.get(name) !== prepared.get(name)) + .sort() +} + +export function processPhase(spec) { + const name = basename(spec.program).toLowerCase() + if (name === 'vswhere.exe') { + return 'discovery' + } + if (name === 'capture-windows-native-environment.cmd') { + return 'capture' + } + if (spec.args?.some((arg) => arg.endsWith('ensure-native-runtime.mjs'))) { + return spec.args.includes('--check-only') ? 'probe' : 'preparation' + } + return spec.args?.includes('rebuild') ? 'addon-build' : 'other' +} + +export function installNativeBenchmarkObserver(api, output, environment = process.env) { + const original = api.runProcessSync + const parent = environmentReceipt(environment) + const receipt = { parentBefore: parent, parentAfter: parent, calls: [] } + writeFileSync(output, `${JSON.stringify(receipt, null, 2)}\n`) + api.runProcessSync = (spec) => { + const startedAt = new Date().toISOString() + const start = performance.now() + const childEnvironment = spec.env ?? environment + const call = { + phase: processPhase(spec), + program: basename(spec.program), + args: spec.args, + startedAt, + environment: environmentReceipt(childEnvironment), + changedNames: changedEnvironmentNames(environment, childEnvironment) + } + try { + const result = original(spec) + Object.assign(call, { + code: result.code, + timedOut: result.timedOut, + outputTruncated: result.outputTruncated, + stdoutSha256: hash(result.stdout ?? ''), + stderrSha256: hash(result.stderr ?? ''), + stdoutBytes: Buffer.byteLength(result.stdout ?? ''), + stderrBytes: Buffer.byteLength(result.stderr ?? '') + }) + return result + } catch (error) { + call.error = error.message + throw error + } finally { + call.durationMs = performance.now() - start + call.finishedAt = new Date().toISOString() + receipt.calls.push(call) + receipt.parentAfter = environmentReceipt(environment) + assert.equal( + receipt.parentAfter.hash, + parent.hash, + 'Compiler setup changed its parent environment' + ) + writeFileSync(output, `${JSON.stringify(receipt, null, 2)}\n`) + } + } + return receipt +} + +if (process.env.ORCA_NATIVE_BENCHMARK_TRACE) { + const require = createRequire(import.meta.url) + installNativeBenchmarkObserver( + require('@orca/process-host'), + process.env.ORCA_NATIVE_BENCHMARK_TRACE + ) +} diff --git a/config/scripts/windows-native-preparation-benchmark.test.mjs b/config/scripts/windows-native-preparation-benchmark.test.mjs new file mode 100644 index 00000000000..fa5ea5253aa --- /dev/null +++ b/config/scripts/windows-native-preparation-benchmark.test.mjs @@ -0,0 +1,212 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { test } from 'vitest' +import { parse } from 'yaml' +import { runProcess } from '@orca/process-host' +import { + assertNativeColdProbe, + assertNoHeaderDownloads, + validateObservedPreparation +} from './benchmark-windows-native-preparation.mjs' +import { + changedEnvironmentNames, + environmentReceipt, + installNativeBenchmarkObserver +} from './windows-native-benchmark-observer.mjs' + +const original = { Path: 'unchanged', TOKEN: 'private' } +const parent = environmentReceipt(original) +const captureEnvironment = { ...original, VCINSTALLDIR: 'compiler' } +const call = (phase, code = 0, prepared = false) => ({ + phase, + code, + environment: prepared ? environmentReceipt(captureEnvironment) : parent, + changedNames: prepared ? ['vcinstalldir'] : [] +}) +const receipt = (calls) => ({ parentBefore: parent, parentAfter: parent, calls }) +const candidate = () => + receipt([call('probe', 1), call('discovery'), call('capture'), call('preparation', 0, true)]) + +for (const [variant, warm, calls] of [ + [ + 'control', + false, + [call('addon-build'), call('addon-build'), call('addon-build'), call('addon-build')] + ], + ['control', true, []], + ['candidate', false, candidate().calls], + ['candidate', true, [call('probe'), call('preparation')]], + [ + 'fallback', + false, + [call('probe', 1), call('discovery'), call('capture', 7), call('preparation')] + ], + ['fallback', true, [call('probe'), call('preparation')]] +]) { + test(`qualifies ${variant} ${warm ? 'warm' : 'cold'} receipts`, () => + validateObservedPreparation(receipt(calls), variant, warm)) +} + +test('fails closed on an unobserved or unchanged candidate', () => { + assert.throws(() => validateObservedPreparation(receipt([]), 'candidate'), /discovery count/) + const missingCapture = candidate() + missingCapture.calls.splice(2, 1) + assert.throws( + () => validateObservedPreparation(missingCapture, 'candidate'), + /developer command count/ + ) + const unchanged = candidate() + unchanged.calls[3] = call('preparation') + assert.throws(() => validateObservedPreparation(unchanged, 'candidate'), /not applied/) +}) + +test('rejects failed preparation and unrelated or persistent environment changes', () => { + for (const mutate of [ + (value) => { + value.calls[3].code = 2 + }, + (value) => { + value.calls[3].changedNames = ['token'] + }, + (value) => { + value.parentAfter = environmentReceipt(captureEnvironment) + }, + (value) => { + value.calls[2].timedOut = true + } + ]) { + const value = candidate() + mutate(value) + assert.throws(() => validateObservedPreparation(value, 'candidate')) + } + assert.throws( + () => + validateObservedPreparation( + receipt([call('probe'), call('preparation', 0, true)]), + 'candidate', + true + ), + /changed compiler/ + ) +}) + +test('requires all four initial addon failures and excludes timed header downloads', () => { + const result = { + code: 1, + stderr: [ + 'node-pty', + '@orca/windows-registry', + '@orca/windows-security', + '@vscode/windows-process-tree' + ] + .map((name) => `${name}: unavailable`) + .join('\n') + } + assertNativeColdProbe(result) + assert.throws(() => + assertNativeColdProbe({ ...result, stderr: result.stderr.replace('node-pty:', 'missing:') }) + ) + assertNoHeaderDownloads({ stdout: '', stderr: 'gyp info find VS using VS2026' }) + assert.throws( + () => + assertNoHeaderDownloads({ + stdout: '', + stderr: 'gyp http GET https://nodejs.org/header.tar.gz' + }), + /downloaded/ + ) +}) + +test('uses case-insensitive environment comparisons and never publishes values', () => { + assert.equal( + environmentReceipt(original).hash, + environmentReceipt({ PATH: 'unchanged', token: 'private' }).hash + ) + assert.deepEqual( + changedEnvironmentNames(original, { + PATH: 'unchanged', + TOKEN: 'private', + VCINSTALLDIR: 'compiler' + }), + ['vcinstalldir'] + ) + assert(!JSON.stringify(parent).includes('private')) + assert.throws(() => environmentReceipt({ PATH: 'first', Path: 'second' }), /Duplicate/) +}) + +test('writes an empty warm trace and observes calls without changing execution', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-native-observer-test-')) + try { + const output = join(directory, 'trace.json') + const result = { code: 0, stdout: 'original stdout', stderr: '', timedOut: false } + const seen = [] + const api = { + runProcessSync: (spec) => { + seen.push(spec) + return result + } + } + installNativeBenchmarkObserver(api, output, original) + assert.deepEqual(JSON.parse(readFileSync(output, 'utf8')).calls, []) + const spec = { program: 'vswhere.exe', args: ['-latest'], stdio: 'pipe' } + assert.equal(api.runProcessSync(spec), result) + assert.deepEqual(seen, [spec]) + const value = JSON.parse(readFileSync(output, 'utf8')) + assert.equal(value.calls[0].phase, 'discovery') + assert.equal(value.calls[0].stdoutBytes, result.stdout.length) + assert.equal(value.parentBefore.hash, value.parentAfter.hash) + assert(!readFileSync(output, 'utf8').includes('original stdout')) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +test('preload observes the CJS process-host export imported by production ESM code', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-native-observer-esm-test-')) + try { + const trace = join(directory, 'trace.json') + const program = `import { runProcessSync } from '@orca/process-host'; const value = runProcessSync({program:process.execPath,args:['-e','process.stdout.write("proof")']}); if(value.stdout !== 'proof') process.exit(2);` + const result = await runProcess({ + program: process.execPath, + args: [ + '--import', + pathToFileURL(join(import.meta.dirname, 'windows-native-benchmark-observer.mjs')).href, + '--input-type=module', + '-e', + program + ], + cwd: join(import.meta.dirname, '../..'), + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ORCA_NATIVE_BENCHMARK_TRACE: trace } + }) + assert.equal(result.code, 0, result.stderr) + const value = JSON.parse(readFileSync(trace, 'utf8')) + assert.equal(value.calls.length, 1) + assert.equal(value.calls[0].code, 0) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +test('manual workflow freezes source and limits Windows occupancy without changing a PR gate', () => { + const workflow = parse( + readFileSync( + join(import.meta.dirname, '../../.github/workflows/windows-native-preparation-benchmark.yml'), + 'utf8' + ) + ) + assert.deepEqual(Object.keys(workflow.on), ['workflow_dispatch']) + assert.equal(workflow.jobs.compare.strategy['max-parallel'], 2) + assert.deepEqual(workflow.jobs.compare.strategy.matrix.os, ['windows-2022', 'windows-11-arm']) + assert.equal(workflow.jobs.compare.steps[0].with.ref, '${{ inputs.source_sha }}') + const install = workflow.jobs.compare.steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) + assert.equal(install.with['native-runtime'], 'none') + assert.equal(install.with['node-version'], '24.21.0') + assert.equal(install.with['cache-pnpm-store'], 'false') + assert.equal(workflow.jobs.compare.steps.at(-1).if, 'always()') + assert(!Object.hasOwn(workflow.jobs, 'verify')) +}) diff --git a/config/scripts/windows-native-toolchain-routing.test.mjs b/config/scripts/windows-native-toolchain-routing.test.mjs new file mode 100644 index 00000000000..a8e066b1610 --- /dev/null +++ b/config/scripts/windows-native-toolchain-routing.test.mjs @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import { classifyPrJobs } from './pr-code-change-scope.mjs' +import { classifyNodeServerChanges } from './node-server-change-scope.mjs' +import { nodeServerQualification } from './node-server-qualification.mjs' + +const toolchainSources = [ + 'config/scripts/initialize-windows-native-toolchain.mjs', + 'config/scripts/capture-windows-native-environment.cmd' +] + +describe('Windows native toolchain CI routing', () => { + it.each(toolchainSources)( + 'prepares native caches and packages for a change to %s alone', + (file) => { + expect(classifyPrJobs([file])).toMatchObject({ + should_run: true, + native_cache_changed: true, + package: true, + package_windows: true + }) + } + ) + + it.each(toolchainSources)( + 'selects both Windows server runners for a change to %s alone', + async (file) => { + const collect = vi.fn(async () => new Set()) + for (const options of [ + {}, + { deferGraph: true }, + { modifiedFiles: [file] }, + { deferGraph: true, modifiedFiles: [file] } + ]) { + const scope = await classifyNodeServerChanges([file], collect, options) + expect(scope).toEqual({ shouldRun: true, reason: `Build or CI input changed: ${file}` }) + expect(nodeServerQualification([file], scope)).toEqual({ + qualification: false, + runners: ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'] + }) + } + expect(collect).not.toHaveBeenCalled() + } + ) + + it('preserves unrelated build-script routing', async () => { + const file = 'config/scripts/unrelated-toolchain.mjs' + expect(classifyPrJobs([file])).toMatchObject({ + test: true, + native_cache_changed: false, + package: false, + package_windows: false + }) + expect((await classifyNodeServerChanges([file], async () => new Set())).shouldRun).toBe(false) + }) +})