Files
lmxopcua/code-reviews/Driver.Mqtt/findings.md
T
Joseph Doherty 2545bd7f7d docs(code-reviews): close Driver.Mqtt-006/-007 and Driver.Mqtt.Transport-002
Records the disposition and rationale for each: the primary-host gate is no
longer disposed (matching MqttConnection's documented rule), a reconnect that
resolves zero filters for live topics now throws like the zero-granted case it
is indistinguishable from, and a password configured without a username is
warned about rather than silently dropped. Both module headers drop to 0 open.
2026-08-10 07:59:34 -04:00

21 KiB

Code Review — Driver.Mqtt

Field Value
Module src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt
Reviewer Claude Code
Review date 2026-08-10
Commit reviewed b990adae
Status Reviewed
Open findings 0

Checklist coverage

A comprehensive review completes every category, recording "No issues found" where a category produced nothing rather than leaving it blank.

# Category Result
1 Correctness & logic bugs Driver.Mqtt-001, Driver.Mqtt-003
2 OtOpcUa conventions No issues found
3 Concurrency & thread safety Driver.Mqtt-002, Driver.Mqtt-006
4 Error handling & resilience Driver.Mqtt-004, Driver.Mqtt-007
5 Security No issues found
6 Performance & resource management Driver.Mqtt-002 (leaked broker session per re-initialize)
7 Design-document adherence Driver.Mqtt-001 (docs/drivers/Mqtt.md's "ingest state is reset at every session seam" was not true of the plain path)
8 Code organization & conventions Driver.Mqtt-005
9 Testing coverage Driver.Mqtt-008
10 Documentation & comments Driver.Mqtt-009, Driver.Mqtt-010

Scope

The 11 hand-written source files under the module root and Sparkplug/ (~8,100 LOC): MqttDriver, MqttConnection, MqttSubscriptionManager, LastValueCache, MqttDriverFactoryExtensions, MqttDriverProbe, and Sparkplug/{AliasTable, BirthCache, SequenceTracker, SparkplugIngestor, SparkplugPrimaryHost}. Nothing under bin/ or obj/ was reviewed (generated build output). .Contracts and .Browser are separate modules, read only where this module's behaviour depends on them.

What is already right, and worth not regressing

Recorded because several of these are the reason a defect below is a defect in one narrow spot only: the refused-CONNACK classification (MQTTnet 5 returns rather than throws), a deadline on every wait including teardown, the RawPath-as-published-reference discipline in both ingest paths, the alias-vs-name binding rule, the Reinterpret two's-complement call, "an invalid payload never mutates state", the per-node rebirth debounce and its lifetime cap, and the dispatcher-thread containment (nothing throws, blocks, or does I/O on MQTTnet's pump).

Findings

Driver.Mqtt-001

Field Value
Severity High
Category Correctness & logic bugs
Location src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs:193 (AttachTo), :328 (SubscribeAsync's pending set)
Status Resolved

Description: In Plain mode the manager dedupes SUBSCRIBEs against _subscribedTopics — a set that describes the session it established them on — but AttachTo never cleared it, and the manager outlives a session rebuild. MqttDriver.AdoptOptions replaces the manager only when the ingest identity changed, so both routes that rebuild the broker session re-attach the same instance to a new connection: ReinitializeAsync's rebuild branch (endpoint / credentials / TLS / timeout change — SessionIdentity is a strict superset of IngestIdentity) and, more importantly, the driver-host's ShutdownAsync + InitializeAsync retry, which the #527 comms-loss watchdog now makes automatic and unattended.

After such a rebuild the host re-applies its desired subscriptions (DriverInstanceActor.InitializeSucceeded → ResubscribeDesired), SubscribeAsync computed pending = defs.Where(d => !_subscribedTopics.ContainsKey(d.Topic)) — empty, because every topic was still marked established from the dead session — and EstablishAsync([]) returned immediately having sent nothing. The result is exactly the failure this file's own remarks exist to prevent: a driver reporting Connected / Healthy on a broker session carrying zero subscriptions, receiving nothing, with no error, no exception and no Bad status. It self-corrected only if the new session later dropped, at which point OnReconnectedAsync finally re-established the filters — so a clean reconnect left every value this driver serves frozen indefinitely.

SparkplugIngestor.AttachTo already performed the equivalent reset (ResetSessionState) for its own per-session state, with remarks spelling out this same "the ingestor outlives a session rebuild" reasoning. The plain path was never given the same treatment.

Recommendation: Clear _subscribedTopics at the top of AttachTo, before delivery is wired, mirroring SparkplugIngestor.AttachTo. Pin it with a test that rebinds the manager to a new connection and asserts the next subscribe actually reaches the transport, plus a control proving the within-one-session dedupe still holds — otherwise the "fix" is indistinguishable from deleting the dedupe, which re-triggers every topic's retained seed on every subscribe.

Resolution: Fixed 2b4b3fcb, 2026-08-10 — AttachTo forgets the previous session's established filters. Pinned by MqttSubscriptionManagerTests.AttachTo_ForgetsTheFiltersEstablishedOnThePreviousSession (confirmed to fail against the pre-fix code) with SubscribeAsync_TwiceOnTheSameSession_StillIssuesOneSubscribe as the falsifiability control.

Driver.Mqtt-002

Field Value
Severity High
Category Concurrency & thread safety
Location src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs:1000 (_connection = connection), :1297 (_primaryHost = host)
Status Resolved

Description: InitializeCoreAsync published _connection and _primaryHost — both IAsyncDisposable, both owning a live broker socket and a running reconnect supervisor — over whatever the driver already held, without disposing it. A second initialize on a live driver therefore stranded a fully connected, self-supervising session that no later ShutdownAsync or DisposeAsync could reach (both act only on the current field value), and whose own supervisor kept it alive against the broker indefinitely. For a Sparkplug driver the orphan also retains its group-wide spBv1.0/{group}/# subscription and a second primary-host claim on the same host id — and two MQTT clients presenting one client id take turns evicting each other, forever.

CLAUDE.md records this as latent on the grounds that DriverInstanceActor passes teardownFirst on the #527 retry path. That covers the retry path only: AdoptConfigDuringInit re-runs InitializeAsync with no teardown when an ApplyDelta arrives while the driver is in Connecting/Reconnecting (DriverInstanceActor.cs:743) — an ordinary redeploy racing a reconnect. Depending on every caller to tear down first is also the wrong shape: the driver owns those fields and is the only thing that can dispose them.

Recommendation: await TeardownAsync() at the top of InitializeCoreAsync, under the lifecycle gate both callers already hold. It is idempotent (Interlocked.Exchange to null), so ReinitializeAsync's rebuild branch tearing down first costs a no-op and a first initialize sees three nulls.

Resolution: Fixed 2b4b3fcb, 2026-08-10 — InitializeCoreAsync tears down the previous connection, host-probe loop and primary-host claim before building the new session. Pinned by MqttDriverSessionLifecycleTests.InitializeAsync_OnALiveDriver_TearsDownThePreviousSession, which asserts the broker's live socket count (a leak is by definition the case where the driver's own view disagrees with the broker's) and fails against the pre-fix code.

Driver.Mqtt-003

Field Value
Severity Medium
Category Correctness & logic bugs
Location src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs:376,903; MqttConnection.cs:474,486,657,1039,1134,1368,1384; Sparkplug/SparkplugPrimaryHost.cs:267
Status Resolved

Description: MqttDriverOptions.ConnectTimeoutSeconds carries [Range(1, int.MaxValue)], but nothing validates it on the runtime path — the blob is deserialized straight out of the deploy artifact by MqttJson.Options. Every bounded wait in the module then feeds it to new CancellationTokenSource(TimeSpan.FromSeconds(...)), SemaphoreSlim.WaitAsync(TimeSpan) or Task.WaitAsync(TimeSpan) unchecked:

  • 0 yields an already-cancelled deadline, so connect / subscribe / publish fail instantly against a perfectly healthy broker and the driver reports a timeout it invented;
  • a negative value is outside the legal range of both APIs, so it throws ArgumentOutOfRangeException out of ShutdownAsync and DisposeAsync — the two paths whose whole job is to complete, and the ones a host calls while tearing a bad deployment down.

The module already clamps its other numeric knobs at their use sites (MaxPayloadBytes in the manager's constructor, min/max backoff in NextBackoff, QoS in MapQos, and the probe's own ConnectTimeoutSeconds > 0 ? … : fallback), so this knob is the outlier rather than the convention.

Recommendation: Normalize once, at the single seam every options instance the driver holds passes through, rather than at each of the ten use sites — the value rides into MqttConnection, SparkplugPrimaryHost and MqttClientOptionsFactory through _options.

Resolution: Fixed 2b4b3fcb, 2026-08-10 — MqttDriver.Normalize replaces a non-positive connectTimeoutSeconds (and a negative keepAliveSeconds; zero keep-alive is legal MQTT) with the record's own defaults, applied in the constructor and in ParseOptions. Pinned by MqttDriverSessionLifecycleTests.ShutdownAndDispose_WithANegativeConnectTimeout_DoNotThrow, with InitializeAsync_WithAnAuthoredConnectTimeout_KeepsIt as the control that a legal value is not quietly rewritten to the default.

Driver.Mqtt-004

Field Value
Severity Low
Category Error handling & resilience
Location src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs:375 (ShutdownAsync)
Status Resolved

Description: ShutdownAsync captured the last-message instant with ReadHealth() — the stored snapshot, which is only ever written at initialize/shutdown — rather than GetHealth(), which prefers the live MqttConnection.LastMessageUtc. The stored value is whatever was true when the session was established (usually null), so a shutdown discarded the evidence that the driver had been delivering right up to teardown and reported a driver that was receiving a second ago as one that had never received anything. That timestamp is what the #527 comms-loss watchdog measures a driver against, and shutdown-then-initialize is precisely its retry cycle.

Recommendation: Use GetHealth().LastSuccessfulRead; the connection is still live at that point in the method.

Resolution: Fixed 2b4b3fcb, 2026-08-10 — one-line change plus a comment naming why the two health reads are not interchangeable here.

Driver.Mqtt-005

Field Value
Severity Low
Category Code organization & conventions
Location src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs:1-4
Status Resolved

Description: Three using directives left behind when the TLS / CA-pin code moved out to the .Transport module (Gitea #512) — System.Net.Security, System.Security.Cryptography and System.Security.Cryptography.X509Certificates — no longer resolve anything in this file. They read as though this type still does certificate work, which is exactly the layering claim #512 removed. (System.Buffers looks equally unused and is not: ReadOnlySequence<byte>.ToArray() is BuffersExtensions', not a member — removing it breaks the build.)

Recommendation: Drop the three, and leave a note on System.Buffers so the next reader does not repeat the experiment.

Resolution: Fixed 2b4b3fcb, 2026-08-10 — the three unused usings removed; System.Buffers kept with a comment naming the extension method that needs it. Build verified.

Driver.Mqtt-006

Field Value
Severity Low
Category Concurrency & thread safety
Location src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugPrimaryHost.cs:278
Status Resolved

Description: DisposeAsync calls _gate.Dispose(). StopAsync cancels the reconcile loop but does not await it, so a ReconcileAsync already in flight can be sitting in ClaimAsync/ReleaseAsync's _gate.WaitAsync(...), or on its way to the finally { _gate.Release(); }, when the gate is disposed — surfacing an ObjectDisposedException naming SemaphoreSlim, which tells an operator nothing about what happened. It is contained (both ReconcileAsync and ReconcileLoopAsync catch broadly and log) and no claim can be created after dispose (ClaimAsync re-checks _disposed under the gate), so the impact is a misleading warning rather than a leak.

Worth recording because the sibling type reached the opposite conclusion deliberately: MqttConnection documents that its _lifecycleGate, _reconnectWake and _lifetimeCts are never disposed, precisely so a late callback racing teardown produces a benign no-op instead of an ObjectDisposedException naming the wrong type. Two lifecycle types in one module disagreeing about this is the kind of divergence that gets copied into the third.

Recommendation: Either stop disposing _gate (matching MqttConnection's documented rule and its stated rationale — the gate holds no timer and no surviving registration), or have StopAsync await the reconcile loop before dispose returns. The former is the smaller change and the one already argued for in this module.

Resolution (2026-08-10): Fixed 3f85ba98 — the first option. DisposeAsync no longer disposes _gate, and both the field and the dispose site now carry the rule and the reason, so the module states one answer for its lifecycle primitives rather than two. Nothing is leaked (the gate holds no timer and no registration) and ClaimAsync still re-checks _disposed under it, so no claim can be created after dispose either way. TDD: a test that disposes while a reconcile is in flight failed RED with log.Captured should not contain an element satisfying (name == "ObjectDisposedException") but does. It is deterministic rather than timing-dependent — the gate is taken before the connect starts, so a MiniBroker in blackhole mode (TCP accepted, CONNECT never answered) having accepted the socket proves the claim is inside the gate when dispose runs. Suite green (734).

Driver.Mqtt-007

Field Value
Severity Low
Category Error handling & resilience
Location src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs:406-424 (OnReconnectedAsync)
Status Resolved

Description: When the reconnect path finds live subscribed topics but resolves zero filters for them, it logs a warning and returns normally. The type's own contract, stated two paragraphs later, is that a reconnect which re-establishes nothing must throw, so MqttConnection tears the fresh session down and retries rather than serving a connected-but-deaf driver — and this branch reaches that same outcome by a different route (no subscription is re-established either way). The asymmetry is deliberate as written ("Silence here is how the wildcard defect hid"), but the remedy it chose was a louder log rather than the teardown the failure warrants.

In practice the branch is close to unreachable — Register prunes _subscribedTopics of exactly the topics missing from ByFilter, and with Driver.Mqtt-001 fixed a rebind clears the set outright — which is an argument for leaving the code alone, not for leaving the contract ambiguous.

Recommendation: Throw here as the zero-granted case does, or state explicitly in the remarks why this deaf-reconnect shape is exempt from the type's own rule. Do not leave the two answers side by side unexplained.

Resolution (2026-08-10): Fixed 2298485b — it throws, and the remarks now state the single rule. Exempting it was considered and rejected: the reason the partial-rejection case is exempt is that tearing the session down would punish the tags still flowing, and here there are none — every live topic is in the set that resolved to nothing — so the teardown costs nothing and the retry is not futile, since it runs against whatever Register has published by then. The subscribed.Count == 0 branch stays a debug no-op: a reconnect with nothing subscribed yet re-establishes nothing because there is nothing to establish. TDD: Should.ThrowAsync<InvalidOperationException> failed RED (should throw System.InvalidOperationException but did not). The test reaches the state the only way production can — Register prunes _subscribedTopics of the topics its new table dropped, but a SUBSCRIBE already in flight records its own topic after that prune, so a deploy landing inside the round-trip leaves a live topic no authored tag resolves to — driven through a DuringSubscribe hook on the existing fake transport. Paired with the control that a reconnect with no live subscriptions stays a no-op, which a blanket filters.Count == 0 throw would have broken. Suite green (734).

Driver.Mqtt-008

Field Value
Severity Low
Category Testing coverage
Location tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ (708 tests at b990adae)
Status Resolved

Description: Coverage is genuinely strong (golden Sparkplug vectors, the §3.6 message matrix, CONNACK classification, backoff, retained-seed handling, write encoding, the accept-gate). The gap that mattered is that MqttSubscriptionManager.AttachTo had no test at all — grepping AttachTo across the suite matched only SparkplugIngestorTests. That is why Driver.Mqtt-001 survived: the Sparkplug half of the same seam is pinned by AttachTo_DropsBirthsFromThePreviousSession, and the plain half was not. Nothing covered a driver-level second initialize either (Driver.Mqtt-002), because every driver-lifecycle test initializes exactly once.

The shape of the gap is worth naming: both defects live in "what happens to state that outlives the session it describes", and both are invisible to a suite whose fixtures build one session per test.

Recommendation: Cover every session-rebind seam on both ingest paths, and assert re-initialization against an observable the driver does not own (the fake broker's live socket count), since a leak is by definition the case where the driver's own view is wrong.

Resolution: Fixed 2b4b3fcb, 2026-08-10 — added MqttSubscriptionManagerTests.AttachTo_ForgetsTheFiltersEstablishedOnThePreviousSession and …SubscribeAsync_TwiceOnTheSameSession_StillIssuesOneSubscribe, plus the new MqttDriverSessionLifecycleTests (3 tests). All three regression tests were confirmed to fail against the un-fixed production code, and both controls to pass in either state. Suite 708 → 713 green.

Driver.Mqtt-009

Field Value
Severity Low
Category Documentation & comments
Location src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs:241-248 (type remarks)
Status Resolved

Description: The remarks on the publish leg stated "This driver has no IWritable leg in v1, so a Sparkplug rebirth NCMD is the only outbound application message it ever sends." Gitea #508 added the write leg — MqttDriver.WriteAsync calls connection.PublishAsync for every OPC UA write — and #511 added a third caller in SparkplugPrimaryHost's retained STATE. A comment that under-states what a method is used for is how a later "this is only ever called from X, so it can assume Y" change gets made.

Recommendation: Enumerate the three production callers instead, and date the "no IWritable leg" claim as historical.

Resolution: Fixed 2b4b3fcb, 2026-08-10 — remarks now name all three callers and mark the retired claim against #508.

Driver.Mqtt-010

Field Value
Severity Low
Category Documentation & comments
Location src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SequenceTracker.cs:294 (Reset)
Status Resolved

Description: Reset()'s remarks call it "the reconnect seam (§3.6 invariant #5 — late join)", but the reconnect path does not call it: SparkplugIngestor.ResetSessionState clears the whole _trackers map instead. Its only caller in the tree is SequenceTrackerTests. The reasoning in the remarks is correct and load-bearing — it is the attribution that is wrong, and it sends a reader looking for the reconnect reset to the wrong method.

Recommendation: Keep the method and the rationale; state that the ingestor drops the map rather than resetting instances, and why (dropping also releases the entry for a node the plant stopped publishing).

Resolution: Fixed 2b4b3fcb, 2026-08-10 — remarks corrected; no code change.