merge: MQTT driver+transport review findings resolved (Driver.Mqtt-006/-007, Driver.Mqtt.Transport-002)

This commit is contained in:
Joseph Doherty
2026-08-10 08:01:34 -04:00
8 changed files with 270 additions and 24 deletions
@@ -366,6 +366,43 @@ public sealed class MqttConnectionTests
built.Credentials.GetUserName(built).ShouldBe("svc");
}
/// <summary>
/// <b>Driver.Mqtt.Transport-002.</b> A configured password with no username is dropped — MQTT
/// 3.1.1 may not carry one without the other, and this factory does not synthesize the
/// password-only CONNECT 5.0 would permit. What must not happen is the drop being silent: the
/// deployment then believes it authenticates and does not, which is the one direction an
/// operator cannot see from the config. The secret itself stays out of the log.
/// </summary>
[Fact]
public void BuildClientOptions_PasswordWithoutUsername_IsDroppedLoudlyAndWithoutLeakingIt()
{
const string secret = "s3cr3t-that-must-not-be-logged";
var log = new CapturingLogger();
var built = MqttClientOptionsFactory.BuildClientOptions(
new MqttDriverOptions { Host = Host, Password = secret }, clientIdSuffix: null, logger: log);
built.Credentials.ShouldBeNull();
log.CountContaining("password").ShouldBe(1);
log.CountContaining(secret).ShouldBe(0);
}
/// <summary>
/// The falsifying control for the test above: an ordinary anonymous deployment configures
/// neither credential and must stay silent, or every such driver warns on every connect.
/// </summary>
[Fact]
public void BuildClientOptions_NoCredentialsAtAll_SaysNothing()
{
var log = new CapturingLogger();
MqttClientOptionsFactory.BuildClientOptions(
new MqttDriverOptions { Host = Host }, clientIdSuffix: null, logger: log)
.Credentials.ShouldBeNull();
log.CountContaining("password").ShouldBe(0);
}
/// <summary>The ctor must not touch the network — the universal-browser throwaway-instance pattern.</summary>
[Fact]
public void Ctor_IsConnectionFree()
@@ -583,6 +583,51 @@ public sealed class MqttSubscriptionManagerTests
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(Good);
}
/// <summary>
/// <b>Driver.Mqtt-007.</b> Live subscribed topics that resolve to ZERO filters re-establish
/// nothing, which is the same outcome as a total SUBACK failure and must get the same answer:
/// throw, so <see cref="MqttConnection"/> tears the fresh session down and retries rather than
/// serving a connected-but-deaf driver. A warning was not enough — the whole point of the type's
/// split contract is that "no subscription was re-established" is fatal to the session.
/// </summary>
/// <remarks>
/// The state is reached the only way production can reach it: <see cref="MqttSubscriptionManager.Register"/>
/// prunes <c>_subscribedTopics</c> of the topics its new table no longer carries, but a SUBSCRIBE
/// already in flight records its topic <i>after</i> that prune has run — so a redeploy landing
/// inside the subscribe round-trip leaves a live topic no authored tag resolves to.
/// </remarks>
[Fact]
public async Task OnReconnectedAsync_LiveTopicResolvingNoFilter_Throws_RatherThanServingADeafSession()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
transport.DuringSubscribe = () => mgr.Register([]); // the redeploy lands mid-round-trip
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
transport.DuringSubscribe = null;
await Should.ThrowAsync<InvalidOperationException>(
() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
}
/// <summary>
/// The falsifying control: a reconnect with no live subscriptions at all re-establishes nothing
/// because there is nothing to establish, and must stay a no-op. Throwing here would tear down
/// the session of every driver whose tags have not been subscribed yet.
/// </summary>
[Fact]
public async Task OnReconnectedAsync_NoLiveSubscriptions_IsANoOp()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await Should.NotThrowAsync(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
transport.Calls.Count.ShouldBe(0);
}
// ---------------------------------------------------------------------------------
// C1 — wildcard-authored tags on the paths that reconstruct state from a FILTER string.
// Delivery matches an incoming topic (concrete); reconnect + degrade key off the subscribed
@@ -898,10 +943,18 @@ public sealed class MqttSubscriptionManagerTests
/// <summary>When set, the whole subscribe call throws instead of answering.</summary>
public Exception? Throw { get; set; }
/// <summary>
/// Runs inside the subscribe round-trip, so a test can land a concurrent
/// <see cref="MqttSubscriptionManager.Register"/> in the window between its prune and this
/// call recording its topic as live.
/// </summary>
public Action? DuringSubscribe { get; set; }
public Task<IReadOnlyList<MqttSubscribeOutcome>> SubscribeAsync(
IReadOnlyList<MqttTopicSubscription> filters, CancellationToken cancellationToken)
{
Calls.Add(filters);
DuringSubscribe?.Invoke();
if (Throw is not null) throw Throw;
IReadOnlyList<MqttSubscribeOutcome> outcomes =
[.. filters.Select(f => new MqttSubscribeOutcome(f.Topic, !Reject(f.Topic), Reject(f.Topic) ? "NotAuthorized" : "GrantedQoS1"))];
@@ -1,4 +1,5 @@
using System.Text;
using Microsoft.Extensions.Logging;
using MQTTnet;
using Shouldly;
using Xunit;
@@ -254,10 +255,105 @@ public sealed class SparkplugPrimaryHostTests
await Should.NotThrowAsync(async () => await host.DisposeAsync());
}
/// <summary>
/// <b>Driver.Mqtt-006.</b> Dispose runs while a reconcile is in flight — the claim is inside the
/// gate, blocked on a broker that accepts TCP and never answers CONNECT — and must not leave that
/// reconcile releasing a disposed semaphore. The symptom is an <c>ObjectDisposedException</c>
/// naming <c>SemaphoreSlim</c>, logged as a reconcile failure: it tells an operator nothing about
/// what happened, and it hides whatever the reconcile was actually failing on.
/// </summary>
/// <remarks>
/// Deterministic rather than timing-dependent: the gate is taken before the connect starts, so
/// the broker having accepted the socket proves the claim is inside it when dispose runs.
/// </remarks>
[Fact]
public async Task Disposing_while_a_reconcile_is_in_flight_does_not_fault_on_a_disposed_gate()
{
using var broker = new MiniBroker { Blackhole = true };
var log = new ExceptionCapturingLogger();
var options = Options() with
{
Host = "127.0.0.1",
Port = broker.Port,
UseTls = false,
ProtocolVersion = MqttProtocolVersion.V311,
ConnectTimeoutSeconds = 2,
};
var host = new SparkplugPrimaryHost(options, "drv-1", new FixedRole(shouldActAsPrimary: true), log);
var starting = host.StartAsync(CancellationToken.None);
(await WaitUntilAsync(() => broker.AcceptedCount > 0, TimeSpan.FromSeconds(10)))
.ShouldBeTrue("the claim must be inside the gate before dispose runs");
await host.DisposeAsync();
await starting;
log.Captured.ShouldNotContain(
name => name == nameof(ObjectDisposedException),
"a teardown racing an in-flight reconcile must not surface a disposed-semaphore failure");
}
private static async Task<bool> WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (condition())
{
return true;
}
await Task.Delay(25);
}
return condition();
}
private sealed class FixedRole(bool shouldActAsPrimary) : IDriverPrimaryRoleSource
{
public bool ShouldActAsPrimary { get; private set; } = shouldActAsPrimary;
public void PublishPrimaryRole(bool value) => ShouldActAsPrimary = value;
}
/// <summary>Records the type name of every exception logged, which is the observable here.</summary>
private sealed class ExceptionCapturingLogger : ILogger
{
private readonly List<string> _captured = [];
public IReadOnlyList<string> Captured
{
get
{
lock (_captured)
{
return [.. _captured];
}
}
}
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull
=> null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (exception is null)
{
return;
}
lock (_captured)
{
_captured.Add(exception.GetType().Name);
}
}
}
}