sendAsync's executor catch swallows every exception thrown after the future completes, and that is why a stranded async ticket is invisible #329
Closed
opened 2026-09-04 10:00:04 +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#329
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?
Follow-up to #324. Three findings, all in
MessageService, all measured by me during that merge. They are one unit because the first one is what hides the second.F2 — the silent sink. Fix this one first.
finishAsyncTaskcallstask.future.complete(result)on its first line. So by the time anything later in that method throws, the future is already completed.completeExceptionallyon a completed future returnsfalseand does nothing. Nothing logs. The exception is gone.Measured, not reasoned. I dropped the
turnId != nullguard infinishAsyncTaskand ran the suite:Zero
NullPointerExceptionin the log. I did not accept that. I re-ran with the guard dropped and a temporarylog.errorin that catch:19 real exceptions on the ordinary path. 74/74 tests green. Not one log line.
Direction of harm: this is not one bug. It is a blind spot over the whole async region. Any defect that throws after the future completes produces a passing test suite and a silent daemon. That is how the
turnId != nullguard came to be load-bearing and completely unpinned —task.turnIdis only ever set inmarkAsyncQuestion(:1186), so it isnullfor every async ticket whose worker never calledfleet_ask, and removing the guard breaks every one of those with no visible sign.Goal: an exception thrown inside that executor task must reach a log, whether or not the future was already completed.
Invariant: do not stop completing the future first.
finishAsyncTaskcompleting before it cleans up is what makes #324's crash harmless to the caller, and moving the completion later would lose an answer that was already delivered.Candidate mechanism, as a candidate only: if
task.future.completeExceptionally(t)returnsfalse, log at error with the ticket and the target. Decide it yourself and justify it. If you find a better shape — for example not letting the cleanup throw at all — say so.F1 — an async ticket is silently stranded. I confirmed this by experiment.
This is the report's item 4 from #324, which that PR reported but did not fix. I did not leave it as an argument. I wrote a throwaway test using the
forgetTurnForTestseam that #324 merged, firingask()'s own timeout cleanup afteranswer()unblocked the worker but before the worker's real reply arrived:The worker replied.
answer()returnedREPLIED. The async ticket stayedPENDINGwith anullreply.Why the local-read fix cannot help.
answer()callsfinishAsyncTask(turnId, result)at :1011, and that overload does its ownasyncTasksByTurn.get(turnId)at :1290. Reading a field once helps a caller that already holds theTask. It does nothing for a caller that must still look the task up — that lookup itself racesask()'s unlocked forgetting.Path in.
ask()'sticket.answer().get(timeoutMillis)can time out at the same instantanswer()completes that future. I read this myself at :924-936:rendezvous.answerAskreturnstrueandask()still runsmarkAskTimedOut+clearAsyncQuestion(turnId, true)under no lock, which removes theasyncTasksByTurnentry.answer()then waits for the worker's real reply, gets it, and its lookup finds nothing.Direction of harm: silent, and worse than #324 was. The crash made noise. Here
fleet_poll{ticket}reportsPENDINGforever, and teardown'sabandon()eventually resolves it asWORKER_FAILED— "session released before it replied" — long after the worker actually replied. A lead reading that is told a lie about its own worker. TheaskAnsweredAsyncTasks()recovery built for #137/#307 does not catch it: that only fires whenreply()finds no live waiter, and hereanswer()'s waiter is live.Goal: a worker's real reply must complete its async ticket, even when
ask()'s unlocked cleanup removed the turn mapping first.Invariants — all four from #324 still hold, plus one:
ask()must not start blocking on the target's session lock. Prove no deadlock if you take it.clearAsyncQuestion'sforgetTurn=trueforgetting stays (#307 depends on it).markAskTimedOutmust still run before that forgetting (#307).answer()returns on success.fleet_askinside a resumed turn moves its task to a freshturnId, andanswer()'sQUESTIONguard at :1010 deliberately leaves the ticket open. A fix that completes the ticket whenever the lookup fails would break that — the lookup also legitimately fails in the chained case. Say how you tell the two apart.Candidate mechanism, as a candidate only:
answer()already holds aTaskreference from its own lookup at :985. Passing that down instead of looking it up again is the obvious move. It is not obviously right — invariant 5 is exactly why. Decide it yourself and justify it.F3 — the same double read #324 just fixed, still present
Two reads of the same volatile field, one for the check and one as the removal key, under no lock. Structurally identical to what #324 fixed at :1250. Read-confirmed by me; I have not reproduced it.
Fix it the same way. If you conclude the path in is unreachable here, say why — and remember that "unreachable" must name the thing that stops it, not just the absence of a known trigger.
Order
F2 first. While it stands, neither F1's fix nor the
turnId != nullguard can be pinned by any test, because the failure they cause is invisible. Once F2 logs, say in your report whether that changes how you tested F1.Rules
forgetTurnForTestseam merged in #324 is already there — use it rather than building another.git stash— the stash is shared across every worktree here.git worktree removeorgit worktree prune— other workers are live in those directories.git add -A. Never merge.cd fleetd && mvn clean installunpiped; quote the realTests run:andBUILDlines. Never read$?after a pipe.fleetd/fleetd.yamlis gitignored and absent from your worktree. Do not report on its contents.fleet_reply.Find what else has this shape — report it, do not fix it
F2 is "a catch that cannot report, because the thing it reports through is already resolved". Look for other places in this file where a
catchwrites to something that may already be terminal, or where a cleanup step runs after a future is completed. One line each, in your report. Do not go and fix them.Already established, do not re-derive
Task.turnId,Task.questionandTask.completedNanosarevolatile. None of this is a visibility bug; all three findings are compound actions.markAskTimedOut(:1208) andclearAsyncQuestion(:1216) were each checked in #324 and are safe in themselves —clearAsyncQuestionuses itsturnIdparameter throughout, never a fresh field read.clearAsyncQuestionis still the cause of the risk in its readers.strandedReplies/queuedDeliveriespair was checked and dismissed in #324: single atomic map operations on documented best-effort flags.Merged to
mainas0c86503(--no-ff; the branch was behind main, caught withgit merge-base --is-ancestor). Follow-up commit4aa1faecorrects a comment — see below.Build after the merge, unpiped:
Tests run: 1351, Failures: 0, Errors: 0, Skipped: 0,BUILD SUCCESS.My own mutation
I ran one mutation of my own, different from the worker's, against the full suite.
Mutation T — drop F1's
task != nullguard inanswer():All four:
NullPointerException: Cannot read field "future" because "task" is null. The guard ispinned on the blocking-ask path by four tests, not one. Reverted.
The fix is narrower than its comment claimed
The comment that landed with F1 said a
nulltask means "this was never an async ticket". I checkedthat and it is wrong. A genuine async ticket also arrives with
task == null, becauseask()dropsthe
asyncTasksByTurnentry in its catch (clearAsyncQuestion(turnId, true)) whilerendezvous.closeAsk(turnId)runs later in its finally. Between the two, the ask is stillanswerable and the entry is gone.
A throwaway probe firing only that first half printed:
The stranded ticket this issue set out to fix, one step earlier in the same race. The probe used
forgetTurnForTest, so it omitsmarkAskTimedOut; that cannot change the outcome, becauseaskTimedOutis read only byaskAnsweredAsyncTasks, whichreply()never reaches whileanswer()'s own waiter is live. I have not raced the two real threads.So F1 narrows this window, it does not close it. Opened as #334, and the comment in the code now
says so instead of claiming the guard is complete.
Same shape, reported by the worker, not fixed here
Three more sites in the same file where a failure has nowhere to go. Filed separately as #335:
whenCompleteswallowing at :1100-1102abandon's uncaught cleanup at :712-724finally-overrides-return at :887-889 / :1057-1059Closing. Three fixes in, one gap out (#334), three candidates out (#335).