Repository navigation
Fix flaky server and WebSocket tests (held locks, listeners, log capture, env) - #868
Merged
Merged
Conversation
…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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/truein 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")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_capture4.
checkpoints::tests::review_r6_automatic_checkpoint_cooldown_is_loggedand 5.extensions::tests::scan_warns_with_legacys_two_log_lines_for_the_two_failure_classestracinglogging 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.ctor, already inCargo.lock), and it keeps only the events it captures. A new test fails without this.6.
net_bind::tests::rebind_serves_a_connection_queued_on_the_retiring_listener(connection reset by peer)7.
term09_output_queue::incident_backlog_spills_instead_of_disconnectingandslow_but_progressing_client_survives_over_threshold_backlog8. Environment-variable collisions (including
real_gateway_outage_preserves_exact_cold_native_history_without_live_authority)HOME,CLAUDE_CONFIG_DIRand 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 readCLAUDE_HOMEwithout any lock while two session-name tests set it; run together, they failed 60 of 60.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.Overall
Full
freshell-serverunit suite, 30 runs at load 77 to 148: the only failures were insettings_store::tests::override_reload_is_throttled_to_a_configurable_window(17 runs) andconcurrent_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 --checkandclippy -D warnings(all targets) are clean forfreshell-serverandfreshell-ws.🤖 Generated with Claude Code