fix(mqtt): review minors - QoS 0 is not answer evidence, caller cancels stay cancels, rebirth wait cap, bound/trie pins (R06 G2 MQTT review minors 2 3 4 6 7 8)

2: only a PUBACKed (QoS >= 1) publish advances LastSuccessfulRead; a QoS 0 Sparkplug
command completing means only that the bytes left the local socket.
3: a no-verdict SUBSCRIBE caused by the caller's own cancellation now throws
OperationCanceledException (refs still Bad + pending), so the breaker no longer
counts a pipeline timeout as a failure.
4: the rebirth wait is the window capped at 15 s, below the AdminUI 20 s per-call
timeout.
6: a 10-topic test pins the 8-in-flight publish bound. 7: partial-level wildcards and
$share filters are pinned never to confirm. 8: the intended init-vs-shutdown
supersede is commented at the re-check.

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 16:39:24 -04:00
parent 099f253321
commit e7e84e0577
8 changed files with 171 additions and 8 deletions
@@ -149,6 +149,13 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
/// </summary>
private static readonly UTF8Encoding Utf8Strict = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
/// <summary>
/// Ceiling on the rebirth request's wait, below the AdminUI's 20 s <c>BrowserSessionService.PerCallTimeout</c>
/// (review minor 4): a configured window above it could never take effect there, and a rebirth aimed at an
/// offline edge node must not hold the operator's button for the whole window.
/// </summary>
internal static readonly TimeSpan MaxRebirthWait = TimeSpan.FromSeconds(15);
/// <summary>The quiet spell that ends a rebirth request's wait once its births have started arriving.</summary>
private static readonly TimeSpan RebirthQuietInterval = TimeSpan.FromMilliseconds(500);
@@ -225,6 +232,9 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
/// <summary>The Sparkplug birth-observation window this session was opened with (R06:M-4).</summary>
internal TimeSpan BirthWindow { get; }
/// <summary>How long a rebirth request waits for its births: the window, capped at <see cref="MaxRebirthWait"/>.</summary>
internal TimeSpan RebirthWait => BirthWindow < MaxRebirthWait ? BirthWindow : MaxRebirthWait;
/// <summary>How many NBIRTH/DBIRTH messages this session has observed — the settle loop's signal.</summary>
internal long BirthCount => Interlocked.Read(ref _birthCount);
@@ -987,7 +997,7 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
try
{
await SettleBirthsAsync(
() => BirthCount, baseline, awaitFirstBirth: true, RebirthQuietInterval, BirthWindow, cancellationToken)
() => BirthCount, baseline, awaitFirstBirth: true, RebirthQuietInterval, RebirthWait, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
@@ -806,9 +806,11 @@ public sealed class MqttDriver
.ConfigureAwait(false);
results[index] = new WriteResult(0u);
// A PUBACK (or a sent QoS 0 command) is the broker answering — evidence the session works,
// so it feeds LastSuccessfulRead alongside inbound messages.
Interlocked.Exchange(ref _lastPublishUtcTicks, DateTime.UtcNow.Ticks);
// A PUBACK is the broker answering — evidence the session works, so it feeds
// LastSuccessfulRead alongside inbound messages. A completed QoS 0 publish is NOT: it only
// means the bytes reached the local socket buffer, and frequent Sparkplug commands would
// otherwise keep the watchdog's clock fresh over a half-open session (review minor 2).
if (encoded.Qos >= 1) Interlocked.Exchange(ref _lastPublishUtcTicks, DateTime.UtcNow.Ticks);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@@ -1118,7 +1120,10 @@ public sealed class MqttDriver
await StartPrimaryHostAsync(cancellationToken).ConfigureAwait(false);
// Re-checked after publishing: a shutdown whose gate wait expired between the check above and
// here captured whatever it found and left these fields for us. We still hold the gate, so no
// here captured whatever it found and left these fields for us. Intended consequence (review
// minor 8): a perfectly healthy init that is just completing when a ShutdownAsync arrives also
// releases itself and throws OperationCanceledException — the queued shutdown would tear it down
// anyway, and the actor serialises init/shutdown, so only an abandoned init can meet this. We still hold the gate, so no
// newer initialize can have published anything — everything in them is ours to release.
if (!IsCurrentLifecycle(generation))
{
@@ -454,6 +454,10 @@ public sealed class MqttSubscriptionManager
if (outcome.TransportFailure is { } failure)
{
// The refs are already Bad and pending either way. But a cancel of the CALLER's token (a pipeline
// timeout, host shutdown) is a cancellation, not a broker fault: surfacing it as
// InvalidOperationException made the resilience breaker count it as a failure (review minor 3).
var callerCancelled = cancellationToken.IsCancellationRequested;
// No verdict at all (transport throw / SUBSCRIBE deadline): THROW, so the caller holds no handle
// and DriverInstanceActor's health-tick reconcile re-subscribes. Returning a handle here was the
// silent-death case — the actor thought the subscription live and never retried. The refs already
@@ -464,6 +468,15 @@ public sealed class MqttSubscriptionManager
_handleByRawPath.TryRemove(new KeyValuePair<string, ISubscriptionHandle>(def.Name, handle));
}
if (callerCancelled)
{
throw new OperationCanceledException(
$"MQTT driver '{_driverId}': SUBSCRIBE for {pending.Count} tag(s) was cancelled by the caller; "
+ "the topics stay pending and will be retried.",
failure,
cancellationToken);
}
throw new InvalidOperationException(
$"MQTT driver '{_driverId}': SUBSCRIBE for {pending.Count} tag(s) got no broker verdict "
+ $"({failure.GetType().Name}); the subscription will be retried.",