fix(mqtt): retry failed SUBSCRIBEs via a pending set; throw on no-verdict subscribe (R06:M-1, M-11)

A failed SUBSCRIBE (refusal or no broker verdict) used to drop its topic from every
retry set while SubscribeAsync returned a live handle, leaving tags dark behind a
Healthy connection until a redeploy. Failed topics now join a pending set retried by
the next SubscribeAsync and by every reconnect (not counted toward the reconnect's
total-failure verdict, so an ACL-denied topic cannot reconnect-loop). A no-verdict
subscribe degrades its refs then throws so the actor's reconcile re-subscribes. A
subscribe with no transport attached is recorded pending, never established (M-11).
MiniBroker now answers SUBSCRIBE.

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:22:17 -04:00
parent 0d49eaf77b
commit 01efd39177
5 changed files with 374 additions and 40 deletions
+12 -3
View File
@@ -408,9 +408,18 @@ value. `MqttConnection` therefore raises `MqttConnectRejectedException`:
code. (`ServerMoved` is permanent, `UseAnotherServer` is temporary — per the MQTT 5 spec.)
A reconnect that completes without re-subscribing would be a healthy-looking connection that receives
nothing forever, so `OnReconnectedAsync` **throws** when zero filters are granted, tearing the session
down rather than swallowing it. A SUBACK failure degrades just the affected RawPaths to
`BadCommunicationError`.
nothing forever, so `OnReconnectedAsync` **throws** when none of the previously live filters is granted
(or the SUBSCRIBE gets no verdict), tearing the session down rather than swallowing it. A SUBACK failure
degrades just the affected RawPaths to `BadCommunicationError`.
**A failed SUBSCRIBE is retried, never forgotten (R06:M-1).** Every topic whose SUBSCRIBE was refused or got
no verdict joins a *pending* set that the next `SubscribeAsync` and every reconnect re-request; a grant moves
it to the live set, and a redeploy that drops its tags drops it. Pending topics do not count toward the
reconnect's total-failure verdict, so one ACL-denied topic cannot reconnect-loop the driver. A plain-mode
`SubscribeAsync` whose SUBSCRIBE got **no verdict at all** (transport throw, SUBSCRIBE deadline) degrades its
refs and then **throws**, so `DriverInstanceActor` holds no handle and its health-tick reconcile re-subscribes;
a SUBACK *refusal* still returns a handle (the broker answered). A subscribe issued with no transport attached
records its topics as pending, never as established (M-11).
There are **two** kinds of reconnect and only one of them fires that callback. A drop the connection's
own supervisor recovers keeps the same `MqttConnection`, so `Reconnected` → `OnReconnectedAsync`