diff --git a/.github/actions/mask-visual-studio/action.yml b/.github/actions/mask-visual-studio/action.yml new file mode 100644 index 00000000..8e1239fc --- /dev/null +++ b/.github/actions/mask-visual-studio/action.yml @@ -0,0 +1,94 @@ +name: Mask Visual Studio +description: > + Make every MSVC discovery strategy of msvc.cppm fail on a runner that ships + Visual Studio, and assert that they do. The runner is disposable, so this is + both safe and closer to "absent" than any env-only trick. + +inputs: + attempts: + description: How many times one rename is retried before it counts as denied + required: false + default: "5" + retry-delay-ms: + description: Milliseconds between two attempts at the same rename + required: false + default: "2000" + +runs: + using: composite + steps: + - name: Mask Visual Studio + shell: pwsh + env: + MASK_ATTEMPTS: ${{ inputs.attempts }} + MASK_RETRY_DELAY_MS: ${{ inputs.retry-delay-ms }} + run: | + $ErrorActionPreference = 'Continue' + + # All three of msvc.cppm's discovery strategies converge on + # \VC\Tools\MSVC — vswhere returns an installationPath that + # find_latest_msvc_tools then resolves through it, the env strategy + # checks it explicitly, and the well-known-path scan tests for it — so + # masking that directory is what makes this runner answer "no Visual + # Studio" to all three. + # + # The rename is refused outright on some hosts: "Access to the path + # '...\18\Enterprise\VC' is denied", while the same script on the same + # runner image commits the same rename minutes earlier or later (image + # windows-2025-vs2026 20260925.250 renamed it in one run and was + # refused in another). A refusal is therefore retried, and then + # answered one level deeper: a handle into VC does not block renaming + # VC\Tools, and the postcondition below only needs VC\Tools\MSVC gone. + # What outlasts both is reported, never swallowed. + $attempts = [int]$env:MASK_ATTEMPTS + $delay = [int]$env:MASK_RETRY_DELAY_MS + + function Mask-Path([string]$Path) { + for ($i = 1; $i -le $attempts; $i++) { + try { + Rename-Item -LiteralPath $Path -NewName "$(Split-Path $Path -Leaf).masked" -ErrorAction Stop + Write-Host "masked $Path" + return $true + } catch { + if ($i -lt $attempts) { + Start-Sleep -Milliseconds $delay + } else { + Write-Host " denied after $attempts attempts: $Path — $($_.Exception.Message)" + } + } + } + return $false + } + + $vswhere = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" + if (Test-Path $vswhere) { [void](Mask-Path $vswhere) } + + # -Path with a trailing wildcard segment lists the CONTENTS of the + # matches, not the matches themselves, so `…\*\*\VC` would hand back + # VC's children. Resolve-Path returns the directories themselves. + $vsRoots = @(Resolve-Path "C:\Program Files*\Microsoft Visual Studio\*\*\VC" ` + -ErrorAction SilentlyContinue) + foreach ($p in $vsRoots) { [void](Mask-Path $p.Path) } + + # The fallback, and the reason it is enough: vswhere's resolution and + # the well-known-path scan both end at VC\Tools\MSVC, so masking Tools + # answers "no Visual Studio" as well as masking VC does. + $tools = @(Resolve-Path "C:\Program Files*\Microsoft Visual Studio\*\*\VC\Tools" ` + -ErrorAction SilentlyContinue) + foreach ($p in $tools) { [void](Mask-Path $p.Path) } + + foreach ($v in @('VSINSTALLDIR','VCINSTALLDIR','VCToolsInstallDir', + 'VS170COMNTOOLS','VS160COMNTOOLS','VS150COMNTOOLS')) { + "$v=" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 + } + + # Check the mask's own postcondition here, where the cause is obvious, + # instead of letting it surface three steps later as a confusing pass. + $remaining = Get-ChildItem "C:\Program Files*\Microsoft Visual Studio\*\*\VC\Tools\MSVC" ` + -Directory -ErrorAction SilentlyContinue + if ($remaining) { + Write-Host "FAIL: VC tools still present after masking:" + $remaining | ForEach-Object { Write-Host " $($_.FullName)" } + exit 1 + } + Write-Host "Visual Studio masked: no VC\Tools\MSVC remains." diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index 591526ad..c952e61a 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -751,40 +751,13 @@ jobs: steps: - uses: actions/checkout@v4 + # The mask retries a refused rename — "denied" is a state of the host, + # not of the image — and then masks VC\Tools, which is where all three + # of msvc.cppm's discovery strategies end. Its postcondition, no + # VC\Tools\MSVC left, is what keeps an incomplete mask from reading as a + # green no-Visual-Studio install. - name: Mask Visual Studio - shell: pwsh - run: | - $ErrorActionPreference = 'Continue' - - # All three of msvc.cppm's discovery strategies converge on - # \VC\Tools\MSVC, so mask the VC directory rather than the - # Visual Studio root: the root is held open on the runner and - # renaming it is denied, while VC one level down renames fine. - # The runner is disposable, so this is both safe and closer to - # "absent" than any env-only trick would be. - $vswhere = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" - if (Test-Path $vswhere) { Rename-Item $vswhere "vswhere.exe.masked" } - - Get-ChildItem "C:\Program Files*\Microsoft Visual Studio\*\*\VC" ` - -Directory -ErrorAction SilentlyContinue | ForEach-Object { - Rename-Item $_.FullName "$($_.Name).masked" -ErrorAction SilentlyContinue - } - - foreach ($v in @('VSINSTALLDIR','VCINSTALLDIR','VCToolsInstallDir', - 'VS170COMNTOOLS','VS160COMNTOOLS','VS150COMNTOOLS')) { - "$v=" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 - } - - # Check the mask's own postcondition here, where the cause is - # obvious, rather than letting it surface later as a confusing pass. - $left = Get-ChildItem "C:\Program Files*\Microsoft Visual Studio\*\*\VC\Tools\MSVC" ` - -Directory -ErrorAction SilentlyContinue - if ($left) { - Write-Host "FAIL: VC tools still present after masking:" - $left | ForEach-Object { Write-Host " $($_.FullName)" } - exit 1 - } - Write-Host "Visual Studio masked: no VC\Tools\MSVC remains." + uses: ./.github/actions/mask-visual-studio - name: Install xlings shell: pwsh diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index e4f7d045..84e71d95 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -143,52 +143,12 @@ jobs: echo "MCPP_SELF=$MCPP_SELF" >> "$GITHUB_ENV" # Masked before anything else touches Visual Studio, so no process of - # ours is holding a handle into it. + # ours is holding a handle into it. The mask retries a refused rename + # (a state of the host, not of the image) and then masks VC\Tools; its + # postcondition — no VC\Tools\MSVC left — is what keeps an incomplete + # mask from reading as a green "bare Windows" row. - name: Mask Visual Studio - shell: pwsh - run: | - $ErrorActionPreference = 'Continue' - - # All three of msvc.cppm's discovery strategies converge on - # \VC\Tools\MSVC — vswhere returns an installationPath that - # find_latest_msvc_tools then resolves through it, the env strategy - # checks it explicitly, and the well-known-path scan tests for it. - # So mask the VC directory rather than the Visual Studio root: the - # root is held open on the runner and renaming it is denied, while - # VC one level down renames fine. The runner is disposable, so this - # is both safe and closer to "absent" than any env-only trick. - $vswhere = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" - if (Test-Path $vswhere) { Rename-Item $vswhere "vswhere.exe.masked" } - - # -Path with a trailing wildcard segment lists the CONTENTS of the - # matches, not the matches themselves, so `…\*\*\VC` would hand back - # VC's children. Resolve-Path returns the directories themselves. - # Errors are reported, not swallowed: a silent failure here is how - # the first attempt "masked" nothing and still looked fine. - Resolve-Path "C:\Program Files*\Microsoft Visual Studio\*\*\VC" ` - -ErrorAction SilentlyContinue | ForEach-Object { - $p = $_.Path - Write-Host "masking $p" - try { Rename-Item -LiteralPath $p -NewName "VC.masked" -ErrorAction Stop } - catch { Write-Host " rename failed: $($_.Exception.Message)" } - } - - foreach ($v in @('VSINSTALLDIR','VCINSTALLDIR','VCToolsInstallDir', - 'VS170COMNTOOLS','VS160COMNTOOLS','VS150COMNTOOLS')) { - "$v=" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 - } - - # Check the mask's own postcondition here, where the cause is - # obvious, instead of letting it surface three steps later as a - # confusing pass. - $left = Get-ChildItem "C:\Program Files*\Microsoft Visual Studio\*\*\VC\Tools\MSVC" ` - -Directory -ErrorAction SilentlyContinue - if ($left) { - Write-Host "FAIL: VC tools still present after masking:" - $left | ForEach-Object { Write-Host " $($_.FullName)" } - exit 1 - } - Write-Host "Visual Studio masked: no VC\Tools\MSVC remains." + uses: ./.github/actions/mask-visual-studio # After masking, so nothing this action does can be holding Visual # Studio open. It installs xlings (which mcpp resolves the winlibs diff --git a/.github/workflows/measure-windows-tool-crt.yml b/.github/workflows/measure-windows-tool-crt.yml index c3a06c92..713c28a4 100644 --- a/.github/workflows/measure-windows-tool-crt.yml +++ b/.github/workflows/measure-windows-tool-crt.yml @@ -84,24 +84,12 @@ jobs: name: measure-candidate path: candidate - # The same mask as ci-windows.yml's bare-Windows row, applied before - # anything touches Visual Studio. + # The same mask as ci-windows.yml's bare-Windows row — one action, so + # the two rows cannot drift apart — applied before anything touches + # Visual Studio. - name: Mask Visual Studio if: matrix.row == 'bare' - shell: pwsh - run: | - $ErrorActionPreference = 'Continue' - $vswhere = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" - if (Test-Path $vswhere) { Rename-Item $vswhere "vswhere.exe.masked" } - Resolve-Path "C:\Program Files*\Microsoft Visual Studio\*\*\VC" -ErrorAction SilentlyContinue | ForEach-Object { - try { Rename-Item -LiteralPath $_.Path -NewName "VC.masked" -ErrorAction Stop } - catch { Write-Host " rename failed: $($_.Exception.Message)" } - } - foreach ($v in @('VSINSTALLDIR','VCINSTALLDIR','VCToolsInstallDir','VS170COMNTOOLS','VS160COMNTOOLS','VS150COMNTOOLS')) { - "$v=" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 - } - $left = Get-ChildItem "C:\Program Files*\Microsoft Visual Studio\*\*\VC\Tools\MSVC" -Directory -ErrorAction SilentlyContinue - if ($left) { Write-Host "FAIL: VC tools still present after masking"; exit 1 } + uses: ./.github/actions/mask-visual-studio - uses: ./.github/actions/bootstrap-mcpp diff --git a/tests/e2e/845_play_game_restores_the_terminal.sh b/tests/e2e/845_play_game_restores_the_terminal.sh index 08c0f812..698a2597 100644 --- a/tests/e2e/845_play_game_restores_the_terminal.sh +++ b/tests/e2e/845_play_game_restores_the_terminal.sh @@ -14,6 +14,12 @@ # terminal's mode, and the prepare action does not outlive mcpp; # G4 with standard input not a terminal the game is off, a line says why, # and the build succeeds. +# +# The arrows and the Ctrl-C are sent when the status row that carries the +# score first appears: the frame the build itself drew is what the test waits +# for, not a wall-clock delay. A delay sent them into the Programs phase +# whenever the build program took that long, and G1 then failed on the phase +# word while the score was on the screen beside it. set -e TMP=$(mktemp -d) @@ -69,7 +75,18 @@ if pid == 0: script = ('trap : INT; stty -g > before.txt; "$0" build --play-game=snake; ' 'code=$?; stty -g > after.txt; exit $code') os.execvpe("sh", ["sh", "-c", script, mcpp], env) -raw, t0, during, sent, status = b"", time.time(), None, False, 0 + +def strip_ansi(blob): + return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", blob.decode("utf-8", "replace")) + +# The game rides every frame, and the row the assertion below names is the row +# to steer on: nothing is gained by guessing when it is up. The build reaches +# Phase::Building only after its build program has compiled and run (and after +# the toolchain was resolved), which on a loaded runner is a second or more — +# so the arrows and the Ctrl-C wait for the row rather than for the clock. +STEER_ROW = re.compile(r"Building .* · snake \d+") +raw, during, sent, status = b"", None, False, 0 +give_up = time.time() + 60 # a build that never draws the row is a failure, not a hang while True: r, _, _ = select.select([master], [], [], 0.1) if r: @@ -78,7 +95,7 @@ while True: except OSError: chunk = b"" raw += chunk - if time.time() - t0 > 1.5 and not sent: + if not sent and (STEER_ROW.search(strip_ansi(raw)) or time.time() > give_up): during = termios.tcgetattr(slave) for key in (b"\x1b[B", b"\x1b[C", b"\x1b[A"): os.write(master, key) @@ -109,7 +126,7 @@ def mode_of(path): for f in g.split(":")) return g before, after = mode_of("before.txt"), mode_of("after.txt") -plain = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", raw.decode("utf-8", "replace")).replace("\r\n", "\n") +plain = strip_ansi(raw).replace("\r\n", "\n") print(plain) assert during is not None, "the build ended before the game could be observed" # G1