From 823ac7a519770794e4110130a8bf32a0894da841 Mon Sep 17 00:00:00 2001 From: Zhe Feng Date: Tue, 13 Oct 2026 23:42:04 +0800 Subject: [PATCH 1/2] test: steer e2e 845 by the status row it asserts, not by the clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `845_play_game_restores_the_terminal.sh` sent its arrow keys and its Ctrl-C at a fixed 1.5s after the fork, while the build reaches Phase::Building only after its build program has compiled and run (and after the toolchain was resolved). On the runner where run 38406939721's macos leg failed, that work was 1.24s of the 1.5s budget, so the signal landed in the Programs phase and G1's `Building .* · snake \d+` found no row to match -- while the rows that did reach the terminal carried the score, and only the phase word differed (phase_status() prints `Running` for Phase::Programs). The wait is now the row itself: the arrows and the Ctrl-C go out when the frame the assertion names first appears, which is also the frame that proves the prepare action (the 4s sleep) is running. A 60s cap keeps a build that never draws the row from hanging the leg. The score assertion is unchanged. --- .../845_play_game_restores_the_terminal.sh | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/e2e/845_play_game_restores_the_terminal.sh b/tests/e2e/845_play_game_restores_the_terminal.sh index 08c0f8124..698a2597f 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 From c9161f1509ad259132743daacfa798b1b3c17eac Mon Sep 17 00:00:00 2001 From: Zhe Feng Date: Tue, 13 Oct 2026 23:42:10 +0800 Subject: [PATCH 2/2] fix: retry a refused Visual Studio mask, and keep it in one action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Mask Visual Studio" step renamed `...\Microsoft Visual Studio\*\*\VC` to `VC.masked` in one attempt and treated "Access to the path is denied" as final. That refusal is a state of the host, not of the image: on windows-2025-vs2026 (image 20260925.250) run 38406939721 was refused at 13:59 UTC where the same image committed the same rename at 09:57 and 14:32. Since the step's postcondition is what keeps an incomplete mask from reading as a green "bare Windows" row, the refusal turned into a red required check. The rename is now retried (5 attempts, 2s apart), and a directory that stays locked is answered one level deeper: a handle into VC does not block renaming VC\Tools, and VC\Tools\MSVC is where vswhere's resolution, the environment strategy and the well-known-path scan all end. The postcondition, the env variables it clears and the failure it prints are unchanged. The block had three copies — ci-windows.yml, ci-fresh-install.yml and measure-windows-tool-crt.yml — which is why they could drift; it now lives in .github/actions/mask-visual-studio and the three call sites name it. --- .github/actions/mask-visual-studio/action.yml | 94 +++++++++++++++++++ .github/workflows/ci-fresh-install.yml | 39 ++------ .github/workflows/ci-windows.yml | 50 +--------- .../workflows/measure-windows-tool-crt.yml | 20 +--- 4 files changed, 109 insertions(+), 94 deletions(-) create mode 100644 .github/actions/mask-visual-studio/action.yml diff --git a/.github/actions/mask-visual-studio/action.yml b/.github/actions/mask-visual-studio/action.yml new file mode 100644 index 000000000..8e1239fc2 --- /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 591526ad6..c952e61a1 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 e4f7d0457..84e71d950 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 c3a06c928..713c28a4b 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