fix(mqtt): stop disposing the primary-host gate a live reconcile still holds

Driver.Mqtt-006 (Low, concurrency). DisposeAsync disposed _gate while StopAsync
cancels the reconcile loop WITHOUT awaiting it, so a ReconcileAsync already in
flight could be inside _gate.WaitAsync, or on its way to the finally-release,
when the semaphore went away. The release then threw ObjectDisposedException
naming SemaphoreSlim, logged as a reconcile failure — telling an operator
nothing about what happened and hiding whatever the reconcile was really
failing on.

Left undisposed, matching MqttConnection's documented rule for its own lifecycle
primitives and its stated rationale: a late callback racing teardown should be a
benign no-op rather than an ObjectDisposedException naming the wrong type. Two
lifecycle types in one module disagreeing about this is what gets copied into
the third. 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.

Test 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) accepting the socket proves the claim is inside the gate when dispose
runs. RED: log.Captured contained "ObjectDisposedException".
This commit is contained in:
Joseph Doherty
2026-08-10 07:58:38 -04:00
parent 2298485bda
commit 3f85ba985e
2 changed files with 108 additions and 2 deletions
@@ -73,7 +73,10 @@ public sealed class SparkplugPrimaryHost : IAsyncDisposable
private readonly ILogger? _logger;
private readonly TimeProvider _timeProvider;
/// <summary>Serializes claim/release against each other and against <see cref="DisposeAsync"/>.</summary>
/// <summary>
/// Serializes claim/release against each other and against <see cref="DisposeAsync"/>. Never
/// disposed — see <see cref="DisposeAsync"/> for why.
/// </summary>
private readonly SemaphoreSlim _gate = new(1, 1);
private CancellationTokenSource? _loopCts;
@@ -275,7 +278,14 @@ public sealed class SparkplugPrimaryHost : IAsyncDisposable
ex, "MQTT driver '{DriverId}': Sparkplug primary-host stand-down failed during dispose.", _driverId);
}
_gate.Dispose();
// _gate is deliberately NOT disposed, matching MqttConnection's rule for its own lifecycle
// primitives. StopAsync cancels the reconcile loop without awaiting it, so a ReconcileAsync can
// still be inside the gate — or on its way to the finally-release — when this returns. Against a
// disposed semaphore that release throws an ObjectDisposedException naming SemaphoreSlim, which
// is logged as a reconcile failure and tells an operator nothing (and hides whatever the
// reconcile was really failing on). Left undisposed the late release is a harmless no-op: the
// gate holds no timer and no registration, and ClaimAsync re-checks _disposed under it, so no
// claim can be created after dispose either way.
}
private async Task ReconcileLoopAsync(CancellationToken ct)