An Error in StatusPoller.loop or SessionReaper.loop kills the thread permanently and silently, and start() then refuses to restart it #538
Closed
opened 2026-09-12 08:34:18 +02:00 by ltms
·
1 comment
No Branch/Tag Specified
main
worker/fleetd-612-unita-87807e-1
worker/612-b3-mcpwirings-da2b58-3
worker/612-b2-cb185-176d3a-2
worker/612-b1-completion-457459-1
worker/612-agaps-73a926-2
worker/608-sleeps-3a64ff-3
worker/621-b4520b-1
worker/618-b83894-2
worker/fleetd-615-e05481-5
worker/lead-autocompact-5f1ab2-3
worker/fleetd-613-f85deb-3
worker/fleetd-608-flaky-nudge-test-d0c2d1-3
worker/lead-context-gauge-ad404f-1
worker/gauge-wiring-9158c1-4
worker/redeploy-slowstart-ead0e5-5
worker/charter-bytes-13668c-6
worker/rollover-outcome-291483-2
worker/589-f64303-2
worker/593-1a8025-5
worker/589-fcd2aa-1
worker/568-9fdaa2-3
worker/571-attempted-outcome-5739f7-2
worker/581-completionresolver-cas-sites-0542b7-6
worker/562-loop-health-wiring-test-99611c-5
worker/562-surface-loop-health-7df5cc-4
worker/575-waiter-cleanup-sites-62ad80-1
worker/572-answer-lock-release-46a9ae-5
worker/567-probe-channel-leak-a38fc5-6
worker/551-record-before-send-7cbf56-1
worker/561-listener-fanout-survives-a-throw-61d538-2
worker/555-redeploy-main-flow-seam-65c2f5-2
worker/556-injector-owns-registration-e027a5-1
worker/552-post-restart-mktemp-abort-bc2672-4
worker/553-onstatus-completion-leak-0da881-2
worker/550-shasum-linux-196132-1
worker/538-loop-dies-on-error-4a5eeb-6
worker/426-health-coverage-ef1fd4-4
worker/504-failed-reported-clean-3cfd66-3
worker/537-capturedlog-close-e4c437-2
worker/459-broken-link-targets-cadc17-5
worker/535-appender-leak-fe74c1-1
worker/512-part2-shutdown-detection-434701-9
worker/529-logger-level-sweep-2a5533-8
worker/528-drain-gate-call-site-5de83d-7
charter/forge-mcp-vs-token
worker/521-swap-guard-unpinned-28e931-5
worker/519-probe-test-harness-d25ab8-4
worker/525-logger-level-leak-1b4eb0-6
worker/518-fleetmcp-resolver-wiring-8ef96c-1
worker/512-drain-complete-line-7edd71-3
worker/517-abort-branch-and-jar-id-41b641-2
worker/500-9e52c9-3
worker/509-4912f4-2
worker/511-9a4b23-1
worker/493-479f45-2
worker/505-03f8b2-1
worker/492-followup-detect-unclear
worker/501-a31fa0-7
worker/498-451d1c-5
worker/494-1015ce-2
worker/492-209647-1
worker/489-001902-2
worker/480-relative-handover-path-906323-1
worker/480-b-handover-skill-45bf1f-5
worker/474-followup-source-pin-f54a55-17
worker/474-charter-check-on-reload-f54a55-17
worker/466-quarantine-repeatcount-report
worker/393-opencode-skill-seeding-71854b-13
worker/469-canonical-tool-names-2a472a-16
worker/466-quarantine-escalation-5ae9c1-15
worker/446-hot-exhausted-pattern-0af580-6
worker/464-charter-tool-name-guard-a85635-12
worker/463-listfleet-default-fails-open-f1c76c-11
worker/458-invariant-5-by-purpose-862f9a-10
worker/439-coordinator-row-gate-bc032a-8
worker/449-herdr-protocol-576015-4
worker/450-abstract-spawn-599e1c-5
worker/437-ack-refuses-177d91-1
worker/444-placement-window-feb56a-2
worker/440-helddurable-derived-d462d7-13
worker/425-rework-placement-resolve-c58ba1-9
worker/421-lead-peek-held-msgs-cdbad2-10
worker/435-fixed-policy-cap-fe11de-12
worker/422-gate-state-observability-9e79d6-11
worker/431-memberregistry-live-readers-cdbad2-10
worker/424-architect-slot-hot-038b41-7
worker/422-model-gate-spawn-c29f48-6
worker/425-default-profile-live-f55534-8
worker/415-coverage-wording-2cbf9c-5
worker/416-3ad1da-1
worker/418-588283-3
worker/deterministic-stamp-race-409-3cb7b6-10
worker/armed-reads-live-config-404-ed931f-9
worker/reply-peer-refusal-391-5a34bd-7
worker/models-allowlist-aa9e9b-3
worker/ttl-stamp-race-399-f1122f-8
worker/scrub-receipt-400-316b3e-5
worker/exhaustion-detection-395-105105-6
worker/scrub-abort-394-316b3e-5
fix/scrub-uid-abort
worker/task-scrub-517574-2
worker/t386-clock-bd5b78-4
worker/t384-scrub-813790-5
worker/t381-cc-748314-2
worker/t373-336973-2
worker/t365-3920c5-3
worker/t358-6e989b-1
worker/t355-8b321c-1
worker/fleetd-369-hermetic-git-tests-e8b19a-3
worker/fleetd-368-stale-lead-binding-f5682e-2
worker/fleetd-360-deploy-units-0d3793-1
worker/359-dead-lead-tabs-f1253b-4
worker/362-worktree-skills-c03e51-3
worker/361-coord-visibility-655144-1
362-plugin-visibility-and-drift
worker/errscan-bed2ca-2
worker/amqp-log-identity-bed2ca-2
worker/withdefaults-guard-561704
worker/sleepguard-82076d-1
worker/fd334-9ee1b6-5
worker/fd348-f1ab27-4
worker/fd335-a71c35-1
worker/fd342-174a17-2
worker/fd345-490d0f-3
worker/fleetd-337-5ec7d4-21
worker/fleetd-341-af5a6b-24
worker/fleetd-339-5ca0a2-23
worker/fleetd-338-83a4a1-22
worker/fleetd-333-281f46-18
worker/fleetd-329-11bdbb-16
worker/fleetd-330-2770fb-17
worker/fix-326-50506e-15
worker/fix-324-3e9bbf-14
worker/fix-323-b8287d-13
worker/fix-316b-bd0860-11
worker/fix-318-76ca36-9
worker/fix-317-486aec-8
worker/fix-315-ce47c5-6
worker/fix-307-275890-6
worker/fix-308-b4f664-7
worker/fix-309-ec3939-8
worker/fix-310-7a3974-9
worker/fix-302-52ad0e-9
worker/fix-298-ce1acb-8
worker/fix-297-66bd11-7
worker/fix-296-104622-6
worker/fix-293-bare-closetab-eb22b5-3
worker/fix-280-gone-ask-lapse-bca98e-2
worker/fix-290-reapidle-guard-coverage-9b0dd1-1
worker/fix-285-trust-seed-8f3565-10
worker/fix-284-backend-error-seat-85912c-11
worker/fix-282-chained-ask-e6d0bb-8
worker/fix-283-teardown-leaks-f40dfa-9
worker/fix-281-pin-handler-actions-4921ac-7
worker/audit-rendezvous-lifecycle-d072ae-2
worker/audit-health-placement-1a2476-6
worker/audit-teardown-exits-e207a5-3
worker/audit-launcher-asymmetry-27e370-4
worker/audit-rest-authz-6ca53c-5
worker/investigate-275-abandon-asking-fdef52-8
worker/fix-274-worktree-leak-b0095d-7
worker/fix-273-exhausted-pattern-9665b5-6
worker/fleetd-267-model-check-bd8068-1
worker/fleetd-131-archunit-18b834-7
worker/fleetd-266-sshagent-rename-a014ff-6
worker/fleetd-184-uid-claim-8e1f31-4
worker/fleetd-184-warn-b381ee-10
worker/fleetd-184-docs-be1d12-9
worker/fleetd-257-9bf010-7
worker/fleetd-103-23a113-6
worker/fleetd-247-342356-5
worker/fleetd-116-04dea8-4
worker/fleetd-252-a830e0-3
worker/fleetd-111-7e8673-9
worker/fleetd-155c-f8ef4b-8
worker/fleetd-176-b928ca-3
worker/fleetd-249-7a7878-2
worker/cb248-composition-root-b-9acdf7-15
worker/cb148-envrc-default-fa6c82-12
worker/cb201-unit5-wiring-6c12e6-8
worker/cb241-fallback-echo-1175e9-11
worker/cb149-trust-dialog-2392a5-9
worker/cb134-148-overlay-visible-c9b986-10
worker/cb234-session-id-keyed-04e1fc-1
worker/cb201-unit3-nudge-abdf5c-6
worker/cb201-unit2-policy-c1102c-5
worker/cb201-unit4-outcome-a13bfa-7
worker/cb201-unit1-classifier-91b9b1-4
worker/cb201-227-refine-831980-3
worker/cb175-model-readback-0f085f-1
worker/cb222-charter-tmpdir-17f013-1
worker/cb226-architect-slot-race-cd3aa8-3
worker/cb224-worktree-root-group-024523-2
worker/cb-123-role-demotion-c600f7-2
worker/cb-219-opencode-roots-1f677e-1
worker/cb214-claude-session-id-b9eab4-4
worker/cb213-zdotdir-wrong-process-dd6de4-3
worker/cb211-exhaustion-classification-9546e0-2
worker/cb137-ambiguous-task-4df3d8-4
worker/cb209-agentsessionid-4dfdb6-2
worker/cb185-hostenvnames-2692b5-3
worker/cb206-opencode-sqlite-128718-2
worker/cb185-worktree-group-fc0c99-1
worker/cb-137-ask-ticket-e7760c-2
worker/cb-172-broker-uri-d36ae4-4
worker/cb-175-model-readback-76ead6-3
worker/cb-161-pane-ancestry-293510-1
worker/cb-164-rebase-885863-8
worker/cb-164-empty-scrape-false-success-1a80af-3
fix/cb-197-ticket-ttl-from-completion
worker/cb-189-remote-url-coverage-4692f3-1
worker/cb-185-blockers-027756-4
worker/cb-192-gap-log-11b631-2
worker/cb-633-fix-5f4396-3
worker/cb185-router-d6436d-3
worker/cb185-router-routing-gaps-9e9d33-3
worker/cb185-paneids-992586-2
worker/cb-633-allow-list-union-ed374b-1
worker/cb-157-credential-in-remote-url-496e44-2
worker/cb-641-health-herdr-evidence-8f1f54-6
worker/cb-640-health-msg-evidence-99c9cd-1
worker/cb-642-fleets-status-skill-bbbc40-5
cb-634-ide-mcp
worker/lead-comms-wiring-c014b9-7
worker/lead-mailbox-c19577-6
worker/autocompact-window-82bc2f-5
worker/cb-634-probe-18056f-4
worker/cb635-broker-urienv
worker/cb-632-config-retry-8e0efa-7
lead/cb-622e-claude-md
lead/cb-622-followup
worker/cb-622a-165dff-1
lead/cb-622d-opencode-mount
worker/cb-622b-717c67-2
worker/cb-622c-ab7759-3
worker/cb-617b2-20ca4b-3
worker/cb-617a-5c2f4a-1
worker/cb596-4e49ef-3
worker/cb586-10500c-1
worker/cb-606-b9343a-25
worker/cb604-1445f8-24
worker/cb582-477374-21
worker/cb584-8c2281-22
worker/cb600-e6b9a9-20
worker/cb602-ce257f-19
worker/cb601-b42837-18
worker/cb598-6c7ba7-17
worker/cb599-740fe4-16
worker/cb597-282224-15
worker/cb590fix-185e9a-10
worker/cb528-recovery-race
worker/cb594-96bead-8
worker/cb590-916766-2
worker/cb527-997d99-3
worker/cb592-env-leak-3cbf9c-1
worker/cb588-async-ticket-nudge-3218f7-5
worker/cb578b-9dcb13-6
worker/cb581-d24826-5
worker/m2-u5-ef8c42-15
worker/cb578a-516499-2
worker/cb576-01a04b-17
worker/cb579-lead-tab-acba06-20
worker/cb580-terminal-health-ed6058-21
worker/cb577-f36fdc-18
worker/cb573b-3db06f-16
worker/cb568c-f36fdc-18
worker/cb568-drop-cause-c3ac1c
worker/cb575-cancelled-notification-c3ac1c
worker/m4-sol-a2cbec-3
worker/cb574-async-ask-c3ac1c
worker/cb573-health-model-8ca857-14
worker/cb572-unknown-target-7f2e35-13
worker/u4-700706-9
worker/u3-b9fcb6-6
worker/u2-ef5b68-4
worker/u1-469dce-1-clean
worker/u1-469dce-1
worker/cb-564-health-events-70cf7e-2
worker/cb-565-recycle-drops-role-98e58f-3
worker/cb-563-missing-reply-df2866-1
worker/cb-562-readiness-gate-silent-6c23c9-3
worker/cb-560-architect-presence-da8155-1
worker/cb-561-architect-silent-off-a71cab-2
worker/cb-548-bind-architect-slot-fe1b8c-1
worker/parity-overlay-settings-5fb711-1
secrets-central-store
cb-559-hot-key-correction
cb-557-fleet-role-pools
worker/cb-553-maxload-explicit-spawn-305ee3-6
worker/cb-551-idle-lead-heartbeat-f1633c-1
worker/cb-544-drain-preserves-worktree-925fad-3
worker/cb-552-docs-sync-1cb9cf-4
worker/cb-548-rendezvous-guard-rebased
worker/cb-548-rendezvous-guard-116b53-10
worker/cb-548-authz-v2-586df6-8
worker/cb-548-authz-264363-5
salvage/cb-528b-codex-home
salvage/cb-528a-codex-launcher
CB-518-primary-flow
feature/peer-launcher-spi
cb-103-injector
v1.1.0
v1.0.0
Labels
Clear labels
blocked
needs-live-proof
ready-to-delegate
silent-default
Cannot start until something else lands. The body says what.
Merged and green, but never shown working on the running daemon. Not the same as done.
Scope, files and acceptance criteria are written. A worker can be briefed from the body alone.
A feature that compiles, passes tests, and ships turned off. Nine recurrences and counting.
No Label
Milestone
No items
No Milestone
Projects
Clear projects
No project
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: fleet/fleetd#538
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found while answering a question from the fleet01 lead about
Injector's delivery loop. Their hypothesis was re-delivery; the re-delivery is not reachable, but the reason it is not reachable is worse than the thing they were looking for.Measured on main at
f1640f5.The structure — measured
Injector.onStatusdelivers insidesynchronized (t), and its deliverytryis caught by exactly one clause (Injector.java:391):RuntimeException, notThrowable.grep -n 'catch ('over the whole file returns exactly two clauses,:391and:495, bothRuntimeException.The only production caller of
onStatusisStatusPoller.java:79, inside the per-target body ofloop(). That body's catches areHerdrExceptionandRuntimeException. Measured withgrep -nE 'catch \((Throwable|Error)': noThrowableorErrorcatch in the file. Control: the file does contain 3catch (clauses, so the pattern works.loop()is the entry point of a virtual thread (StatusPoller.java:62):What follows — measured from the code, not observed in production
An
Error(or any non-RuntimeExceptionThrowable) thrown inside the per-target body:Injector's:391clause, sot.queue.poll()never runs andp.statestaysQUEUED;synchronized (t), releasing the monitor;StatusPoller.loop'sHerdrExceptionorRuntimeExceptionclauses;loop(), which is the virtual thread's entry point, so thestatus-pollerthread dies.Then the recovery path is closed, and this is the part that matters:
runningis only setfalseat:103(anInterruptedExceptioninsleep) and:109(stop()). Neither runs here, sorunningstaystrue.start()beginsif (running) return;. So a restart attempt is a silent no-op. The poller cannot be brought back without restarting the daemon.grep -rn 'UncaughtExceptionHandler\|setDefaultUncaught' fleetd/src/main/java→ no matches. Nothing installs a handler.Fleetd.java:533constructs the poller and never checks it again.The consequence is fleet-wide:
StatusPolleris the single thread that drivesInjector.onStatusfor every target. With it dead, no queued message is ever delivered to any member, no turn completion is ever observed, and every blockingfleet_sendrides out its timeout./healthzstays green — it does not know about this thread.The same shape in SessionReaper — measured
SessionReaperis documented as "Modeled onStatusPoller", and it inherited this too:Identical. Two singleton daemon loops, one defect.
I checked the other
Thread.ofVirtual()sites. The rest are per-task threads or executor factories, where a dead thread costs one task rather than a permanently dead subsystem. These two are the singleton long-lived loops, and they are the two that matter.Is an Error actually reachable here? — DERIVED, not observed
I have not seen this happen. The argument that it is not merely theoretical:
NoClassDefFoundErroris the realistic candidate, and it is reachable in this deployment by a documented route. #413 records that building in thefleetdtree disarms the running daemon: amvn cleandeletestarget/fleetd.jarwhile the JVM keeps running on it. Any class not yet loaded then fails on first use. A lazily-loaded class first touched inside the poller body is exactly this bug's trigger.NoClassDefFoundErroron their host on 2026-09-10, in a different code path (the shutdown drain). So the error class is not hypothetical in this system; only its arrival inside these two loops is.I am labelling this derived on purpose. The structural claim is measured; the reachability is an argument.
Why it is bad out of proportion to its likelihood
Three channels, all reassuring, which is this week's recurring shape:
/healthzErrorgoes to an uncaught handler that was never installedstart()runningis stilltrueAn operator who suspects the poller and calls
start()gets silence and no poller. That is worse than a crash: a dead daemon gets restarted in a minute, while this one keeps answering, keeps acceptingfleet_send, and delivers nothing.The fix
Throwableper iteration, notRuntimeException— in bothStatusPoller.loopandSessionReaper.loop. Log it at ERROR with the target, and continue to the next target. One target'sErrormust not end the loop. This alone removes the permanent-death path.loop()for any reason other thanstop(), log at ERROR and setrunning = falseso thatstart()can actually restart it. Afinallyinloop()is the right place — note this is the opposite of thefinallyguidance on the drain-completion line in #512, because here the signal wanted is "the loop exited", which afinallyreports correctly.Do not fix this by installing a global default uncaught-exception handler. That hides the per-loop recovery this needs behind a process-wide net and still leaves
runningstuck true.Acceptance
Errorfrom the per-target body and asserts the loop survives and polls the next target. This must fail before the fix.running == false, sostart()restarts it. Also failing before the fix.shasum -a 256, and run a green control.@TempDir.Related: #413 (the route that makes
NoClassDefFoundErrorreachable), #412 (the same family in the completion thread), #512 (a negative check over a channel that cannot carry the failure).Closed by PR #543, merged after I verified it myself on the merged tree.
Build
mvn -B clean installon the merged tree: exit 0,Tests run: 1716, Failures: 0, Errors: 0, Skipped: 0. That is 1712 onmainplus the 4 new tests. The PR body says 1705, measured from basef1640f5; that is not a disagreement, the base is older thanmain.Mutations — I re-applied four myself
The acceptance criterion here was that both loops are fixed, because a fix to one and not the
other is half a fix. So I mutated each half separately and checked the other half's tests stayed
green.
StatusPollerper-targetcatch (Throwable)→catch (RuntimeException)anErrorForOneTargetDoesNotStopPollingTheNextTarget:38 » TimeoutStatusPollerfinally, droprunning = falseanAbnormalExitClearsRunningSoStartCreatesANewLoop:50—an abnormal loop exit must clear running ==> expected: <false> but was: <true>SessionReaperper-iterationcatch (Throwable)→catch (RuntimeException)anErrorInOneIterationDoesNotStopTheNextIteration:44—the reaper must continue to the next iteration after an ErrorSessionReaperfinally, droprunning = falseanAbnormalExitClearsRunningSoStartCreatesANewLoop:57, same messageAll four killed. A kill is its own harness proof, so none needed a separate false-assertion run.
Restores were sha-checked, and both of my shas matched the worker's independently:
273a4e48…forStatusPoller.java,d33567ba…forSessionReaper.java. Green control after thelast restore: exit 0,
1716/0/0/0, cleangit status.The proof cell caught my own mistake
My first proof cell counted the mutated text. For the two deletion mutations the mutated text
is a prefix of the pristine text, so that cell reported "applied" both before and after — a cell
that agrees with whatever you already believed.
It did not get to. I ran the cell against the untouched tree first and required it to report
not-applied, and it aborted:
I switched the cell to count the pristine anchor instead — 1 before, 0 after — and re-ran all
four. This is the one-level antidote the fleet01 lead described: run the cell against a tree where
the mutation was not applied and require it to say so. No deeper turtle is needed.
One thing I checked instead of assuming
SessionReaperResilienceTest's first test counts down a latch on the second call to theinjected clock. That only means "the next iteration" if nothing else in the same iteration calls
the clock.
SessionManager.sweepWipRefsdoes not — it delegates straight toworktrees.pruneWipRefs. So the second clock call really is the nextreapIdle, and theassertion message is accurate.
Not closed by this
Nothing calls
start()a second time.Fleetdconstructs and starts each loop exactly once(
Fleetd.java:321for the reaper,Fleetd.java:533for the poller). So the new log line"… loop exited unexpectedly; it can be restarted"names a capability with no restarter behind it.This change is still a strict improvement: before it, a dead loop left
running == true, andstart()begins withif (running) return;, so a restart was impossible even in principle. Now itis possible. Building the supervisor that actually does it is separate work, filed as a follow-up.
Injector.javawas not touched, as the ticket required.