diff --git a/fleetd/src/main/java/dev/ltms/fleet/msg/MessageService.java b/fleetd/src/main/java/dev/ltms/fleet/msg/MessageService.java index d944780..557a864 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/msg/MessageService.java +++ b/fleetd/src/main/java/dev/ltms/fleet/msg/MessageService.java @@ -1037,9 +1037,22 @@ public final class MessageService { // it using whatever turnId it now reads. The #282 chained-ask case is unaffected // because it is still gated purely by result.outcome() == QUESTION above, which does // not depend on this lookup — widening what "task" means here cannot complete a ticket - // the chained ask deliberately left open. When task is null (this was never an async - // ticket — a blocking fleet_ask's answer() call has no Task at all), there is nothing - // to complete, matching the old lookup-miss behaviour. + // the chained ask deliberately left open. + // + // A null task is NOT only "this was never an async ticket". That reading was in this + // comment when #329 merged and it is wrong. A genuine async ticket also lands here + // with task == null, because ask()'s timeout path runs clearAsyncQuestion(turnId, + // true) — which drops the asyncTasksByTurn entry — in its catch block, while + // rendezvous.closeAsk(turnId) runs later, in its finally. Between those two the ask + // is still answerable but the map entry is already gone, so the lookup at :991 + // returns null and this ticket is never completed. Measured on 2026-09-04: a probe + // firing only that first half before answer() runs printed + // "answer=REPLIED phase=PENDING reply=null" — the same stranded ticket #329 set out + // to fix, one step earlier in the same race. The probe used forgetTurnForTest, which + // omits ask()'s markAskTimedOut; that cannot change the outcome, because askTimedOut + // is read only by askAnsweredAsyncTasks, and reply() never reaches it while this + // method's own waiter is live. So #329 narrows this window rather than closing it. + // Open as fleetd #334 — do not read this guard as complete. if (result.outcome() != Outcome.QUESTION && task != null) { finishAsyncTask(task, result); }