Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions .github/workflows/stress.yml
Original file line number Diff line number Diff line change
@@ -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:
#
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 10 additions & 2 deletions crates/termlens/tests/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"),
Expand Down
Loading