A reply delivered while release() is running is stranded unacked forever: #298 closed "already held", not "arrives during release" #318
Closed
opened 2026-09-04 08:53:28 +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#318
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 by a delegated hunter. I read both methods and confirmed the interleaving.
Fourth instance today of "a one-way gate is not a gate", and the second where the earlier fix closed exactly the direction its own incident came from.
The two halves do not share a lock
AmqpReplyInbox.release— everything underchannelLock:deliverCallback— the insertion intoheldtakes nochannelLock:It only takes
channelLockin theduplicatebranch, for the ack.The path in
fleet_stop) releases a worker.MessageService.abandonruns first and finds nothing to recover — the message has not arrived yet. ThenreplyInbox.release(target).fleet_reply. There is no live rendezvous waiter, so it falls toinbox.publish(...), which uses the separate publish channel and succeeds.basicCanceltakes effect. The delivery lands in the client's consumer work pool — a separate thread.basicCancelstops new dispatches; it does not flush ones already handed to the pool. That is precisely whyreleasebothers to requeue at all, and the method's own javadoc says so.releasehas already runheld.remove(target)and is iterating the old map. The work-pool thread now runscomputeIfAbsent, finds nothing, and creates a new map under the same key.releasefinishes iterating the old map and returns. The entry in the new map is never nacked.Result: the message stays delivered-but-unacked on a channel that lives until the whole inbox closes. It is never requeued, so it is never redelivered.
peek(target)would still see it, but nothing peeks a released target again — the session is gone from the roster and its terminal id is never reused. The reply is invisible until the daemon restarts.Direction of harm: silent loss. A worker's real report disappears with no log line naming it. This is the same end state as fleetd #307, reached by a different route.
Why the existing fix does not cover it
Commit
be5ba22(#298, "release() requeues held deliveries instead of dropping them") is the dedicated fix for this method. Its contract test,AmqpReplyInboxContractTest.releaseCancelsConsumerAndRequeuesHeldDeliveryForRecovery, callsawaitPeekto force the delivery to settle beforereleaseruns. So it proves the "already inheld" case and never exercises a delivery landing concurrently. The gate closed the direction the incident came from.Honest limits
Neither the hunter nor I reproduced this. It is confirmed by reading the two methods and the RabbitMQ client's dispatch model, not observed. The window is one work-pool thread against one release, so it needs a reply landing in roughly the same moment as a teardown. That is not exotic — the idle reaper fires on a timer with no regard for what a worker is doing — but it is narrow, and I am not claiming it has cost us a report.
What I want
Goal: a delivery that arrives for a target at any point during or after its
releasemust not end up sitting unacked in a map nobody will ever read. Either it is requeued so a later session can get it, or it is refused — but it must not be silently retained.Invariants:
basicAcka reply nobody has read. #298 exists because dropping was the old behaviour.releasemust not become able to throw where it currently does not. Its javadoc is explicit that a failedbasicNackis logged and release proceeds, because release is called from teardown paths that must complete. A failedbasicCancelstill throws — leave that as it is.channelLockacross a blocking broker round trip from the delivery thread.basicCanceland the nack loop already run under it; adding a delivery-side wait on the same lock risks serialising every delivery behind a teardown, or worse. If your fix takes that lock indeliverCallback, say why it cannot deadlock or stall the consumer pool.heldis acked and dropped so the broker stops resending it. Do not break that.Candidate mechanisms, all candidates only — pick one and justify it:
deliverCallbackconsult a "this target is released" marker and nack the delivery immediately instead of holding it. The marker has to be set and read consistently withheld.remove, which is the whole difficulty.remove-then-iterate with a single atomicheld.compute(target, ...)that both takes the map and leaves behind a tombstonecomputeIfAbsentwill see.heldfor the target once more at the end ofreleaseand nack anything that appeared. Simplest, and it narrows the window rather than closing it — if you choose this, say so plainly rather than describing it as a fix.Decide it yourself and justify it. I do not know which is right. If you find a fourth option, or find that one of these cannot work, that is a good outcome — say so.
Rules
held.removehas run but beforereleasereturns. If you cannot build that seam without changing production code beyond the fix, say so and describe exactly what your test does and does not prove.git stash— the stash is shared across every worktree here.git worktree removeorgit worktree prune— other workers are live in these worktrees.git add -A. Never merge.cd fleetd && mvn clean installunpiped, and quote the realTests run:andBUILDlines. Never pipe maven throughtail/head, and never read$?after a pipe.fleet_reply.Shape check
When done, look in
msg/only for the same shape: two halves of one invariant guarded by different locks, or by a lock on one side and none on the other. One line each, do not fix any of it.Merged as
fa1f496. Pushed tomain.Build on
mainafter the merge:Tests run: 1336, Failures: 0, Errors: 0, Skipped: 0—BUILD SUCCESS, unpiped.The mechanism is the right one
The worker chose the tombstone in the same map over a separate "released" set, and the reason it gives is the one that matters:
ConcurrentHashMapalready serializescomputeandcomputeIfAbsentfor the same key against each other, so the atomicity comes free and there is no second structure to keep in sync. A separate set would have reintroduced the two-things-that-must-agree shape that keeps producing defects here.It also explicitly refused the third candidate — re-checking
heldonce more at the end — on the grounds that it narrows the window rather than closing it, and said it would not call that a fix. That is the correct reading of the ticket.It ran the contract suite against a real broker. Docker was available, so
mvn test -Pcontract -Dtest=AmqpReplyInboxContractTestran with Testcontainers RabbitMQ:Tests run: 8, Failures: 0including #298's own test, unmodified and still green. That is the check that matters most here, because #298 is exactly the ticket whose fix this one sits next to, and broker redelivery timing is not something source reading settles.What I checked myself
Every read site of
held.RELEASEDis a single sharedLinkedHashMapinstance, so any path that synchronizes on it and mutates it would corrupt state for every released target at once. I listed all six accesses on the branch:close()does not touchheldat all. So no path mutates the sentinel, and the recoveryclear()dropping tombstones is harmless:releasealready cancelled the consumer, and an explicitly cancelled consumer is not restored by automatic topology recovery, so no redelivery arrives for a released target.My own mutations, one per half of the fix. The worker reverted the whole file. I reverted each half separately, to prove neither is carrying the other.
L — keep the tombstone, drop
deliverCallback'sRELEASEDcheck:M — keep the check, revert
releaseto a plainheld.remove(target): same failure, same line.So each half fails on its own. Neither is decoration.
The
own()addition is right — keeping itThe worker flagged
held.remove(target, RELEASED)inown()as a deviation beyond the ask and offered to drop it. Keep it. Without it the fix leaks one tombstone per released target for the life of the process, and it is placed correctly — still underchannelLock, afterbasicConsumereturns, so no delivery under the fresh consumer can race it. The two-argumentremovemeans it can only ever remove a tombstone, never a real map.The shape check found something worth its own ticket
The worker reports the same asymmetric-locking shape in
MessageService:Task.turnId/Task.questionandasyncTasksByTurn:ask()mutates them throughmarkAsyncQuestion/clearAsyncQuestion/markAskTimedOutwith nosessionLockslock, whileanswer()mutates the same task's fields throughclearAsyncQuestion/finishAsyncTaskholdingsessionLocks.finishAsyncTaskagain:sendAsync's background virtual thread calls it unlocked;answer()calls it locked.I touched
markAskTimedOutmyself in #307 two days ago and did not notice this. It is confirmed by reading only, not observed. I am filing it separately rather than folding it in here — it needs its own reachability analysis, and this ticket is done.The lower-severity
strandedReplies/queuedDeliveriesnote is correctly dismissed: each touch is a single atomic map operation on a documented (CB-640) self-healing best-effort flag.Closing.