Repository navigation
fix(freshagent): confirm a sidecar tree gone only once every member has exited, and stop the teardown tests' zombie flake - #864
Merged
Conversation
…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
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>
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.
Problem
The Claude sidecar teardown tests (
crates/freshell-freshagent/tests/claude_sidecar_teardown.rs) failed now and then:a_graceful_exit_does_not_leave_the_grandchild_runningsaid the grandchild was still running after the stop was confirmed.an_abnormal_exit_is_not_graceful_and_the_tree_is_killed_at_oncefailed on PR fix(freshclaude): reliable alert bell, hidden-pane approvals, saved default model, and needs-attention marks on mobile #862 (job 120159949492) withSidecarExit { stop: AbnormalExit, elapsed: 6.78ms, status: Some(1), tree: Confirmed }. On PR feat(freshclaude): real-CLI ground truth, CLI simulator, Stop without a banner or a lost bell, and structured sidecar logs (core PR-A) #863,a_session_stop_confirms_the_whole_sidecar_tree_gonefailed the same way (run 39888949438).The "tree guard" makes sure that stopping a Claude session also stops every process under it, including the
claudeCLI the sidecar started. When it reportsConfirmed, 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 orsystemd --useradopts 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
Confirmedas 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:
Zas gone. A process whose main thread has ended while another thread keeps running also readsZ. The guard reportedConfirmedwhile such a process kept running and writing, and it never killed it.Confirmed. A guard that confirmed right after sending its kill signal would have passed every Linux teardown test.Fix
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. Nokill(pid, 0)liveness check is left./proc/<pid>/stat, where a zombie counts as exited once its last thread has ended.Zprocess counts as alive while it still has other threads (/proc/<pid>/statfield 20).freshell-platformprocess_job.rs, used byclaude_spawn.rs):an_abnormal_exit_is_not_graceful_and_the_tree_is_killed_at_oncenow 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; Pythonpthread_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_exitedandterminating_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 teststhe_job_reports_the_processes_in_it_and_every_one_that_joinsanda_member_the_job_no_longer_lists_is_still_watched_once_it_was_reported:Linux repeat runs at
c03749de7(15 tests in the teardown target):Checks:
cargo fmt --all --checkandcargo clippy -p freshell-freshagent -p freshell-platform --all-targets -- -D warningspass on Linux.x86_64-pc-windows-gnuandaarch64-apple-darwinreports no warnings in the changed files. Other files already have warnings on those targets.Full Electron Build on this branch at
c03749de7, run twice:Not changed here
session_lease::proc_starttime_observation(used by the Linux session confirmation) andfreshell-codexsidecar_storealso treat anyZprocess 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.settle_checkpoint_jobalso treats a job count of 0 as done on Windows, so it may have the same early-confirm gap.freshell-serverextensions::tests::scan_warns_with_legacys_two_log_lines_for_the_two_failure_classesfailed once in the pre-push hook and passed on retry. It is unrelated to this change.🤖 Generated with Claude Code