From 46163c28e6197ddac0614bc54f312232e1daef3b Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:34:56 +0700 Subject: [PATCH 1/2] ci: hunt flakes on Windows too, where the exit edge is synthesized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stress workflow ran on Linux and macOS only, which left the leg that became required in 0.10 as the one least exercised — and it is the one whose liveness the harness manufactures rather than reads. On Unix "the terminal closed" is a kernel fact: EOF on the master means every slave descriptor is closed. On Windows the pseudoconsole's output pipe outlives the child, so `EXIT_CLOSES_THE_TERMINAL` and `ExitWatch` synthesize the edge instead — 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 nothing had run it more than once per commit. The other Windows-only path 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`. Three things this is not. Not a blind matrix add: `windows-latest` defaults to `pwsh` and the iteration loop is POSIX shell, so the job now pins `defaults.run.shell: bash` — Git Bash ships `seq` and arithmetic expansion, so one script still serves all three, and on Unix the only change is an unused `pipefail`. Not the same clock: a Windows iteration costs two to three times a Linux one and the serial shard is the long pole, so Windows gets sixty minutes against thirty. Not the same faults at the ends of the thread axis, and the comment says so — at 1 thread it hunts the exit synthesis rather than the macOS teardown race, at 16 it hunts conhost churn on a four-core box. The 94 `#[cfg_attr(windows, ignore)]` tests stay ignored: they are what ConPTY cannot honour, not what is untested. The README's and CONTRIBUTING §3's "Linux and macOS" now name the third. Closes #290 Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- .github/workflows/stress.yml | 37 ++++++++++++++++++++++++++++++++---- CHANGELOG.md | 13 +++++++++++++ CONTRIBUTING.md | 5 +++-- README.md | 4 ++-- 4 files changed, 51 insertions(+), 8 deletions(-) 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..7e1e253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ 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) + ## [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 From 28591b2babf92893ec2d0da45d3552595c50817b Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:48:35 +0700 Subject: [PATCH 2/2] fix(test): put the short deadline on the wait that must expire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `a_query_the_app_moved_past_is_context_not_a_cause` spent one 400ms budget on both of its waits. The first must *succeed* — and the fixture deliberately sleeps 200ms before printing the marker it waits for, so the budget covered a spawn, a 200ms pause and the read with 200ms to spare. On a loaded macOS runner at four threads that margin went, and the test failed on its own setup line while asserting about someone else's diagnosis. The deadline now sits where it is actually needed: the terminal gets ten seconds, and the wait that must expire gets its 400ms through `wait_until_for`. That is the split `probe` already makes in this file, and the three sibling tests on 400ms are unaffected — their single wait is the one that must time out, with no output to wait for in front of it. Found by the stress workflow's first run on the three-OS matrix, at iteration 5 of 5 on the macOS four-thread shard. Refs #290 Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- CHANGELOG.md | 13 +++++++++++++ crates/termlens/tests/queries.rs | 12 ++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e1e253..3574ac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,19 @@ listed under a **Changed** or **Removed** heading. 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/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"),