Skip to content

fix(freshagent): confirm a sidecar tree gone only once every member has exited, and stop the teardown tests' zombie flake - #864

Merged
danshapiro merged 10 commits into
mainfrom
fix/teardown-test-zombie-probe
Oct 23, 2026
Merged

danshapiro merged 10 commits into
mainfrom
fix/teardown-test-zombie-probe

Conversation

@danshapiro

Copy link
Copy Markdown
Owner

Problem

The Claude sidecar teardown tests (crates/freshell-freshagent/tests/claude_sidecar_teardown.rs) failed now and then:

The "tree guard" makes sure that stopping a Claude session also stops every process under it, including the claude CLI the sidecar started. When it reports Confirmed, the session handoff treats the old session as fully gone.

Proven causes

Linux: the test was wrong. It used kill(pid, 0) to ask whether the grandchild still ran, and that call also finds a zombie (a killed process nobody has reaped yet). The grandchild's parent has already exited, so init or systemd --user adopts it, and on a busy machine it stays a zombie for a while after the guard has, correctly, confirmed it gone. With the test binary as process 1 of a new PID namespace (where nothing reaps orphans), the old test failed 60 of 60 times.

Windows: the product confirmed too early. The guard reported Confirmed as soon as the job object's count of active processes reached 0. Windows drops a process from that count as soon as it starts to exit, but signals its handle only once the exit is finished. That was 3-5 ms later for an idle node and 25-30 ms later for one holding 768 MB, in all 320 diagnostic runs (run 39882455815). Node keeps the children it starts in a kill-on-close job of its own, so a crashing node kills the grandchild while the guard is still collecting node's exit. The guard's first check therefore already read 0.

A review of the first fix found three more gaps, all fixed here:

  1. Linux guard: the guard counted every process in state Z as gone. A process whose main thread has ended while another thread keeps running also reads Z. The guard reported Confirmed while such a process kept running and writing, and it never killed it.
  2. Test strictness: the tests gave a member 5 seconds to finish exiting after Confirmed. A guard that confirmed right after sending its kill signal would have passed every Linux teardown test.
  3. Windows guard: the guard saw only the processes the job listed when a stop began and at each check. A process started during the stop, and killed by node's crash before the next check, had already left the list and was never watched.

Fix

  • Shared test probe (tests/support/process_probe.rs): one zombie-aware check is used by the integration tests and by the two unit tests (teardown_kills_a_grandchild_the_sidecar_left_behind, claude_catalog_probe_timeout_leaves_no_process_behind). It replaces main's inline copy from feat(freshclaude): durable transcript shows compaction, subagents, command output, errors, images, diffs and token usage #860. No kill(pid, 0) liveness check is left.
    • On Linux it reads /proc/<pid>/stat, where a zombie counts as exited once its last thread has ended.
    • On macOS a zombie counts as running until it is reaped.
    • On Windows it waits on a process handle.
  • The probe now checks at once after a confirmed stop. It uses the same rule the guard uses to decide a member is gone, so a guard that confirms too early fails the test. Only kills that nothing confirms (dropping the guard, a test reaping its own zombie on macOS) still get a bounded wait.
  • Linux guard: a Z process counts as alive while it still has other threads (/proc/<pid>/stat field 20).
  • Windows guard (freshell-platform process_job.rs, used by claude_spawn.rs):
    • The guard keeps handles on the job's members. It confirms only when the job's count is 0 and every member's handle is signaled.
    • When a stop begins it also attaches a completion port to the job. The port reports every process already in the job and every process that joins later, and those reports can still be read after the process has started to exit.
    • A failed query counts as "still running".
  • Load margin: an_abnormal_exit_is_not_graceful_and_the_tree_is_killed_at_once now uses a 20 s grace period and still requires the stop to take less than half of it. With 32 copies running at once, node's crash alone took 1.07-1.24 s and tripped the old 1 s limit in 21 of 192 runs. A guard that waits out the grace period still fails the test.

Tests and results

New tests:

  • a_graceful_exit_does_not_leave_a_member_whose_main_thread_ended_running (Linux; Python pthread_exit). Before the fix the guard confirmed at 60 ms with the process still running. After the fix it passes.
  • an_abnormal_exit_is_confirmed_only_once_a_slow_exiting_grandchild_has_exited and terminating_the_guard_is_confirmed_only_once_a_slow_exiting_grandchild_has_exited. Against the old Windows guard they failed 3 of 3 times (run 39884275738).
  • an_abnormal_exit_is_confirmed_only_once_a_grandchild_started_during_the_stop_has_exited, plus the platform tests the_job_reports_the_processes_in_it_and_every_one_that_joins and a_member_the_job_no_longer_lists_is_still_watched_once_it_was_reported:
    • Without the completion port, both the integration test and the member test failed 3 of 3 times on Windows (run 39895152092).
    • With it, the Job Object tests and the teardown target each passed 20 of 20 times (run 39895154418).
  • Probe self-tests: a running process reads as running, an unreaped zombie reads as exited, and a process whose main thread ended reads as running.
  • Mutation check: with the guard changed to confirm right after its signal, 5 teardown tests fail 3 runs out of 3. With the old 5 s allowance, they passed.

Linux repeat runs at c03749de7 (15 tests in the teardown target):

Condition Runs Result
Teardown target, process 1 of a new PID namespace (no reaper) 60 all passed
Same, 24 namespaces at once 120 all passed
Teardown target, 24 copies at once 200 all passed
Teardown target, 32 copies at once 192 all passed
The two unit tests, 32 copies at once 128 all passed
The two unit tests, in PID namespaces 60 all passed

Checks:

  • cargo fmt --all --check and cargo clippy -p freshell-freshagent -p freshell-platform --all-targets -- -D warnings pass on Linux.
  • Clippy for x86_64-pc-windows-gnu and aarch64-apple-darwin reports no warnings in the changed files. Other files already have warnings on those targets.
  • The pre-push hook passed.

Full Electron Build on this branch at c03749de7, run twice:

  • Run 39897673777: every job passed.
    • Windows: the tree-guard step passed 11 of 11 tests and the Job Object step 7 of 7.
    • macOS: the tree-guard step passed 13 of 13 tests on both macos-latest and macos-15-intel.
  • Run 39897683324: every job passed, with the same counts.

Not changed here

  • session_lease::proc_starttime_observation (used by the Linux session confirmation) and freshell-codex sidecar_store also treat any Z process as gone. The Claude teardown does not depend on them, because the tree guard runs first and now waits for such a process and kills it.
  • The checkpoint code's settle_checkpoint_job also treats a job count of 0 as done on Windows, so it may have the same early-confirm gap.
  • freshell-server extensions::tests::scan_warns_with_legacys_two_log_lines_for_the_two_failure_classes failed once in the pre-push hook and passed on retry. It is unrelated to this change.

🤖 Generated with Claude Code

danshapiro and others added 10 commits October 22, 2026 17:17
…decar teardown tests

claude_sidecar_teardown failed once on the GitHub runner (run 39869794728):
"the grandchild node left behind was killed: SidecarExit { stop: Graceful,
elapsed: 507ms, status: Some(0), tree: Confirmed }". The guard was right.
The test's probe was not: it asked kill(pid, 0), which also finds a zombie.
The fake sidecar exits first, so the grandchild the guard then kills is an
orphan. It stays a zombie until whoever adopted it (init, or a subreaper
such as systemd --user) reaps it, and on a loaded machine that can be after
the guard has confirmed the tree gone.

Evidence: the failing assertion reproduces with the CI message every time
when the test binary runs as PID 1 of a new PID namespace (nothing reaps),
in 38/60 runs when orphans are reaped 50 ms late, and in 7 of 1200 runs on
this host under 96-way parallel load with the normal systemd --user reaper.
Each time /proc/<grandchild>/stat at the assertion read state Z, one thread,
exit signal 15, parent = the reaper.

The probes move to tests/support/process_probe.rs:

- Linux: exact, from /proc/<pid>/stat. A process runs until it is gone,
  dead, or a zombie with no other thread left; a start time that changed
  means the pid was reused. A Z process that still has threads is running
  (its main thread ended alone) or still finishing a signal-induced exit
  (seen under heavy load: the other thread was PF_EXITING and gone 100 ms
  later).
- Other Unix (macOS): a zombie cannot be told apart, so it counts as
  running until it is reaped.
- So on Unix, exited() gives a killed process up to 5 s to finish exiting
  and, off Linux, to be reaped. That never hides a process the guard left
  running: a confirmed guard never signals the group again.
- Windows: unchanged (a process handle).

The group checks ("no group member survives") use the same rules. New tests
cover the probes: running until killed, an unreaped zombie (and its group)
counts as exited, and a process whose main thread ended while another runs
still counts as running (python3 pthread_exit, Linux).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd catalog unit tests

teardown_kills_a_grandchild_the_sidecar_left_behind and
claude_catalog_probe_timeout_leaves_no_process_behind asked kill(pid, 0)
whether a grandchild the process-tree guard killed was gone, so they had
the same race as claude_sidecar_teardown: the orphaned grandchild can still
be an unreaped zombie. Both failed 20/20 as PID 1 of a new PID namespace,
and with orphans reaped 200 ms late the catalog test failed 20/20 and the
teardown test 3/20. lib.rs now includes tests/support/process_probe.rs for
the crate's unit tests, and both tests ask it instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reconcile the two zombie-aware probe fixes for claude_sidecar_teardown.
main's 8cd7d95 added an inline /proc check to the test file
(signal_zero_reaches, linux_proc_state, is_zombie,
process_group_has_live_member); this branch moved every liveness probe
into tests/support/process_probe.rs, which the teardown and catalog unit
tests share. Keep the shared module and drop main's inline duplicate: it
also covers a process whose main thread ended while another thread runs,
pid reuse, and macOS, where a zombie can only be seen gone once reaped.

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

A Job Object stops counting a process as active, and stops listing it,
the moment the process starts to exit, but Windows signals the process
(it has exited) only once that exit is done. Measured on windows-2022
with node trees: when the active count first read 0 the member's process
handle was unsignaled in 320 of 320 runs, and signaled 3-5 ms later
(25-30 ms for a member holding 768 MB).

So the active count cannot tell an owner that a whole tree has exited.
Add what it needs:

- KillOnCloseJob::process_ids: the ids the job lists (nested jobs
  included), growing the buffer when more processes than room.
- ProcessExitWatch::open_member: watch an id only if IsProcessInJob says
  it is a member, so an id reused outside the job is never watched.
- JobMembers: record the members while the job lists them, then
  any_running() until every recorded member's handle is signaled.

Windows tests (electron-build "Test Windows kill-on-close Job Object"):
the job lists the child and its descendants; a member watch opens only
on a process in the job; recorded members run until every one has
exited.

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

an_abnormal_exit_is_not_graceful_and_the_tree_is_killed_at_once failed in
the Windows electron-build job (job 120159949492): "the grandchild was
killed: SidecarExit { stop: AbnormalExit, elapsed: 6.78ms, status:
Some(1), tree: Confirmed }". The product confirmed too early. On Windows
the guard's Confirmed meant only "the job counts no active process", and
a job stops counting a process when it starts to exit, before Windows has
finished that exit and signaled it. Node itself is held to more: it must
be reaped (its handle signaled). On Linux an exiting process is counted
until it is a zombie.

Evidence (diag runs on windows-2022, run 39882455815):
- When the count first read 0, the grandchild's handle was unsignaled in
  every run of every shape (node crashing, the guard terminating the job,
  a detached grandchild), signaled 3-5 ms later, 25-30 ms when it held
  768 MB. Its pid had already left the job's process list.
- Node keeps the children it spawns in a kill-on-close job of its own, so
  a crashing node's exit is what kills the grandchild there, while the
  guard is still reaping node. Its first check then read 0.

The guard now records the job's members (handles opened while the job
still lists them) when a stop begins, before a kill, and at every check,
and confirms only when the count is 0 AND every recorded member's handle
is signaled. A failed query still counts as alive (Lingering, never a
false Confirmed).

New tests with a grandchild holding 512 MB, so its exit is slow: an
abnormal exit, and terminating the guard, are each confirmed only once
the grandchild has exited. Against the unfixed guard both failed 3 of 3
on windows-2022 (run 39884275738), with the CI failure's own signature
(AbnormalExit, 7 ms, Confirmed). They pass on Linux before and after.

The probe still answers at once on Windows, and its docs say why a wait
there would be unsound: dropping the guard closes its kill-on-close job,
which would end a process the guard had wrongly reported gone while the
probe waited.

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

On Linux the sidecar tree guard counted every process-group member in
state Z as gone. A process whose main thread ended (pthread_exit) while
another thread runs on also reads Z, so the guard confirmed the tree gone
and marked itself spent while that process kept running, and dropping the
guard never killed it.

A Z member now counts as alive until no other thread of it is left
(/proc/<pid>/stat field 20). A killed process whose other threads are
still finishing their exit is waited for the same way, which matches the
teardown tests' process probe.

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

ProcessProbe::exited and ProcessGroupProbe::exited waited up to 5 s on
Unix before failing, so the teardown tests no longer checked that a member
had exited when the tree guard reported Confirmed. A guard that confirmed
right after signaling the tree passed every Linux teardown test, because
the killed grandchild finished dying inside the allowance.

The probes test exactly what the guard tests for a gone member (Linux: the
same /proc rule, which now also counts a zombie's live threads; macOS:
nothing left in the process group; Windows: every member's handle
signaled), so after a confirmed stop exited() now answers at once. Only
kills nothing confirms keep a bounded wait (exits_within): dropping the
guard, and on macOS a zombie the test reaps itself.

With the guard mutated to confirm right after its signal, 5 teardown tests
now fail 3 runs out of 3; before this change all passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Windows the sidecar tree guard recorded the job's members only when a
stop began and at each check, from the job's process list. A process that
joined the job after the stop began (node starting a child, or the CLI a
hook, during the grace period) and started to exit before the next check
had already left that list, so the guard never watched it. When node's
crash killed it (node keeps its children in a kill-on-close job of its
own), the guard could confirm the tree gone while that process was still
finishing its exit.

The guard's first record now also attaches a completion port to the job.
The job reports every process already in it and every process that joins
it later, and those reports stay readable after the process starts to
exit, so each check watches every member that joined since.

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

an_abnormal_exit_is_not_graceful_and_the_tree_is_killed_at_once checked
that the stop took under half of the 2 s production grace period. On a
loaded machine node's crash alone can take over a second (21 of 192 runs
failed with 32 copies of the target running at once, at 1.07-1.24 s), so
the check failed although the guard killed the tree at once.

The test now passes a 20 s grace period and still requires the stop to
take under half of it, so a guard that grants the tree the rest of the
grace period still fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@danshapiro
danshapiro merged commit bbfde09 into main Oct 23, 2026
15 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>
danshapiro added a commit that referenced this pull request Oct 23, 2026
…ssProbe

The fork and rollback integration tests each carried a private copy of
`process_gone`, with a weaker rule than the ProcessProbe that #864 added
for the freshagent tests: any `Z` state counted as gone (a killed
multi-threaded node whose other threads are still exiting read as gone
early) and the pid was not pinned to its start time (a reused pid read as
still running until the budget ran out).

freshell-ws's test harness now includes the same process_probe.rs file,
and ClaudeRig gains `runtime_pids` and `runtime_processes`, which opens a
probe on the sidecar and simulated CLI while they run (asserting they do,
so a wrong pid can no longer make the wait pass vacuously). The rollback
test opens A's probes before the undo; the fork test opens the child's
while its fork is parked, before the pane closes. Both tests then wait
with `RuntimeProcesses::wait_until_exited`.

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