Skip to content

test(tui): fix the tests that share process-wide state and fail at random (plan decision 10) - #28

Merged
zy1233 merged 15 commits into
refactor/tui-session-store-leftoversfrom
test/tui-env-race-audit
Oct 20, 2026
Merged

zy1233 merged 15 commits into
refactor/tui-session-store-leftoversfrom
test/tui-env-race-audit

Conversation

@zy1233

@zy1233 zy1233 commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

Summary

Plan decision 10, follow-up. On 2026-10-19 the owner chose race_audit: look for more tests that fail at random because they share process-wide state. #26 found one such pair in pi-shell. This PR looks for the rest and fixes what it finds. It changes tests, scripts and documents only. Production code does not change.

TUI CI cannot find these races. cargo test runs all tests of one binary as threads of one process. They share the environment variables, the current directory and every static. The vendored tests were written for cargo nextest, which starts one process for each test, so their locks cover only a part of what the tests share. TUI CI runs 4 threads in a fixed order, and that order does not find the races. With 32 threads in random order (WSL2, 12 CPUs), 6 of 32 runs of the pi-pager lib at the base of this PR had a failure. After this PR, 0 of 60 runs have one.

The audit found 19 tests that fail at random, and 5 tests that change an environment variable and do not put it back:

Cause Tests that failed Fix
Two groups of tests protect the same GROK_* variables with two locks that do not exclude each other (a #[serial] key and a Mutex) 1 (pi-shell) one shared set of locks (config/test_env_locks.rs)
A test reads a process-wide trace file, and a test that does not hold its lock writes a line to it 1 (pi-pager) the test accepts lines from other tests
Another test forks a child. The child holds a copy of a lock file descriptor until it calls exec, so the heartbeat test gets WouldBlock when it locks again right after it drops its guard 1 (pi-shell) the test retries for up to 5 s
6 process-wide statics that tests set: colour level and native palette, minimal-mode flag, bidi latch, embedded-modal flag, voice gate, theme 16 (pi-pager) the 57 tests that set them run in a process of their own
5 tests set GROK_HOME (one also sets GROK_TEST_VERSION) and do not restore it 0 failed; they leak guards that restore, and serial(GROK_HOME) on 4 of them

Why not a lock for the readers: the readers number in the hundreds (any test that renders text reads the theme), and each round of experiments found new ones. A first attempt with pin_theme() fixed one test at a time and left others. The writers are 57. A test in its own process cannot reach the other tests, so every reader, found or not, is safe.

in_own_process(|| { ... }) (new, pi-pager/src/test_util.rs) runs the body of a test in a child process. The parent starts the same test binary with --exact <name> --test-threads 1, waits, and fails unless the child exits with 0 and reports 1 passed. A failure in the child fails the parent, and the message holds the output of the child. The cost is about 30 ms for each test.

scripts/tui_envspy/                          NEW   env-var probe (LD_PRELOAD), report tool, stress script, README
scripts/tests/test_tui_envspy.py             NEW   10 tests of the probe and the report tool
scripts/tui_baseline.toml                    pi-pager `reference_passed` 5,649 -> 5,651
tui/crates/codegen/pi-pager/
├── src/test_util.rs                         `in_own_process` + 2 self-tests
├── src/{scrollback,app,views,slash}/...     57 tests run through `in_own_process` (11 files)
├── src/memory_trace.rs                      tests accept lines from other tests
└── src/app/{dispatch/tests/session/lifecycle.rs,effects/tests.rs}   guards for `GROK_HOME`
tui/crates/codegen/pi-shell/
├── src/config/{mod.rs,test_env_locks.rs,tests.rs}, src/agent/config_tests.rs   shared locks
└── src/auth/manager/lock_tests.rs           retry the second `flock`
docs, CHANGELOG.md, AGENTS.md                the plan (section 10.20, r17), the execution log, the baselines

Base: refactor/tui-session-store-leftovers (#27, 739d2bb). Not merged. #27 is not merged either.

Read test_util.rs and config/test_env_locks.rs first. In the other test files, the change moves a test body into the closure, so git diff -w shows far less.

Evidence

All runs are on WSL2 (12 CPUs). "Fails" means that a run has at least one failed test. Each cell is runs with a failure / runs.

Run (pi-pager lib unless noted) Before (739d2bb) After
32 threads, random order, seeds 1-32 6 / 32 0 / 60 (seeds 1-60), 0 / 32 (seeds 201-232)
pi-shell, 32 threads, random order, seeds 1-20 2 / 20 0 / 20
4 threads (the thread count of CI), random order, seeds 101-120 0 / 20 0 / 20
32 threads, fixed order (the order of CI), 10 runs 0 / 10 0 / 10
The 7 other suites (lib, 32 threads, random order), 10 runs each not run 0 / 10 for each
  • Pairs. One test that failed, one test that I suspected, 2 threads, 100 runs, both orders, and a control test that has nothing to do with it. Embedded flag: 10 / 100 and 7 / 100 before, 0 / 100 after. Voice gate: 100 / 100 and 100 / 100 before, 0 after. Theme: 96 and 94 / 100 (mermaid), 92 and 90 / 100 (ghost text) before, 0 after. The controls are 0 / 100 before and after. Colour level and minimal mode: 200 / 200 before, 0 / 200 after.
  • Populations. One failed test with a whole module (32 threads, random order, 60 runs): views:: 14 / 60, app:: 9 / 60, scrollback:: 7 / 60 before; 0 / 60 for each after. The 3 rtl_* tests that failed, with three groups of tests that call set_appearance: 52, 44 and 29 / 60 before. After, I ran only the first group again: 0 / 60.
  • The env probe (ENVSPY_TAG; 5 runs of pi-pager and 5 of pi-shell, random order, 32 threads): 0 overlapping changes, 0 leaks, 0 variables still changed at exit. One run of the whole pi-pager lib before the fix of the 5 tests (default order, 12 threads): 3 overlaps, 1 leak, 2 variables still changed at exit.
  • The fork storm (the heartbeat test): one test locks, drops its guard and locks again, 300 times in a loop, while another test forks /bin/true in a loop. 363 of 1,500 loops fail before the fix and 0 of 1,500 without the storm. The fixed test passes 5 of 5 under the storm.
  • in_own_process itself. The 2 self-tests pass. I also broke one isolated test on purpose, in a clone: set_embedded(true) became set_embedded(false) in embedded_fills_area_without_centering, so its assertion fails in the child. The parent test failed, and its message held the output of the child. I restored the clone.
  • The whole manifest (scripts/tui_baseline.py test, WSL2, clean environment, incremental build, about 4 m 56 s) on 88e04e9: 8,490 passed, 0 failed, 21 ignored. 88e04e9 is a local commit from before I put the commits in order. It is not in this PR. The tui/ tree of the last code commit (6438b59) differs from it in 2 places in test_util.rs: a blank line, and the format string of one panic message. I did not run the tests on 6438b59 (see "Not verified"). The lib tests of pi-pager: 5,377 -> 5,379 (the 2 self-tests). With 4 threads on my machine, they take 27.2 to 28.0 s in 3 runs (28.8 s before): the 57 child processes add no time that these runs can show. In CI the lib is slower; see "Cost in CI" below.
  • Gates, on 6438b59. scripts/tui_baseline.py check passes: 0 warnings, 970 packages in the dependency graph, leader 243 and x.ai 297 (both at the ceiling), cargo check --workspace --tests exit 0. rustfmt --check on the 20 changed Rust files: no new hunk (rustfmt 1.8.0, edition 2024).
  • Python, on 6438b59. ruff check . and ruff format --check . pass. Full pytest (real_llm deselected): Windows 2,569 passed, 39 skipped, 31 deselected; Linux (WSL2) 2,578 passed, 30 skipped, 31 deselected. Both include the 10 new tests.
  • CI, on ea9609f and on the last commit 7b27775. 7b27775 changes documents only, but TUI CI runs again because its path filter looks at the whole PR. Both runs of TUI CI (second) pass: 8,490 passed, 0 failed, 21 ignored, and every suite equals its reference_passed. The gates give the same numbers as my run (970 packages, 0 warnings, leader 243, x.ai 297). The cargo test job took 22 m 04 s and 22 m 43 s. CI (second) passes too (ruff, pytest 3.11, 3.12 and 3.13). TUI CI runs 4 threads in a fixed order, so it cannot find these races: a pass does not show that they are gone.
  • Cost in CI. The lib of pi-pager took 15.55 s and 15.79 s in the two runs (libtest time). Five earlier runs without the isolation took 8.21 to 14.83 s (mean 12.1 s). That is about 3.5 s more, or about 60 ms for each isolated test, against 30 ms on my machine. Two runs are a small sample (the earlier runs differ by up to 6.6 s), so this is an estimate. The whole cargo test job takes about 22 minutes. A docs-only commit that I add after this text has a CI run that this text does not list.
  • Not verified:
    • The tests on 6438b59 on my machine, and cargo clippy. I wanted to run the whole manifest, the 60 runs, the 2 self-tests and a negative check on 6438b59. The disk that holds my WSL2 disk file was full (that file only grows), WSL stopped, and one run ended with Bus error. I freed the space, but did not run the tests again. The first test run on this code is the CI above. cargo clippy gave no result in the same incident, so it was not run on the last code commit; TUI CI does not run it.
    • Other machines, macOS and Windows. The tools run on Linux only. 0 / 60 is one sample: a window of a few microseconds can hide in 60 runs.
    • About 140 tests set other settings of the appearance cache (set_show_thinking_blocks 69, set_group_tool_verbs 20, and so on). I did not isolate them. No pair or population run found a victim of them.
    • 2 failures with no known cause: blank_quote_line_survives_drag_copy_as_blank failed once and did not repeat. command_palette_search_bar_cursor_only_when_focused failed in 1 of 60 runs before the isolation, and not after. I suspect the vim-mode setting.
    • The probe lists tests that read GROK_HOME, GROK_TEST_VERSION or GROK_AUTH_EARLY_INVALIDATION_SECS while another test changes them (6 groups in pi-pager, 25 in pi-shell). None of them failed in the runs above. I did not read each group.
    • The named serial keys of pi-shell that the r16 notes list: no test read GROK_AUTH_FORCE_DARK_WAKE while it changed (5 probe runs). heap_profile_hooks has one test. For attribution_emit_count, I did not check which tests reach the counter from outside.
    • Crates outside the manifest and the integration-test binaries (except the three that the probe flagged).

Not in this PR (found, not done)

These need the owner to decide.

  • cargo nextest in TUI CI. One process for each test removes this whole class of races, also for tests that someone writes later. In 16 of 16 runs on aa76df4 (before the isolation), nextest had no failure. The price: the lib tests of pi-pager take 79.5 s instead of 29.7 s with 4 threads (wall time). nextest does not run doctests, so cargo test --doc stays. It changes the CI and the baseline runner, so I did not do it.
  • A rebuild loop caused by protoc. When find_protoc() finds protoc on PATH, it returns the bare name. pi-proto-build then prints cargo:rerun-if-changed=protoc. That path does not exist, so cargo rebuilds pi-tools-api and the crates above it on each call. In TUI CI run 39193752451, one cargo test job compiles pi-tools-api 7 times. Locally, an absolute PROTOC avoids it. Three fixes are possible: return an absolute path in find_protoc, print no rerun-if-changed for a bare name, or set PROTOC in tui-ci.yml.
  • The other two parts of decision 10 (load_mcp_servers / util/config/mcp.rs, and pi-pager/src/recent_dirs.rs): the owner did not choose them.

Merge Danger

Door: two-way

The change adds tests, scripts and documents, and changes test code. A revert brings back the old tests and the old races. No product code, data or wire format changes.

Blast Radius: the test suites of pi-pager and pi-shell, scripts/, and the baseline manifest

zy1233 added 15 commits October 19, 2026 12:55
Four tests of the pager's effects set GROK_HOME to a temporary directory and never put it back; two of them could also run at the same time. The folder-trust test set GROK_HOME and GROK_TEST_VERSION (the simulated release build) for the rest of the process. A test that ran later read a path that was gone, or saw a release build.

The four tests now take the serial key of the other GROK_HOME tests, and each override is a guard that restores the old value before the temporary directory is removed.
…config tests lock

config/tests.rs protects GROK_SUBAGENTS, GROK_RESPECT_GITIGNORE and the GROK_*_MODEL overrides with its own mutexes. The #[serial] tests of agent/config_tests.rs clear the same variables under the serial_test lock. The two locks do not exclude each other, so a serial test could remove GROK_SUBAGENTS while a config test had set it to 1 (config::tests::subagents_config_models_with_env_var_enables failed in 1 of 10 runs with shuffled order and 32 test threads).

The three mutexes move to config/test_env_locks.rs, and clear_runtime_env_vars() now holds all three until the test ends.
…rite

The trace sink is process-wide. A test that does not hold the MEMTRACE_SINK serial key can write a line into it while sample_events_are_valid_jsonl_and_rotate runs: closing an agent writes a purge line. The test failed on that line in 3 of 32 runs with shuffled order and 32 test threads. It now checks the sample shape on the sample lines only and wants at least one. crash_sample_lands_even_when_the_trace_directory_is_missing parsed the whole file as one line; it now counts the crash lines.
dropping_the_guard_silences_the_heartbeat_before_anyone_else_can_hold_the_lock took the lock again at once after the guard dropped and failed with WouldBlock in 1 of 20 runs with 32 test threads. flock belongs to the open file description, and a child that another test forks keeps a copy of the descriptor until it execs. The test now retries the second lock for up to 5 s.
scripts/tui_envspy/ finds the tests of the TUI tree that share process-wide state. envspy.sh runs cargo test with an LD_PRELOAD shim over getenv, setenv, unsetenv, putenv and chdir, and analyze.py lists the tests that change a variable while another test runs, leave it changed, or read what another test has changed (a chaos mode widens the race window). stress.sh runs the lib tests of one package many times with a shuffled order and many test threads. scripts/tests/test_tui_envspy.py tests the analyzer. AGENTS.md says where the tools are and that the vendored tests expect cargo nextest.
With ENVSPY_TAG=<tag>, stress.sh starts every run through the env spy, so the report lists the tests that change an environment variable at the same time in the random orders, also when no test fails. The README says that chaos mode found no failure on this tree (it widens a window only between tests that already overlap), and AGENTS.md says that the locks of the vendored tests are partial because they were written for nextest.
Words after `--` now go to the test binary as filters. With two filters
and --test-threads 2, the script runs one pair of tests many times. This
shows which test breaks which: the first test fails beside the second
and never beside an unrelated test.
stress.sh ran every test of the lib of a package. pi-acp-lib has a test that hangs forever, and the manifest skips it (`skip` of the suite), so the first run of that package never ended. The script now reads the `skip` list of the suite in scripts/tui_baseline.toml and gives each name to the test binary as --skip, as tui_baseline.py does.
…imal-mode tests

Some tests of the pager change a process-wide static: the colour level, the native palette, the minimal-mode flag. Every test that reads the static while the change is in place can fail, and there are hundreds of readers. A lock helps only when every reader takes it.

in_own_process(|| { ... }) in test_util.rs runs the body of a test in a child process. The test starts the same test binary again with `--exact <name> --test-threads 1` and an environment variable; the child runs the body, the parent does not. The parent fails when the child does not pass exactly one test, and it prints the output of the child. Two self-tests check that a change stays in the child and that a failure in the body fails the parent.

The three tests that set the colour level, the native palette or the minimal-mode flag now run in their own process: thinking_body_dim_italic_survives_the_terminal_native_palette, reseed_round_trip_flips_every_mode_derived_gate and minimal_double_ctrl_c_arms_then_quits.
RTL_BIDI_ENABLED is a process-wide latch. The six rtl tests of the text selection turn it on for a few microseconds, and every test that calls set_appearance (more than 60 of them, none takes a lock) turns it off. The rtl tests are both the writers and the victims: beside the tests of one module that calls set_appearance, 52, 44 and 29 of 60 runs failed.

with_rtl_bidi now runs the body in its own process.
…heir own process

modal_window::EMBEDDED and VOICE_MODE_ENABLED are process-wide. Five tests set them for a short time. Four tests of the settings modal read the first flag, and one test that lists the settings of PR 14 reads the second; none takes a lock, so each fails when it runs beside one of the five. In a pair run of 100, the voice victim failed 100 times.

The five tests now run in their own process.
The theme (theme::cache) is process-wide. 43 tests set it through two helpers, with_theme_test_env and with_test_env of the theme command, and every test that renders text reads it. The pin_theme() helper of the project takes a lock, but only the tests that call it exclude each other. In a pair run of 100, a mermaid test failed 96 times and a ghost-text test failed 92 times.

The two helpers now run their closure in its own process, so each of the 43 tests has the theme to itself.
Plan section 10.20, log entry B.12, the baselines (r17, section 3.4), the
CHANGELOG, AGENTS.md and the README of the env spy now say what the
race check found, what changed, and what it did not cover.
…lines

TUI CI ran on ea9609f: 8,490 passed, 0 failed, 21 ignored. CI passed too.
The notes now say that the first test run on 6438b59 is this CI run, that
88e04e9 is a local commit that is not pushed, and that the lib time of
pi-pager in CI (15.55 s) is inside the spread of earlier runs (8.21 to
14.83 s), so CI cannot tell the cost of the 57 child processes.
Two TUI CI runs (ea9609f and 7b27775) show a lib time of 15.55 s and
15.79 s for pi-pager. Five earlier runs without the isolation took 8.21 to
14.83 s. The notes said that CI cannot tell the cost. Two runs in the top
of the range suggest about 3.5 s more, about 60 ms for each isolated test.
The notes now say this as an estimate from a small sample.
@zy1233
zy1233 added this pull request to stack #32 October 20, 2026 06:16
@zy1233
zy1233 merged commit ce29cd8 into main Oct 20, 2026
7 checks passed
@zy1233
zy1233 deleted the test/tui-env-race-audit branch October 20, 2026 06:19
zy1233 added a commit that referenced this pull request Oct 25, 2026
…to #31 (plan r19) (#33)

The owner asked for three things: merge the five open PRs (#27 to #31) into main with
`gh stack`, set the V0.6 milestone from the present state, and pause development for
1 to 2 days.

The merge is done (stack #32, `gh stack merge 32 --yes --squash`). main goes from
7d82c27 to b33017a with five squash commits. The tree of each commit is the tree of its
PR head. CI and TUI CI pass on b33017a (8,490 passed, 0 failed, 22 skipped).

The plan gets a new section "发布里程碑 V0.6": the scope (the 10 commits on main after
v0.5.0), six exit conditions with their state, the known issues (two are new), and what
is not in V0.6. It also gets the r19 status, the pause, and the fixed status of the
places that still said "waiting for merge" for #25 to #28. The execution log gets
section 10.22: the install of `gh stack` (the first download was cut short), the merge,
the check of the trees, CI on main, the milestone, the pause, and what is not verified.

The GitHub milestone v0.6.0 (#2) is created. It has no due date and no PRs, as v0.5.0.
No condition is met yet. Only documents change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant