A throwable from any listener callback in onStatus skips the delivered-future completion, so a caller waits forever on a message that WAS delivered #553
Closed
opened 2026-09-12 09:41:59 +02:00 by ltms
·
6 comments
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#553
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 following up a shape the #546 worker reported out of scope. Their instinct was right; the
consequence is larger than either of us scoped it, and unlike #546 this one needs no
Error.Measured on
mainat93a9ed3(after #549 merged).The structure
Injector.onStatushas exactly twotryblocks in its whole body:There is no
try/finallyaround the method. After the monitor is released, the rest of themethod runs as a straight sequence of unguarded calls, and the block that completes the
caller's future is last:
Anything that throws in any earlier block unwinds out of
onStatusand skips thesentblockentirely.
What that costs
By the time control reaches the listener blocks, the delivery has already happened inside the
monitor:
t.queue.poll()— the message is off the queue,p.state = Pending.State.DELIVERED— recorded as delivered,Skipping the
sentblock meanssent.delivered()is never completed, in either direction. TheCompletableFuturestays pending forever. There is no retry that can fix it: the message is gonefrom the queue, so no later round revisits it.
Caller-visible: a blocking
fleet_sendrides out its full timeout and reports a failure for a briefthe member actually received and is already working on. An async
wait:falseticket never resolves.Why it is reachable without an
ErrorThe production
TurnListeneris not a stub.Fleetd.java:494-528delegates every callback straightinto two real subsystems:
forget.accept(target)ispresence::forget. None of these is wrapped. An ordinaryRuntimeExceptionfrom any of them is enough — noNoClassDefFoundError, no#413, none of#546's reachability argument required.
The interaction with #543 and #549, which is the part I want on the record
Before #543, a throwable escaping
onStatuskilled thestatus-pollerthread. That was a worseoutage but a loud one, and the daemon was visibly broken.
#543 made the loop survive. #549 made the delivery path survive. Both are right and I am not
proposing to revert either. But together they turn this from "the daemon dies" into "one caller's
future is silently orphaned and everything else carries on looking healthy". The blast radius
shrank and the detectability went to zero.
That is the third time this week the same pattern has shown up here, and it is worth stating as a
rule rather than a coincidence: removing a crash does not remove the half-finished state the
crash used to discard — it makes that state permanent and quiet. #546 was the same sentence about
a queue entry; this is it about a future.
The fix
Wrap the post-monitor section so the
sentblock always runs. The likely shape is atry/finallywhere the
finallycarries only thesentcompletion — but note thefinallyneeds care, becausecompleting the future is exactly the kind of "always do this" that must not also swallow the
original throwable. Whatever escaped still has to reach
StatusPoller'scatch (Throwable)so thelog.errorfires.Guarding each listener call individually is the alternative and is probably better: a listener that
throws is a defect in that listener, and swallowing it wholesale trades one silent failure for
another. At minimum each callback should be attempted independently so one bad listener cannot
prevent the others, and the
sentcompletion should not be reachable-past.I am not prescribing which. The ticket's job is the enumeration, per the fleet01 lead's checklist:
the mutable state and external side effects this region can leave half-done are the queue entry
(already removed),
p.state(alreadyDELIVERED), the pasted text (already in the pane), and thecaller's future (never completed) — and right now nobody restores the last one.
Also in scope: the narrow catch at :500
The resubmit nudge's own
catch (RuntimeException e)is the same one-class-too-narrow shape #546fixed at :391. On its own it is minor — an
Errorthere skips onelog.debugand one Enter nudge,and the next round retries the nudge. What makes it worth fixing is its position: it is the
first block after the monitor, so an
Errorescaping it skips every block below, including thesentcompletion. Fixing the wrapper above makes this one much less interesting; fixing this onealone does not fix the wrapper, because every other block in the region is still unguarded.
Acceptance
RuntimeExceptionfromturnListener.onTurnCompletestill leaves the deliveredfuture completed. Failing before the fix.
onTurnFailed, foronTurnCompleteWithPostAction, and forforget.accept.onStatus(soStatusPoller'slog.errorstillfires) — the fix must not convert a loud listener bug into a silent one. This is the control, and
it is the one that can fail for the same reason the fix would falsely pass.
Errorfrom the resubmit nudge at :500 does not prevent thesentcompletion.shasum -a 256,green control, and the proof cell run against the un-mutated tree first to confirm it reports
not-applied. Anchor the proof cell on the pristine text, by line number where the file has
more than one instance —
NOT_DELIVEREDappears at :400, :419 and :582, and a file-wide grepconflates them.
Credit
The #546 worker reported the narrow catch at :500 as an out-of-scope shape, exactly as briefed, and
declined to fix it. Their assessment of its consequence — "just skips one debug log and one Enter
nudge, the next round retries" — is right about the nudge itself and stops one line short: it does
not account for what the escape skips below it. Reporting the shape is what made the rest of
this visible.
Related: #546 / PR #549, #538 / PR #543, #551 (the other half of the delivery record), #413.
Scoping correction before anyone writes the fix. There are two futures in this region, not one, and the obvious fix only covers one of them.
Measured on
mainat93a9ed3.The obvious fix, and why it is not enough
The natural patch is to wrap the post-monitor region in
try/finallyand completesent.delivered()from thefinally, so a throwing listener cannot orphan it. That is correct as far as it goes. It is not enough.The rendezvous waiter is registered only by
turnListener.onDelivered(...), and that call lives inside the veryif (sent != null)block thefinallyis backstopping. On the throwing path it never runs:CompletionResolver.java:247-265 captureBaselineis the only thing that doesinFlight.put(target, new InFlight(waiter, ...)), andonDelivered(:239) is its only caller.CompletionResolver.java:301-308 resolve:inFlightentry,resolvereturns silently and resolves nobody.SessionManager.java:895 onDeliveredis skipped too, so the session never moves toBUSYand itsturn count never bumps.
So a
finallythat only completes the delivery future converts a hang into a hang with a success receipt. That is the worse of the two failures: the caller is told the send landed, and then waits out the full timeout for an answer that can never be resolved.It is reachable on the ordinary path, not a corner
Injector.java:361if (!t.awaitingPickup)contains both assignments, in order:The delivery guard at
:376passes because of the assignment at:365. SoturnCompleted == trueandsent != nullin the same round is the normal case — complete the previous turn, then deliver the next queued message.turnListener.onTurnComplete(target)then runs beforeif (sent != null), andFleetd.java:494-528delegates every callback straight intocompletionandsessionsunguarded, so an ordinaryRuntimeExceptionfrom either is enough. NoErrorrequired.What the fix should do
Preferred: move the
if (sent != null)block to run first, beforeresubmit/notReady/turnCompleted/turnFailed. Register the turn, then run the side effects. That is the same "record first, then act" ordering #546 settled one layer down, and it makes the whole class of "an earlier listener threw" harmless rather than repairing one future out of two.If some ordering constraint forbids that — for example if
onTurnCompletefor the previous turn must be observed beforeonDeliveredfor the new one — then the line that proves it goes on this ticket, thefinallystays, and thefinallymust also callturnListener.onDelivered(target, sent.token()), guarded so a throw from it cannot mask the original throwable.Either way the original throwable must still unwind to
StatusPoller'scatch (Throwable). A listener bug has to stay loud; swallowing it here would be a worse defect than the one being fixed.The general rule, because this will recur
A backstop that completes the future the failing block was going to complete is not the same as a backstop that does what the block was going to do. The block's job was to register the turn and then complete it. A
finallythat only completes has done half of it. The trap is that the enumeration of half-finished state looks complete from inside the function — the registration lived in a different class.Correction to my own previous comment. Do not follow its "What the fix should do" section — the preferred option in it is wrong and would introduce a CB-116 regression. The measurement of the defect stands; only the recommended fix changes.
What I got wrong
I wrote: "Preferred: move the
if (sent != null)block to run first, beforeresubmit/notReady/turnCompleted/turnFailed."I also wrote the escape hatch — "if some ordering constraint forbids that ... the line that proves it goes on this ticket" — and then found the line. Here it is.
onDeliveredwritesinFlight;onTurnCompletereads it, for the previous turn:That comment states the constraint outright. Move
onDeliveredfor the new turn ahead ofonTurnCompletefor the previous one, andonTurnCompletereads the new turn'sInFlightand resolves the new turn's waiter with the previous turn's scraped output. That is the cross-turn stale reply CB-116 exists to prevent, andresolve's own comment at:303-305names it:So "register before side effects" is the right instinct in general and the wrong instruction here. Registration must come after the previous turn is resolved.
The fix, corrected
Keep
if (sent != null)last, inside thetry, and make thefinallydo the block's whole job rather than only its completion:boolean sentHandled = false;set totrueat the end of theif (sent != null)block.finally, whensent != null && !sentHandled:sendError == null→turnListener.onDelivered(target, sent.token())first, thensent.delivered().complete(null).sendError != null→sent.delivered().completeExceptionally(sendError)only. Nothing was delivered, so noonDeliveredon this path.try/catch (Throwable)that logs at WARN and swallows only its own throwable, then still completes the future.StatusPoller'scatch (Throwable). Do not return from thefinally, and do not catch the original. A listener bug stays loud.Acceptance, sharpened
With a
TurnListenerwhoseonTurnCompletethrows, in a round that also delivers a message, assert all three:onDeliveredwas called exactly once — this is what catches a recovery that double-registers on the normal path,sent.delivered()completed,Why the correction was worth its own comment
The previous comment is instruction surface. A stale doc's severity is not what it says, it is what someone would do next if they believed it — and what they would do here is trade a hang for a stale-answer regression, which is the worse of the two. #513 is the same family.
Two additions from a peer lead's question, both measured by me on
mainat93a9ed3. Neither changes the fix in the previous comment; one strengthens the record, one gives a better acceptance test.1. Every
inFlightremove on the completion path is the conditional two-arg formThe question raised was whether the
turnCompletedpath could delete a registration the reorder had just installed — turning "waiter never registered" into "waiter registered and immediately removed", the same silent hang with a more convincing log trail. It reaches a remove, and it cannot do that. All eleven mutation sites inCompletionResolver, with their enclosing method:onTurnCompletereachesresolve;onTurnFailedreachesfail. But every remove on those paths isConcurrentMap.remove(key, value)— it deletes only if the mapped value is stillturn. The previous turn's completion holds the oldInFlightwhile the map would hold the new one, so the remove is a no-op and the registration survives. The single unconditionalremove(target)is insidecaptureBaselineitself, on its own no-waiter path, unreachable from the previous turn's completion.That distinction — conditional at nine sites, unconditional at the one place it is safe — is clearly deliberate. Worth not undoing.
The reorder is still unsafe, for the reason in the previous comment: the hazard is the
getat:276, not the remove. The two-arg remove protects the map; nothing protects the read.2. A better acceptance test than the one I specified
I asked for "assert
onDeliveredwas called exactly once". Assert the invariant instead of the call that happens to maintain it:That assertion goes red on the current
try/finallyfix, red on a bad reorder, and green only on a correct one. AssertingonDeliveredwas called is the same property one level shallower: it tests the mechanism rather than the guarantee, and a future fix that satisfies the invariant another way would fail it for no reason.CompletionResolver.inFlight(String)at:269already exists as a package-private test hook, so this needs no new seam.Keep the exactly-once check as well — it is what catches a recovery that double-registers on the normal path — but the registration assertion is the one that defines "fixed".
3. The real defect is in the contract, and it is a separate ticket
Stated by the peer lead, and it survives both ordering arguments above:
The
Injectorowns "every delivered turn has a registered waiter". The only thing that can satisfy it isCompletionResolver.captureBaseline, reached through aTurnListenercallback thatFleetd.java:494-528wires up unguarded. Any listener added later can break theInjector's invariant by throwing, and theInjectorhas no way to notice. Reordering moves which listener has to behave; it does not remove the dependency.Filed separately — that is a contract change with a blast radius, and this ticket's job is to stop the live hang. Not in scope here.
One more constraint on the fix, and one observation about
CompletionResolverthat is not this ticket's job but should not be lost. Both came from a peer lead's review; both measured by me onmainat26f380a.The
sentHandledflag must be set BEFORE the calls, not afterThe fix in the previous comment guards the
finally's recovery with asentHandledflag so the normal path cannot double-register. Where that flag is set is itself the bug the ticket is about, one level in:Set it after the calls and a throw from
onDeliveredpart way through — aftercaptureBaseline'sinFlight.putatCompletionResolver.java:265, before whatever follows — leaves the flagfalse. Thefinallyreads "not handled" and callsonDeliveredagain, running a secondcaptureBaselineover a registration that had already succeeded.And the second capture is not merely redundant — it can make the hang permanent:
The baseline is a snapshot of the pane taken at delivery, and a completion is suppressed whenever the tail still equals it. The recovery's re-capture runs later — after
onTurnCompletethrew and after any scraping it did — so it can snapshot a pane that has already absorbed the new turn's output. From then ontailnever differs frombaseline, and every subsequent completion for that turn is suppressed. Thereturndeliberately keeps the in-flight record, which is exactly what makes this permanent rather than transient: the waiter stays registered, stays eligible, and can never fire.So the two compose. The late flag is the trigger; the late baseline is the damage. Setting the flag first removes both — no second call, therefore no second capture — which is why no extra guard on the re-capture is needed.
The general form, and it is #546's rule one layer in: the flag must record the intent to call, not the fact of having completed the call. A flag written after a side effect cannot distinguish "never ran" from "ran and failed halfway" — the same one-value-for-several-states problem as
NOT_DELIVERED.Extra acceptance test
A
TurnListenerwhoseonDeliveredthrows after the registration happened, assertingonDeliveredwas called exactly once, not twice. That assertion goes red on the wrong flag placement and on nothing else.Not this ticket: the conditional removes are load-bearing and untested
Recorded here so it is not lost, and carried onto #556 as a non-goal-to-break.
Nine of the ten
inFlightremoves are the conditionalremove(key, value); the one unconditionalremove(target)is insidecaptureBaseline's own no-waiter path, where a previous turn's completion cannot reach it. That is someone having thought carefully about exactly the overwrite hazard — and having left the thinking in the code rather than in a comment or a test.Nothing exercises the distinction. The next person to "tidy" a two-arg remove into a one-arg one removes the protection without a single test going red.
This is the same family as #512's
unknownarm, from the other direction: there, a branch that is now unreachable in production has its test as the entire remaining coverage; here, a branch that is reachable has no coverage at all. Both are guarantees maintained only by a detail nothing exercises.A test that fails when a two-arg remove becomes a one-arg one is the shape wanted — the
mktemp -tlesson from #545 pointed at a Java idiom instead of a shell one. Whether that is expressible without being brittle is an open question, and saying so is better than quietly dropping it.Lead verification of PR #557 at
d4a51c6. This comment is newer than any brief — where it disagrees, this wins.The PR is good and I am not asking for a redesign. The flag placement is right, the CB-116 reasoning is right,
forget.acceptmatchesmain's count exactly (2 sites, no leftover marker — I checked), and both mutations reproduce. One gap, found by measurement, and it is on the one path the flag placement itself created.The gap
onStatusstates its own invariant in the new comment:That is not yet true. Trace a throw from
onDeliveredon the normal path:The message was typed into the pane and taken off the queue, and its delivery future stays pending forever.
This is not a regression —
maindoes the same thing. But it is the ticket's own stated goal left unclosed, and the flag is what closes the backstop against it.Measured, not argued
I added one assertion the PR does not make, and ran it against
d4a51c6:The fix — split the two concerns the one flag is currently doing
sentHandledhas to guard theonDeliveredre-call, because a secondcaptureBaselineis the permanent-suppression bug. It must not guard the future completion, becauseCompletableFuture.completeandcompleteExceptionallyare idempotent — on the normal path the completion already happened and a second call is a no-op returningfalse.So move the flag off the block and onto the call it actually protects:
I ran this before asking for it
Applied to
d4a51c6, then reverted (the worktree is back to a cleand4a51c6):The 47 includes
anOnDeliveredThrowAfterItsOwnRegistrationDoesNotRunASecondTime, still green — the split does not reintroduce the double-call, which was the thing worth checking.!sentHandledstill gates the call; only the completion escaped the gate.The general shape, for the next person
One boolean was carrying two different meanings: "the onDelivered call has been made" and "the future has been dealt with". Those come apart exactly when the call throws between them — which is the case this ticket is about. A flag that records an intent covers everything after the intent; it cannot also stand in for the completion of a step that happens later and can fail on its own. When a guard has to be moved earlier to fix one hazard, check every other thing that guard was also holding up: here, moving it earlier was correct and it silently took the completion with it.
Merged as
f606fcc(PR #557). Closing.Verified on the merged tree, not on the branch —
mainhad moved, so the branch passing proves nothing about what actually lands.Lead mutation — one the worker did not run
The worker's own mutation reverted my fix (put
!sentHandledback on the outerif). That proves the outer guard is wrong. It does not prove the inner guard is load-bearing — so I ran the other half.Dropped
!sentHandledfrom the inner guard at:637, lettingonDeliveredrun twice. Line-anchoredsed, pristine anchor counted withawkstring-equality (not a regex, notperl): 1 → 0, so the mutation genuinely applied.Killed by two tests, each naming the observed value:
That second line is the positive-direction evidence: the mutant did not merely fail, it produced the double call the guard exists to prevent. Restored,
shasum -a 256 -cOK (byte-identical), green control 47/59.So both halves of the split are now pinned by a killed mutation, in opposite directions. That was the thing worth checking, because the whole defect was one boolean doing two jobs — proving one job is guarded says nothing about the other.
What the defect turned out to be
sentHandledcarried two meanings: "onDeliveredwas called" and "the future has been dealt with". Those are the same fact only while nothing between them can fail — and they come apart exactly whenonDeliveredthrows part way through, which is the case this ticket is about.Setting the flag before the call is correct, and that was the fleet01 lead's catch: a flag written after a side effect cannot tell never ran from ran and failed halfway, and a second
captureBaselinemakes the hang permanent viaCompletionResolver's own:364-369suppression. But the same flag was also the outer guard on the backstop, so moving it earlier — right, for that hazard — silently took the future completion with it.CompletableFuture.completeis idempotent, so the completion never needed a guard at all.The general shape, which is the part worth keeping
When a guard has to move earlier to fix one hazard, enumerate everything else that guard was holding up. Anything idempotent almost certainly did not need it, and that is where the bug hides.
And the method that found it: a comment claiming an invariant is a free test case. The PR's own comment said "
sent's future MUST be completed one way or another, in every path out of this region". Turning that sentence into the one assertion the PR did not make produced a value —expected: <true> but was: <false>— instead of an opinion. That is cheaper than reading the code to decide whether the comment is true.Unblocked by this
#551 and #556 were both held on this merging. Both are now free.
A daemon redeploy is owed — this changes Java — but #544 is also Java and still in flight, so I am doing one redeploy after it lands rather than restarting the daemon under a live worker.