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/tools/git_clone_retry.sh b/.github/tools/git_clone_retry.sh index a5c19060..d4d25f74 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/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/.github/workflows/openkal-cross.yml b/.github/workflows/openkal-cross.yml index f79b40c9..1e6c5f44 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" 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