From 608d3072cb867f962b71950c7920283411de3fb0 Mon Sep 17 00:00:00 2001 From: Zhe Feng Date: Tue, 13 Oct 2026 21:38:46 +0800 Subject: [PATCH 1/2] fix: resolve the openkal index and runtime revisions once per run The four build jobs of openkal-cross.yml each cloned `main` of the index and of the runtime of their own accord, at four different moments on four runners that become available at four different times, and the run jobs then asserted that all four had built the same sources. On 2026-10-13 that assertion failed for a reason the change under test had nothing to do with: run 38399400601 started at 12:43 UTC, its build jobs at 12:52:08 through 13:01:33, and mcpp-index@main took four commits inside that window (21b9a77a 12:56:12, d3a328c8 13:04:42, cfb808bc 13:10:06, 0340de61 13:17:58), so the four clones straddled pushes and the run jobs reported `index sources differ`. Resolve both refs once, in a new `revisions` job, and have every host materialise the commit it names. OPENKAL_BRANCH keeps its meaning: an override still verifies exactly what it named, now as one commit rather than as whatever the branch had become when each runner woke up. The run jobs keep their cross-host comparison; it is what proves a host used the pin rather than re-resolving for itself. git_clone_retry.sh grows the two primitives that needs: `--rev `, which prints the commit a ref names without downloading the tree, and `--commit `, which materialises exactly that commit, both under the retry the helper already exists for. Writing a SHA into the workflow instead would be the pin-that-keeps-a-check-green-by-keeping-it- out-of-date that the file itself warns about. --- .github/tools/git_clone_retry.sh | 125 ++++++++++++++++++++++++---- .github/workflows/openkal-cross.yml | 86 ++++++++++++++++++- 2 files changed, 194 insertions(+), 17 deletions(-) diff --git a/.github/tools/git_clone_retry.sh b/.github/tools/git_clone_retry.sh index a5c190605..d4d25f742 100755 --- a/.github/tools/git_clone_retry.sh +++ b/.github/tools/git_clone_retry.sh @@ -38,6 +38,8 @@ # and their attention, a slow true red costs 50 seconds. # # Usage: git_clone_retry.sh [git clone args...] +# git_clone_retry.sh --rev # print the commit it names +# git_clone_retry.sh --commit # that commit, exactly # Env: GIT_CLONE_ATTEMPTS (default 4), GIT_CLONE_BACKOFF (default "5 15 30") set -euo pipefail @@ -50,28 +52,121 @@ read -r -a BACKOFF <<< "${GIT_CLONE_BACKOFF:-5 15 30}" # different reason than the first and the log stops making sense. dest="${*: -1}" +# `cmd || rc=$?`, NOT `if cmd; then …; fi; rc=$?`. +# +# After an `if`, `$?` is the status of the IF STATEMENT — which is 0 when the +# body did not run. Written that way this script exits 0 on a clone that never +# succeeded, i.e. it turns a hard failure into a green step with a missing +# checkout. Caught by asserting the exit code in the helper's own test rather +# than by reading the output, which looked correct. +# +# ONE COPY OF IT, for all three verbs below. They back off over the same +# sequence and give up in the same words, so the retry is a property of the +# helper rather than of whichever verb was written last. +failed() { # $1 = attempt number (1-based), $2 = exit status of that attempt + if (( $1 == ATTEMPTS )); then + echo "git_clone_retry: giving up after $ATTEMPTS attempts (last exit $2)" >&2 + exit "$2" + fi + delay="${BACKOFF[$(( $1 - 1 < ${#BACKOFF[@]} ? $1 - 1 : ${#BACKOFF[@]} - 1 ))]}" + echo "git_clone_retry: attempt $1/$ATTEMPTS failed (exit $2); retrying in ${delay}s" >&2 + sleep "$delay" +} + +# ── TWO MORE VERBS, AND WHY THEY ARE IN THIS FILE ────────────────────────── +# +# `--rev` and `--commit` exist because "clone this branch" stopped being one +# act the moment four jobs needed it. Every `build` job of `openkal-cross.yml` +# cloned `main` of the index and of the runtime of its own accord, on runners +# that become available at different moments — the macOS leg waits in a queue +# the others do not — and the `run` job then asserted that all four had built +# the same sources, and failed the run when they had not. On 2026-10-13 that +# assertion was right to fail and the change under test had nothing to do with +# it: the run started at 12:43 UTC, its build jobs at 12:52:08 through +# 13:01:33, and mcpp-index@main took four commits between 12:56:12 and +# 13:17:58 — so clones taken minutes apart straddled pushes, and the run job +# reported `index sources differ`. +# +# The repair is to resolve the ref ONCE, at the start of the run, and have +# every host materialise that commit. Writing the SHA into the workflow would +# be the pin nobody is reminded to move that `openkal-cross.yml` warns about in +# its own header, so the resolution is a job — and these are the two primitives +# it needs: ask what a ref names, and get exactly that commit. + +# `--rev `: print the commit the ref names. +# +# `ls-remote` rather than clone-and-rev-parse: what the caller wants is one +# number, and the tree behind it is tens of megabytes on both of the +# repositories this is pointed at. +if [[ "${1:-}" == "--rev" ]]; then + [ "$#" -eq 3 ] || { echo "git_clone_retry: usage: --rev " >&2; exit 2; } + ref="$2"; url="$3" + # A bare name is a BRANCH. `refs/…` and `HEAD` are taken as written, so a + # tag or a fully qualified ref can be asked for too. + case "$ref" in + refs/*|HEAD) want="$ref" ;; + *) want="refs/heads/$ref" ;; + esac + for (( i = 1; i <= ATTEMPTS; i++ )); do + rc=0 + # stderr is NOT captured: what git says about a URL it cannot reach is + # the diagnostic, and it is the message a plain clone would have shown. + listing=$(git ls-remote "$url" "$want") || rc=$? + if (( rc == 0 )); then + sha=$(printf '%s\n' "$listing" | awk -v w="$want" '$2 == w { print $1; exit }') + # No such ref is an ANSWER, not a blip: the host was reached and + # said the ref is not there, so retrying only delays the report. + [ -n "$sha" ] || { echo "git_clone_retry: $url has no $want" >&2; exit 1; } + printf '%s\n' "$sha" + exit 0 + fi + failed "$i" "$rc" + done +fi + +# `--commit `: materialise exactly that commit, and nothing +# the branch did afterwards. +# +# NOT `git clone --branch `: that argument takes a branch or a tag, and a +# commit is neither. `init` + `fetch --depth 1 origin ` is the shape that +# names a commit, and however far the branch moves after the resolution, it +# costs one commit's tree once. +if [[ "${1:-}" == "--commit" ]]; then + [ "$#" -eq 4 ] || { echo "git_clone_retry: usage: --commit " >&2; exit 2; } + sha="$2"; url="$3" dest="$4" + [[ "$sha" =~ ^[0-9a-f]{40}$ ]] \ + || { echo "git_clone_retry: --commit wants a 40-hex commit, got '$sha'" >&2; exit 2; } + for (( i = 1; i <= ATTEMPTS; i++ )); do + rc=0 + { + git -c init.defaultBranch=main init --quiet "$dest" && + git -C "$dest" remote add origin "$url" && + git -C "$dest" fetch --quiet --depth 1 origin "$sha" && + git -C "$dest" checkout --quiet --detach FETCH_HEAD + } || rc=$? + if (( rc == 0 )); then + exit 0 + fi + # Nothing a failed attempt made survives it, for the reason the clone + # path gives below about its partial clone: a tree half-made by this + # attempt is not a retry, it is a second failure with a different cause. + # And a caller that gives up should not find a directory claiming to be + # the checkout it asked for. + rm -rf -- "$dest" + failed "$i" "$rc" + done +fi + for (( i = 1; i <= ATTEMPTS; i++ )); do - # `cmd || rc=$?`, NOT `if cmd; then …; fi; rc=$?`. - # - # After an `if`, `$?` is the status of the IF STATEMENT — which is 0 when - # the body did not run. Written that way this script exits 0 on a clone that - # never succeeded, i.e. it turns a hard failure into a green step with a - # missing checkout. Caught by asserting the exit code in the helper's own - # test rather than by reading the output, which looked correct. rc=0 git clone "$@" || rc=$? if (( rc == 0 )); then exit 0 fi - if (( i == ATTEMPTS )); then - echo "git_clone_retry: giving up after $ATTEMPTS attempts (last exit $rc)" >&2 - exit "$rc" - fi - if [[ -n "$dest" && "$dest" != -* && -e "$dest" ]]; then + # Nothing to remove on the last attempt: there is no retry to prepare for. + if (( i < ATTEMPTS )) && [[ -n "$dest" && "$dest" != -* && -e "$dest" ]]; then echo "git_clone_retry: removing partial '$dest' before retrying" >&2 rm -rf -- "$dest" fi - delay="${BACKOFF[$(( i - 1 < ${#BACKOFF[@]} ? i - 1 : ${#BACKOFF[@]} - 1 ))]}" - echo "git_clone_retry: attempt $i/$ATTEMPTS failed (exit $rc); retrying in ${delay}s" >&2 - sleep "$delay" + failed "$i" "$rc" done diff --git a/.github/workflows/openkal-cross.yml b/.github/workflows/openkal-cross.yml index f79b40c91..1e6c5f44b 100644 --- a/.github/workflows/openkal-cross.yml +++ b/.github/workflows/openkal-cross.yml @@ -44,6 +44,13 @@ name: openkal cross-build (4 hosts × 3 targets) # Three cells are native. The nine crosses include every output built on Linux # ARM64, and are what a single-host workflow cannot reach. # +# A `revisions` JOB RUNS FIRST, before any of them, and resolves the index and +# the runtime branches to one commit each. The four build jobs materialise +# those commits, because "the four hosts built the same sources" — the claim +# the run jobs assert — needs one revision to be about. Four independent +# clones of a moving `main` are not that, and on 2026-10-13 they were not one +# revision (run 38399400601). See the job. +# # THE RUN JOBS INSTALL NOTHING — not mcpp, not a compiler, not a C runtime. # A program above openkal carries its C library, its C++ runtime and its # unwinder; what remains is the operating system it was built for. If a @@ -93,8 +100,75 @@ env: OPENKAL_BRANCH: ${{ github.event.inputs.openkal_ref || 'main' }} jobs: + # ── THE TWO REVISIONS EVERY HOST BUILDS AGAINST, RESOLVED ONCE ─────────── + # + # FOUR JOBS CLONING ONE BRANCH ARE NOT FOUR COPIES OF ONE REVISION. The build + # matrix cloned `main` of the index and of the runtime of its own accord, on + # runners that become available at different moments — the macOS leg waits in + # a queue the others do not — and the `run` job below then asserted that all + # four had built the same sources. + # + # THAT ASSERTION WAS RIGHT AND IT WENT RED FOR A REASON NO HOST CAUSED. On + # 2026-10-13, run 38399400601, all three `run` jobs failed with + # + # index sources differ + # + # — so the four hosts did record different revisions. The run started at + # 12:43 UTC and its build jobs at 12:52:08, 12:53:44, 12:58:20 and 13:01:33 + # UTC, and they do not clone at the same point in their own step sequence — + # the macOS leg builds this commit's mcpp first — while mcpp-index@main took + # four commits inside that window: 21b9a77a at 12:56:12, d3a328c8 at 13:04:42, + # cfb808bc at 13:10:06, 0340de61 at 13:17:58. Two clones straddling one push + # is all it takes, and the reading arrives 40 minutes later, in a job that + # built nothing. + # + # THE ALTERNATIVE — pinning a SHA into this file — is the failure + # `OPENKAL_BRANCH` above is annotated with: a pin nobody is reminded to move, + # which keeps a check green by keeping it out of date. So the refs are + # resolved here, once, and every host materialises the commit named below. + # `OPENKAL_BRANCH` keeps its meaning: a run that overrides it still verifies + # exactly what it named, now as one commit rather than as whatever the branch + # had become when each runner woke up. + # + # The `run` job keeps its comparison. It is no longer a race against a moving + # branch; it is what proves each host used the pin rather than re-resolving + # for itself. + revisions: + name: the index and runtime revisions this run builds + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + index: ${{ steps.resolve.outputs.index }} + runtime: ${{ steps.resolve.outputs.runtime }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + + - name: Resolve both refs, once + id: resolve + run: | + set -euo pipefail + RETRY="$GITHUB_WORKSPACE/.github/tools/git_clone_retry.sh" + index=$("$RETRY" --rev main https://github.com/mcpp-community/mcpp-index) + runtime=$("$RETRY" --rev "$OPENKAL_BRANCH" \ + https://github.com/mcpplibs/openkal-llvm-runtime) + # The `run` job's comparison already refuses a SHA that is not 40 hex; + # this refuses to PIN one, so a mis-resolution fails here, in 10 + # seconds, rather than as four builds of four different trees. + for commit in "$index" "$runtime"; do + [[ "$commit" =~ ^[0-9a-f]{40}$ ]] \ + || { echo "::error::not a commit: '$commit'"; exit 1; } + done + echo "mcpp-index@main is $index" + echo "openkal-llvm-runtime@$OPENKAL_BRANCH is $runtime" + echo "index=$index" >> "$GITHUB_OUTPUT" + echo "runtime=$runtime" >> "$GITHUB_OUTPUT" + build: name: build 3 targets on ${{ matrix.host }} + needs: revisions runs-on: ${{ matrix.runner }} timeout-minutes: 120 strategy: @@ -238,6 +312,9 @@ jobs: command -v x86_64-w64-mingw32-gcc gcc 2>/dev/null || true - name: The program — one source, three targets + env: + INDEX_SHA: ${{ needs.revisions.outputs.index }} + RUNTIME_SHA: ${{ needs.revisions.outputs.runtime }} run: | set -euo pipefail # THE SEVENTH CALL SITE. `git_clone_retry.sh` was written because a @@ -251,11 +328,16 @@ jobs: # # One name that did not resolve ended a 120-minute job in its first # minute, beside a real failure it had nothing to do with. + # + # BOTH CLONES NOW NAME A COMMIT. `--depth 1 -b main` was four jobs + # describing a moving branch as one revision; see the `revisions` job + # for the run that paid for it. "$GITHUB_WORKSPACE/.github/tools/git_clone_retry.sh" \ - --quiet --depth 1 -b "$OPENKAL_BRANCH" \ + --commit "$RUNTIME_SHA" \ https://github.com/mcpplibs/openkal-llvm-runtime "$RUNNER_TEMP/okl" "$GITHUB_WORKSPACE/.github/tools/git_clone_retry.sh" \ - --quiet --depth 1 https://github.com/mcpp-community/mcpp-index "$RUNNER_TEMP/openkal-index" + --commit "$INDEX_SHA" \ + https://github.com/mcpp-community/mcpp-index "$RUNNER_TEMP/openkal-index" export MCPP_OPENKAL_INDEX="$RUNNER_TEMP/openkal-index" cd "$RUNNER_TEMP/okl/examples/same-source" mkdir -p "$RUNNER_TEMP/out" From 5ff06786fe34da7d76e3cd4241dcca8a724793de Mon Sep 17 00:00:00 2001 From: Zhe Feng Date: Wed, 14 Oct 2026 06:06:15 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20two=20flaky=20required=20checks=20?= =?UTF-8?q?=E2=80=94=20the=20Visual=20Studio=20mask=20and=20e2e=20845=20(#?= =?UTF-8?q?806)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: steer e2e 845 by the status row it asserts, not by the clock `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. * fix: retry a refused Visual Studio mask, and keep it in one action 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. (cherry picked from commit b5630e200b568e377101171aa7b4df13ff43eb55) --- .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 +--- .../845_play_game_restores_the_terminal.sh | 23 ++++- 5 files changed, 129 insertions(+), 97 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 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