Skip to content

fix(supervisor): make four flaky supervisor tests reliable - #866

Merged
danshapiro merged 4 commits into
mainfrom
fix/flaky-supervisor-tests
Oct 23, 2026
Merged

danshapiro merged 4 commits into
mainfrom
fix/flaky-supervisor-tests

Conversation

@danshapiro

Copy link
Copy Markdown
Owner

Fixes four freshell-supervisor tests that failed now and then in pre-push hooks and CI, mostly when the machine was heavily loaded. No test was skipped and no assertion was loosened.

The tests, their causes, and the fixes

1. provider_accepted_completion_retries_after_registry_success_on_fresh_healthy_startup

Failed with Registry::open ... Busy when the test reopened the registry. This test had two separate causes:

  • A child process kept the registry lock alive (product bug). The registry lock is an flock. When another thread starts a child process, the child holds a copy of every open file until it runs exec. That copy kept the lock held after the registry was dropped, so a reopen right afterwards got Busy. In a scratch test with 4 threads starting /bin/true while the registry was opened and dropped in a loop, 1,814 of 3,000 reopens returned Busy before the fix and 0 of 3,000 after. Fix: the registry's lock now unlocks explicitly when it is dropped, which releases it for every copy at once (OwnerLock in registry.rs, using std's File::try_lock/unlock, with a warning log if the release fails). New regression test: dropped_registry_releases_ownership_while_a_spawning_child_shares_its_lock.
  • A control connection kept the supervisor until after it had replied (product bug). serve_control answered each connection in a separate task that held its own copy of the supervisor, and so of the registry, until after it wrote the reply. The test reads the reply, drops its handles and reopens the registry. If that task's thread was paused for 10–20 ms between writing the reply and finishing, the reopen got Busy. With a 100 ms pause injected at that point, the test failed 3 out of 3 times; with the fix and the same pause it passed 6 out of 6. Fix: the connection now lets go of the supervisor before it writes the reply (serve_control_connection in service.rs). New regression test: control_connection_releases_supervisor_before_it_replies reopens the registry at the moment the reply is written. It got Busy before the fix and passes after it, for both a valid and a malformed request.

2. recovery_five_natural_successes_exhaust_durable_budget_until_manual_retry

Did not fail in about 1,100 runs. It uses only the registry, so the only way it can get Busy is the child-process lock copy described above. The same registry fix covers it.

3. retained_output_owned_preparing_replacement_keeps_committed_prior

Got Busy 4 times in 96 whole-suite runs, from the same child-process lock copy. The same registry fix covers it.

4. provider_mountpoint_candidate_target_joins_owned_preparation_and_cleanup

Failed in 52 of 64 whole-suite runs under load ("early eof", UnexpectedEof, "image lookup timed out", or losing track of its helper). Cause (test bug): the test used real 250 ms time limits and a 50 ms cancellation, so under load the limits ran out at random points instead of at the stall each case is meant to test. Fix: the test now runs on tokio's paused clock. The fake helper reports when it is holding back the exchange being tested, and only then does the test move the clock past the limit. If the code under test hangs, the test fails after 60 real seconds instead of hanging forever. Removing either of the product's time limits makes it fail that way, as it should. Adds tokio test-util as a dev-dependency.

Stress results after the fixes

  • 48 whole-suite runs, 16 at a time, at machine load 260–547 on 96 cores: none of the tests above failed.
  • 80 runs of just these tests (plus the new regression test), 8 at a time alongside those suite runs: 80 of 80 passed.
  • Earlier runs on this branch: 0 Busy failures in 128 whole-suite runs and in 320 runs alongside process-starting tests; test 4 passed 640/640 and 960/960 on its own under load.
  • cargo fmt --check and clippy -D warnings are clean, and the pre-push hook passed.

Not fixed here

These tests still fail sometimes under heavy load. They fail the same way on main and are outside this change:

  • provider_claude_nested_source_reply_requires_live_scope_after_private_ipc ("must not reacquire the held soul lifecycle lock: Elapsed")
  • provider_claude_source_helper_uses_private_bound_ipc_without_creating_stable_home
  • native_auth_uses_private_ipc_and_cleans_only_verified_owned_helper ("helper must actually run")

🤖 Generated with Claude Code

danshapiro and others added 4 commits October 22, 2026 22:06
Registry ownership is an flock on supervisor.lock, and flock belongs to
the open file description. A child process spawned by any other thread
holds a duplicate of every descriptor between fork and exec
(close-on-exec only closes it at exec), so closing the owner's
descriptor left the root owned until that child exec'd. A successor
opening the registry in that window was refused as Busy. Seen as
intermittent Busy failures in tests that drop a supervisor and cold-open
its registry while neighbouring tests spawn processes
(provider_accepted_completion_retries_after_registry_success_on_fresh_healthy_startup,
retained_output_owned_preparing_replacement_keeps_committed_prior,
recovery_five_natural_successes_exhaust_durable_budget_until_manual_retry).

OwnerLock now unlocks explicitly on drop, which releases the lock for
every duplicate at once, and uses std's File::try_lock/unlock instead
of raw libc flock calls.

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

provider_mountpoint_candidate_target_joins_owned_preparation_and_cleanup
ran on the wall clock with 250 ms preparation/cleanup budgets and a
50 ms caller cancellation. Under machine load those bounds expired at
arbitrary points: mid-request (the fixture's request read failed with
"early eof"), mid private exchange (UnexpectedEof in the fixture's
helper), or before success paths finished ("image lookup timed out"
in the prepared and empty modes).

The test now runs on a paused tokio clock held still by a blocking task
(tokio's documented way to stop auto-advance during real I/O), so no
budget can expire on its own. The fixture reports where it parks the
operation, and the test advances the clock past the first bound only at
that stall: the withheld private exchange after the helper's exit was
answered (timeout, cancel-private-exchange) or the withheld image lookup
(slow-image). If the code under test wedges, the hold ends after 60 real
seconds and the test's own bound fails it instead of letting it hang
(checked by removing the operation and image-lookup deadlines).

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

serve_control answered each connection in a detached task that kept its
Supervisor clone, and so the registry's Arc and owner lock, until after
it had written the reply. A client that read its answer and then handed
the registry to a successor could be refused as Busy whenever that
task's worker was descheduled between the write and the end of the task.
provider_accepted_completion_retries_after_registry_success_on_fresh_healthy_startup
does exactly that: it reads a Recover reply, drops its handles and
cold-opens the registry. A 10-20 ms stall injected after the reply write
made it fail with Busy at the reopen nearly every time.

The per-connection work is now serve_control_connection, which drops its
supervisor handle before it writes the reply, so the reply happens after
the release. The regression test writes the reply through a stream that
cold-opens the registry at the moment the reply goes out; it was refused
as Busy before this change and succeeds after it, for both a valid and a
malformed request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@danshapiro
danshapiro merged commit 90843e1 into main Oct 23, 2026
7 checks passed
danshapiro added a commit that referenced this pull request Oct 23, 2026
Resolves the mountpoint test against #866's frozen test clock: main's
version of the joined preparation test is kept, adapted to this branch's
helper API (HelperDeadlines, AttemptStep, owned HelperContainer). The
launch hand-off modes (a create Docker answers late, a cleanup
inspection that outruns its window) move to their own real-time test,
because the background reaper's removal is real socket I/O after the
run returns; removing the hand-off, or handing off only an unanswered
create, still fails it. A stand-alone helper's removal keeps exactly
its cleanup window.

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>
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