Skip to content

Fix flaky server and WebSocket tests (held locks, listeners, log capture, env) - #868

Merged
danshapiro merged 13 commits into
mainfrom
fix/flaky-server-tests
Oct 23, 2026
Merged

danshapiro merged 13 commits into
mainfrom
fix/flaky-server-tests

Conversation

@danshapiro

Copy link
Copy Markdown
Owner

Fixes a group of server and WebSocket tests that failed now and then in pre-push hooks and GitHub CI, mostly when the machine was busy. No test was skipped, no assertion was loosened, and no sleep was made longer. Each flake was reproduced first, and each fix was checked by repeated runs under load. "Load" below means a load average of roughly 100 to 900 on the 96-core development server, from parallel copies of the test suite plus other agents' work.

The shared cause

Most of these flakes come from one fact. When any thread starts a child process (Git, a shell, a terminal, a sidecar), the child briefly holds a copy of every file and socket the server has open, until it starts its own program. So a lock or listening socket that the server "closed" can stay alive for that moment. A test that started /bin/true in a tight loop from 8 threads made this happen on demand.

Each test

1. session_names::tests::cancelled_wait_retains_executing_lock ("lock releases when the transaction completes")

  • Cause (product): the session-names store released its file lock only by closing its own handle, so a child started during a naming transaction kept the lock held after the transaction ended. With the child-starting loop running, the lock was still held after 156 of 176 commits.
  • Fix: dropping the document lock or the worker lock now unlocks it explicitly, which releases it for every copy. Two new tests hold a child process open mid-start and fail without the fix.
  • After: 0 of 1,014 in the commit-and-check loop; 36 of 36 with the child-starting loop; 40 of 40 in group runs under load.

2. The four "document lock stayed busy" tests (a_capability_paused_head_does_not_starve_the_ready_series_behind_it, an_opencode_native_series_resumes_when_the_shared_serve_arrives_after_boot, the_worker_loop_converges_an_armed_series, capability_pause_consumes_nothing_and_resumes_on_the_wake)

3. checkpoints::tests::cooldown_sends_immediately_without_capture

  • Cause (test): the 1 s limit also covered finding the workspace, which runs Git, and saving the outcome, which syncs files to disk. Under load the whole send took 1.5 to 4.9 s (48 of 48 full-suite runs at load ~800).
  • Fix: the test holds the workspace's capture lock for the whole send, so a cooldown that waited for it would fail. Test builds now record when the workspace was found, and the 1 s limit applies from that moment to the cooldown's answer. A cooldown that stalled for 5 s fails the test.
  • After: 40 of 40 checkpoint-module runs at load 160 to 240, plus the earlier 120 + 60 module runs and 32 full-suite runs.

4. checkpoints::tests::review_r6_automatic_checkpoint_cooldown_is_logged and 5. extensions::tests::scan_warns_with_legacys_two_log_lines_for_the_two_failure_classes

  • Cause (test setup): the tracing logging library remembers, for each log line, whether anyone is listening, the first time that line runs. If another test's thread hit the line first, or hit it while the shared log capture was being installed, the line was marked "nobody listens" for the whole test process. That is how the cooldown log came back empty (19 of 120 module runs). The extension test did not fail in 1,500 module runs, but a standalone program showed the install-time window in 5 of 2,000 runs, and other extension tests hit the same two warning lines while this test installs the capture.
  • Fix: the shared log capture is now installed before any test starts (new dev-dependency ctor, already in Cargo.lock), and it keeps only the events it captures. A new test fails without this.
  • After: 0 failures in 120, 60 and 200-run checks; extensions 300 of 300.

6. net_bind::tests::rebind_serves_a_connection_queued_on_the_retiring_listener (connection reset by peer)

  • Cause (product and test): a child process kept a retired listener open, so new connections queued on a listener nobody served and were reset. The same thing happened in production after a rebind. Separately, the test picked its port on 127.0.0.1 only, so a later rebind to 0.0.0.0 could fail with "address in use".
  • Fix: a retired listener is now shut down explicitly, which stops it on Linux for every copy. The test fixture picks its port on 0.0.0.0. A new test, Linux-only because only Linux stops a listening socket this way, fails without the fix.
  • After: the rebind tests with the child-starting loop went from 21 of 24 failing to 24 of 24 passing.

7. term09_output_queue::incident_backlog_spills_instead_of_disconnecting and slow_but_progressing_client_survives_over_threshold_backlog

  • Cause (test): both read the terminal flood at a fixed ~1 MiB/s and assumed the flood would get far enough ahead to overflow the queue. On a busy machine the flood is produced about that slowly, so the queue never overflowed and the "must produce a gap" check failed (the incident test 1 of 18 at load ~470; the slow-client test 1 of 4 at load ~100).
  • Fix: the reading client keeps reading, but only one frame per pause, until the server's own "queue overflowed" log event appears; then it reads at ~1 MiB/s. Also, the shared reading helper cut the end-of-flood marker out of its buffer before looking for it, so both tests always ran until their 60 s and 75 s deadlines. It now looks first, so both tests end when the flood ends. All assertions are unchanged.
  • After: 32 of 32 runs of both tests at load 150 to 210. The two tests together now take about 50 s instead of about 135 s.

8. Environment-variable collisions (including real_gateway_outage_preserves_exact_cold_native_history_without_live_authority)

  • Cause (test): tests that change HOME, CLAUDE_CONFIG_DIR and similar settings each held a different lock, so they changed the same settings at the same time. That test plus the Claude route-discovery tests failed 76 of 120 runs. Some session-directory tests also read CLAUDE_HOME without any lock while two session-name tests set it; run together, they failed 60 of 60.
  • Fix: one shared guard (TestEnv) now owns the environment for the whole test binary and restores everything it changed, even when a test fails. Tests that can name their inputs directly now do, instead of reading the environment: the session-directory tests pass their test home's Claude folder explicitly.
  • After: 120 of 120 for the first combination; 60 of 60 for the session-directory combination.

Overall

Full freshell-server unit suite, 30 runs at load 77 to 148: the only failures were in settings_store::tests::override_reload_is_throttled_to_a_configurable_window (17 runs) and concurrent_persists_across_two_store_instances_serialize_without_lost_updates (1 run). Neither is touched here; in an earlier side-by-side check the first failed 8 of 16 runs both with and without this branch. They are left for a separate fix. cargo fmt --check and clippy -D warnings (all targets) are clean for freshell-server and freshell-ws.

🤖 Generated with Claude Code

danshapiro and others added 13 commits October 22, 2026 23:04
…d them

A child process forked by any thread of the server (PTY, sidecar or Git
spawns, and in tests every other test's children) shares each open
descriptor until it execs. The session-names store released its flock by
closing its descriptor only, so a child forked while a naming transaction
held `.session-names.lock` kept the lock held after the transaction had
committed: the next participant, including this store's own next
transaction, saw it busy. That is how
`cancelled_wait_retains_executing_lock` failed with "lock releases when
the transaction completes". With eight threads spawning `true` alongside a
commit/probe loop, the lock was still held after 156 of 176 commits.

Dropping the document lock or the worker lock now unlocks the open file
description explicitly before closing it, which releases it for every
descriptor that shares it. Two regression tests park a forked child
between fork and exec while the lock is held and fail without the
explicit unlock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Retiring a listener closed only the server's descriptors. A child process
forked meanwhile by another thread keeps a duplicate until it execs, and
while it does the socket stays in LISTEN: the kernel queues new
connections on it (preferring it over a new wildcard listener for its own
address), nobody accepts them, and they are reset when the child's copy
closes. The rebind barrier ("old listener closed when serve_on returns")
did not hold, and `hundred_rapid_rebinds_never_lose_a_listener_or_reset_a_probe`
saw "Connection reset by peer" on probes made after the barrier.

The accept loop now shuts the socket down after handing off its queue,
which stops it listening for every descriptor that shares it.

The rebind tests also had two fixture races of their own:
- the dropped port owner shares the controller's SO_REUSEPORT port, so a
  forked child holding it could take
  `rebind_serves_a_connection_queued_on_the_retiring_listener`'s
  connection and reset it; the fixture now shuts it down the same way;
- the port was chosen with a loopback-only bind, which can return a port
  that another socket uses on a different address, so the later wildcard
  rebind failed with AddrInUse; the port owner now binds the wildcard.

Under a fork storm the rebind tests failed 21 of 24 runs before this
change and pass 24 of 24 after. A regression test parks a forked child
on the retiring listener and fails without the shutdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tracing-core caches each callsite's interest process-wide the first time
the callsite fires. Two windows cached "never" for the rest of the test
process, silently dropping every later event from that callsite:

- a callsite first fired by another thread while the lazily installed
  global capture was being installed (registered, so the level filter
  already admitted the event, but not yet the default the interest is
  computed from). A standalone repro that fires one callsite while
  another thread installs the global subscriber leaves it stuck in 5 of
  2,000 processes. In this binary the extension tests fire the scan
  warnings while `scan_warns_with_legacys_two_log_lines_for_the_two_failure_classes`
  installs the capture, which is how one of its two warnings went missing.
- a callsite first fired by a thread outside a test's scoped subscriber
  while that scoped subscriber was the only registered one. This is how
  `review_r6_automatic_checkpoint_cooldown_is_logged` captured nothing
  (`captured=[]`): 19 of 120 checkpoint-module runs failed.

The capture subscriber is now installed by a `ctor` before `main`, before
any test thread exists. It keeps only the events it captures: it answers
"sometimes" (never "never") for everything else and rejects those events
in `enabled`, with an INFO level hint, so scoped test subscribers still
receive their events and the rest of the suite does not pay for building
every debug and trace event. Installing an interested-in-everything
subscriber that early slowed timing-sensitive checkpoint tests (39 of 120
runs failed). The one other global installer, a feature-gated
qualification test, now registers an observer on the shared subscriber.

Checkpoint module: 0 of 120 r6 failures after (19 of 120 before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a clock

`cooldown_sends_immediately_without_capture` bounded the whole send by one
second of wall-clock time. The send first resolves the workspace, which
runs Git, so under load the correct path took 1.2 to 4.9 s and failed
(48 of 48 full-suite runs at load ~800; 15 of 16 in an A/B run).

The test now holds the workspace's capture lock for the whole send. A
cooldown attempt must neither wait for that lock nor capture, so one that
did would spend the full automatic budget and settle as
`workspace_lock_timeout`. The elapsed time is bounded by that budget, the
wait the cooldown exists to skip, rather than by a guess. Moving the
cooldown check behind the lock acquisition now fails the test
(`workspace_lock_timeout` after 15 s); the old version could not see that
regression. The test also keys the cooldown and the lock by the resolved
workspace path, as the service does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… fast

`incident_backlog_spills_instead_of_disconnecting` drained its slow client
at a fixed ~1 MiB/s from the start and assumed the PTY flood would outrun
it by the 16 MiB spill bound. On a loaded machine the flood is produced
about as fast as that, so the backlog never reached the bound and the test
failed with "a burst larger than the spill bound must produce an eviction
gap" (1 of 18 runs at load ~470).

The client still never stops reading. Until the server's own
`ws.terminal_stream.queue_overflow_spill` event for this terminal is
captured, it reads one frame (at most 16 KiB) per pause, then it drains at
the original ~1 MiB/s. At that trickle it cannot drain more than a few
MiB of the ~40 MB flood before the existing 60 s deadline, so the backlog
provably crosses the spill bound. Every assertion is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server test binary's env-mutating tests serialized on three different
locks: `HOME_ENV_TEST_LOCK` (HOME/USERPROFILE/FRESHELL_HOME and friends),
`CLAUDE_ENV_TEST_LOCK` (CLAUDE_HOME/CLAUDE_CONFIG_DIR) and module-local
ones (`SNAPSHOT_OUTAGE_ENV_LOCK`, `FROM_ENV_LOCK`). Some tests used none
(the TERM-09 env test, the dotenv tests, the live qualification tests).
Tests holding different locks still changed the same variables at the
same time. Running `real_gateway_outage_preserves_exact_cold_native_history_without_live_authority`
(HOME, CODEX_HOME, CLAUDE_CONFIG_DIR, XDG_DATA_HOME) together with the
Claude route-discovery tests (CLAUDE_HOME, CLAUDE_CONFIG_DIR) failed 76 of
120 runs with a 503 Claude snapshot, and the discovery tests failed 41
times the other way round. Several tests also restored the environment
only after their assertions, so a failure leaked a fake HOME into every
later test.

`crate::test_env_lock::TestEnv` now owns the environment: one lock for the
whole binary (async `lock().await`, sync `lock_blocking()`), `set`/`remove`
through the guard, and every touched variable restored on drop, also when
a failing assertion unwinds. Every mutator and every documented
env-resolving reader uses it. The old locks and ad hoc save/restore
guards are gone. The dotenv tests also stop sharing a fixed directory
under the system temp root, which concurrently running copies of the
binary deleted under each other.

The same three tests together: 120 of 120 pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re first

A deterministic regression test for the failure behind
`review_r6_automatic_checkpoint_cooldown_is_logged`: while a test's scoped
subscriber is registered, another thread outside any scoped subscriber
fires a callsite first; the scoped subscriber must still receive the event
fired under it. Without the capture subscriber installed before `main`,
the first firing caches the callsite as uninterested and the test captures
nothing (fails 0 != 1); with it, the test passes (200 of 200 runs
alongside the extension and r6 capture tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`cooldown_sends_immediately_without_capture` had been loosened from a
1 s bound on the whole send to the 15 s automatic capture budget, so a
cooldown branch that stalled for anything under 15 s still passed (an
inserted 5 s sleep passed in 5.03 s).

The send resolves its workspace (which runs Git) and persists its outcome
(with fsyncs) on every path, cooldown or not, and both follow machine
load. The cooldown policy itself starts once the workspace is known, so
the service now records, in test builds only, when workspace resolution
returned. The test keeps the full send under the held capture lock
(outcome, one dispatch, no capture, no lock wait), then times a second
attempt from that moment to its answer and holds it to the original 1 s.
The same 5 s sleep now fails the test in 5.0 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`a_retired_listener_held_by_a_forked_child_stops_accepting` was gated on
`unix`, but the fix it checks shuts the listening socket down, and only
Linux stops a listening socket that way. macOS and the BSDs answer with
"not connected" (which `stop_listening` accepts) and leave it listening,
so the probe would queue on the retired listener and time out there. The
docs now say the guarantee is Linux's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`drain_throttled_until_marker` trimmed its rolling tail before looking
for the marker. The marker's frame also carries the shell prompt (80
bytes here), so the trim cut the marker away and the drain never stopped
at it: every run of the incident test and of
`slow_but_progressing_client_survives_over_threshold_backlog` read until
its deadline (60 s and 75 s), and the incident test's "pressure window
spans 10 s" check was met by the deadline rather than by the drain.

The drain now looks for the marker first, then keeps only the last
`marker.len() - 1` bytes, which is what a marker split across two frames
needs. Both tests now end when the flood's marker arrives; the 10 s check
holds because the client must read more than 16 MiB at about 1 MiB/s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`slow_but_progressing_client_survives_over_threshold_backlog` drained at
a fixed ~1 MiB/s and needed the flood to get 2 MiB ahead of it before the
queue overflowed. A loaded machine slowed the PTY producer to about that
rate, so the queue never overflowed and the "must evict (gap)" assertion
failed: 1 of 4 runs at load ~100, with no gap before or after the marker.

As in the incident test, the client now reads one frame per pause until
the server's own spill event for this terminal appears in the capture,
then drains at ~1 MiB/s. It reads the whole time, so every pause still
ends in a successful send, and all assertions are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng CLAUDE_HOME

Twelve session-directory tests built a synthetic home and then listed it
through `claude_home(&home)`, which lets a process-wide `CLAUDE_HOME` win,
without holding the crate-wide env lock. Two session-name tests set
`CLAUDE_HOME` to their own homes while they run, so the directory tests
listed the wrong root and found nothing ("left: 0, right: 1"). Run
together with those two tests, the tier-search and index-wiring tests
failed in 60 of 60 runs; in full-suite runs under load three tier tests
failed together once in 60.

All eighteen tests that list a fixture home now pass `<home>/.claude`
explicitly through one helper, as the `TestEnv` discipline asks of any
test that can pin its inputs. Six of them had held the crate-wide env
lock only to make that read safe; they no longer need it. The same
combination, with the whole module: 60 of 60 pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@danshapiro
danshapiro merged commit a902316 into main Oct 23, 2026
7 checks passed
danshapiro added a commit that referenced this pull request Oct 23, 2026
Brings in #864 (Windows/Linux tree-guard fixes and the shared
process_probe test support), #866/#868/#869 (flaky-test fixes) and
#867 (managed helper create retry) on top of core PR-C.

No textual conflicts. Seven files changed on both sides and merged
cleanly; each was checked by hand:
- claude.rs: main's await_create_ownership_committed /
  await_create_adoption_committed helpers and their call sites land in
  PR-C's slimmed test module unchanged (12 uses, same as main).
- claude_snapshot.rs: main's "never clear HOME" fix applies to the
  locate_transcript test; PR-C's genesis tests do not touch HOME.
- claude/teardown_tests.rs, lib.rs: main's zombie-aware ProcessProbe
  module and the REST turn test-pause handshake sit beside PR-C's
  module reshuffle.
- opencode_ws.rs, session_handoff/tests.rs: independent hunks.
- fresh-agent-rollback-rust.spec.ts: main's wait for the codex undo
  refill before typing /redo applies inside PR-C's reworked spec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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