diff --git a/.github/workflows/stress.yml b/.github/workflows/stress.yml index 59bca24..0b30cd6 100644 --- a/.github/workflows/stress.yml +++ b/.github/workflows/stress.yml @@ -1,8 +1,20 @@ -# Flake hunter: the whole test suite, 100 times, on both OSes. +# Flake hunter: the whole test suite, 100 times, on all three OSes. # # A flaky test harness is worse than none — this workflow is the credibility # gate. Run it before every release and whenever wait/timing code changes. # +# Windows joined in 0.10 (#290), when its ci.yml leg became a required check +# (#280) and therefore acquired a flake budget of zero. It is here for one +# reason the other two legs do not have: on Unix "the terminal closed" is a +# kernel fact — EOF on the master means every slave descriptor is closed — +# while on Windows the pseudoconsole's output pipe outlives the child, so +# the harness *manufactures* the edge instead (`EXIT_CLOSES_THE_TERMINAL`, +# `ExitWatch`: reap the child, drain for `DRAIN_GRACE`, call it closed). +# Polling plus a grace window is the shape a one-in-N flake lives in, and +# until now nothing ran it more than once per commit. The second reason is +# `common::fixture_bin`, which shells out to `cargo build -p` from inside +# the tests, on the one filesystem that refuses to relink a running `.exe`. +# # The 100 are split across SHARDS machines rather than run end to end on one. # Three things follow, and only the last one is a cost: # @@ -36,6 +48,13 @@ # # The thread count is in the job name and in the failure, so a flake arrives # already half-diagnosed: at 1 it is a speed race, at 16 a contention one. +# +# The axis transfers across platforms; the faults at its ends do not. At 1 +# thread Windows is hunting the exit synthesis above rather than the macOS +# teardown race — nothing contends, so a child can be reaped before its last +# write has been drained. At 16 it is hunting conhost churn: windows-latest +# is a four-core box, so sixteen pseudoconsoles opening and closing at once +# is genuine oversubscription rather than a figure of speech. name: stress on: @@ -66,7 +85,7 @@ jobs: # on is most of the diagnosis, and a cancelled shard reports nothing. fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] threads: [1, 2, 4, 8, 16] # Balanced by wall time rather than by count. Measured on this suite: # a serial iteration costs 43s against 16s at four threads and 14s at @@ -85,10 +104,20 @@ jobs: - threads: 16 weight: 25 runs-on: ${{ matrix.os }} + defaults: + run: + # windows-latest defaults to pwsh and the iteration loop below is + # POSIX shell. Git Bash ships `seq` and arithmetic expansion, so one + # shell serves all three OSes and the script stays single-sourced. + # On Unix this only adds `pipefail`, which the loop does not use. + shell: bash # A shard is twenty iterations, which is minutes. Generous, but no longer # two hours: a shard that hangs should say so while the run is still worth - # watching. - timeout-minutes: 30 + # watching. Windows gets double, because an iteration there costs roughly + # two to three times a Linux one and the serial shard is the long pole — + # a timeout that fires on a healthy run is a false alarm that costs more + # than the minutes it saves. + timeout-minutes: ${{ matrix.os == 'windows-latest' && 60 || 30 }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index dae0ece..3574ac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,32 @@ listed under a **Changed** or **Removed** heading. ## [Unreleased] +### Changed + +- **The stress workflow runs on Windows too.** It hunted flakes on Linux + and macOS only, which left the newest leg — required since 0.10 — as the + one whose liveness is *synthesized* rather than observed: Unix reads the + terminal-closed edge off the kernel (EOF on the master), while Windows + manufactures it from a reaped child plus a drain grace, and polling with + a grace window is the shape a one-in-N flake lives in. `fixtures`' + in-test `cargo build -p` is the other, on the one filesystem that + refuses to relink a running `.exe`. Same five `--test-threads` shards, + different faults at the ends of the axis, and double the per-shard clock + because a Windows iteration costs two to three times a Linux one. (#290) + +### Fixed + +- **A query test raced its own fixture's pause.** + `a_query_the_app_moved_past_is_context_not_a_cause` spent one 400 ms + budget on both of its waits — the one that must *succeed* and the one + that must *expire* — while the fixture deliberately sleeps 200 ms before + printing the marker the first one waits for. A spawn plus that pause on + a loaded macOS runner at four threads exceeded the budget, and the test + failed claiming the harness had blamed a probe it should not have. The + short deadline now sits on the wait that must expire and nowhere else, + the split `probe` already made. Found by the stress workflow's first run + on the new three-OS matrix, at 1 in 5 iterations on that shard. (#290) + ## [0.10.0] - 2026-09-08 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 16c555a..ccb8dac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,8 +93,9 @@ cargo insta review # inspect and accept/reject each diff that is itself untested is a liability. - Anything touching wait semantics or timing (`wait.rs`, the reader thread, `wait_idle`) must pass the **stress workflow** (`stress.yml` — the suite in - a 100-iteration loop on Ubuntu and macOS). Trigger it from the Actions tab - on your branch, or ask a maintainer to. + a 100-iteration loop on Ubuntu, macOS and Windows, sharded across five + `--test-threads` values). Trigger it from the Actions tab on your branch, + or ask a maintainer to. - Snapshot updates must be **reviewed diffs**: run `cargo insta review` and look at every change. Never blind-accept with `cargo insta accept` or `INSTA_UPDATE=always`. A snapshot diff you can't explain is a bug report. diff --git a/README.md b/README.md index a29c7dc..64ac174 100644 --- a/README.md +++ b/README.md @@ -308,8 +308,8 @@ design. termlens's position: - **Hermetic environments.** `env_clear()` blocks inheritance, `TERM=xterm-256color` is pinned by default, fixtures draw no clocks and no animations. The CI suite runs a 100-iteration - [stress workflow](.github/workflows/stress.yml) on Linux and macOS — - wait/timing changes don't merge without surviving it. + [stress workflow](.github/workflows/stress.yml) on Linux, macOS and + Windows — wait/timing changes don't merge without surviving it. ## Known limitations diff --git a/crates/termlens/tests/queries.rs b/crates/termlens/tests/queries.rs index e3fd53e..fcd8522 100644 --- a/crates/termlens/tests/queries.rs +++ b/crates/termlens/tests/queries.rs @@ -185,8 +185,14 @@ fn the_responder_can_be_disabled_and_says_what_went_unanswered() { /// not blame it. #[test] fn a_query_the_app_moved_past_is_context_not_a_cause() { + // Ten seconds, not the 400ms the timeout below wants: the wait that must + // *succeed* is racing a spawn plus the fixture's deliberate 200ms pause, + // and a 400ms budget for both lost that race on a loaded macOS runner at + // four threads (found by the stress workflow). A short deadline belongs + // on the wait that must expire, and nowhere else — the same split + // `probe` already makes. let mut t = emit( - Duration::from_millis(400), + Duration::from_secs(10), // Probes kitty (deliberately unanswered), does NOT block on a // reply, prints, then sits in a normal read. The pause forces the // output into a *later read* than the probe — output batched into @@ -198,7 +204,9 @@ fn a_query_the_app_moved_past_is_context_not_a_cause() { .unwrap(); t.wait_until(|s| s.contains("ready")).unwrap(); - let err = t.wait_until(|s| s.contains("never-appears")).unwrap_err(); + let err = t + .wait_until_for(|s| s.contains("never-appears"), Duration::from_millis(400)) + .unwrap_err(); let msg = err.to_string(); assert!( msg.contains("^[[?u"),