Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugPrimaryHostTests.cs
T
Joseph Doherty 3f85ba985e 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".
2026-08-10 07:58:38 -04:00

360 lines
15 KiB
C#

using System.Text;
using Microsoft.Extensions.Logging;
using MQTTnet;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Unit tests for the Sparkplug primary-host claim (Gitea #511) — the parts that are decidable
/// without a broker: the STATE bodies, the Will registration, and the gate that decides whether a
/// claim is attempted at all.
/// </summary>
/// <remarks>
/// The claim/release state machine itself needs a broker and is covered by
/// <c>MqttPrimaryHostLiveTests</c> in the integration suite. What is pinned here is everything a
/// live test could not tell apart from a working implementation by accident — above all that the
/// birth and the Will carry the SAME timestamp, which a live test would see as two well-formed
/// messages.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class SparkplugPrimaryHostTests
{
private static MqttDriverOptions Options(
bool actAsPrimaryHost = true,
string hostId = "otopcua-primary",
MqttMode mode = MqttMode.SparkplugB)
=> new()
{
Host = "broker.example",
Port = 1883,
ClientId = "otopcua",
Mode = mode,
Sparkplug = new MqttSparkplugOptions
{
GroupId = "OtOpcUaSim",
HostId = hostId,
ActAsPrimaryHost = actAsPrimaryHost,
},
};
// ---- The matched-timestamp invariant --------------------------------------------------------
[Fact]
public void The_birth_and_the_will_carry_the_same_timestamp()
{
// Sparkplug 3.0 pairs a host's birth and death certificates by their timestamp, so an edge node
// may ignore an OFFLINE older than the ONLINE it holds. The two bodies are necessarily built at
// different moments — the Will goes in the CONNECT packet, the birth is published after CONNACK
// — so reading the clock twice is the natural mistake, and it produces two individually
// well-formed messages that no live test would flag.
var session = SparkplugStatePayload.ForSession("otopcua-primary", 1_770_000_000_123);
SparkplugStatePayload.TryDecode(session.OnlineBody, out var online, out var birthTs).ShouldBeTrue();
SparkplugStatePayload.TryDecode(session.OfflineBody, out var offline, out var deathTs).ShouldBeTrue();
online.ShouldBeTrue();
offline.ShouldBeFalse();
birthTs.ShouldBe(1_770_000_000_123);
deathTs.ShouldBe(birthTs);
}
[Fact]
public void The_state_topic_is_the_v3_form()
=> SparkplugStatePayload.ForSession("scada-primary", 1).Topic.ShouldBe("spBv1.0/STATE/scada-primary");
[Fact]
public void The_timestamp_is_a_bare_json_number()
{
// A quoted timestamp still parses as a valid JSON object, so a serializer choosing string
// formatting for a long would be silently wrong to every conformant host.
var body = Encoding.UTF8.GetString(SparkplugStatePayload.Encode(online: true, 1_770_000_000_123));
body.ShouldBe("{\"online\":true,\"timestamp\":1770000000123}");
}
[Fact]
public void The_body_survives_a_non_invariant_culture()
{
var previous = Thread.CurrentThread.CurrentCulture;
Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("de-DE");
try
{
Encoding.UTF8.GetString(SparkplugStatePayload.Encode(online: false, 1_770_000_000_123))
.ShouldBe("{\"online\":false,\"timestamp\":1770000000123}");
}
finally
{
Thread.CurrentThread.CurrentCulture = previous;
}
}
// ---- The Will actually reaches the CONNECT packet --------------------------------------------
[Fact]
public void The_will_is_registered_on_the_client_options_retained_at_qos1()
{
// The single most important assertion in this file. A retained ONLINE with no Will behind it is
// strictly worse than publishing nothing: a crashed node then asserts forever that a dead host
// is alive. This is the check that the death certificate is armed at CONNECT rather than merely
// constructed.
var session = SparkplugStatePayload.ForSession("otopcua-primary", 42);
var will = new MqttLastWill(session.Topic, session.OfflineBody, SparkplugStatePayload.StateQos, Retain: true);
var built = MqttClientOptionsFactory.BuildClientOptions(
Options(), SparkplugPrimaryHost.ClientIdSuffix, logger: null, lastWill: will, cleanSessionOverride: true);
built.WillTopic.ShouldBe("spBv1.0/STATE/otopcua-primary");
built.WillRetain.ShouldBeTrue();
built.WillQualityOfServiceLevel.ShouldBe(MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce);
SparkplugStatePayload.TryDecode(built.WillPayload, out var online, out _).ShouldBeTrue();
online.ShouldBeFalse();
}
[Fact]
public void No_will_is_registered_when_none_is_supplied()
// Every other connection this repo opens — ingest, browse, probe — must stay Will-free. A Will
// leaking onto the ingest session would publish a host death when ingest merely reconnects.
=> MqttClientOptionsFactory.BuildClientOptions(Options(), clientIdSuffix: null).WillTopic.ShouldBeNull();
[Fact]
public void The_claim_connection_forces_clean_session_over_the_configured_value()
{
// A broker resuming a previous session for this client id may keep the OLD session's Will and
// discard the one this CONNECT carries — a claim with no death certificate behind it.
var persistent = Options() with { CleanSession = false };
MqttClientOptionsFactory.BuildClientOptions(persistent, "-spbhost", cleanSessionOverride: true)
.CleanSession.ShouldBeTrue();
// ...and the override is genuinely an override, not a constant.
MqttClientOptionsFactory.BuildClientOptions(persistent, clientIdSuffix: null)
.CleanSession.ShouldBeFalse();
}
[Fact]
public void The_claim_connection_uses_a_distinct_client_id()
{
// Two MQTT clients presenting one client id is not an error the broker reports: it evicts the
// older one. Ingest and the claim would take turns killing each other, forever, looking like a
// flapping broker.
var ingest = MqttClientOptionsFactory.BuildClientOptions(Options(), clientIdSuffix: null).ClientId;
var claim = MqttClientOptionsFactory
.BuildClientOptions(Options(), SparkplugPrimaryHost.ClientIdSuffix).ClientId;
claim.ShouldNotBe(ingest);
claim.ShouldBe("otopcua-spbhost");
}
// ---- Whether a claim is attempted at all -----------------------------------------------------
[Fact]
public void A_configured_claim_is_claimable()
=> SparkplugPrimaryHost.CanClaim(Options()).ShouldBeTrue();
[Theory]
[InlineData("")]
[InlineData(" ")]
public void A_blank_host_id_is_refused_rather_than_publishing_to_a_malformed_topic(string hostId)
{
// "spBv1.0/STATE/" is a legal MQTT topic, so a publish would succeed and reach nobody.
SparkplugPrimaryHost.CanClaim(Options(hostId: hostId)).ShouldBeFalse();
SparkplugPrimaryHost.DescribeUnusable(Options(hostId: hostId)).ShouldNotBeNullOrWhiteSpace();
}
[Fact]
public void The_flag_is_ignored_outside_sparkplug_mode_and_says_so()
{
var plain = Options(mode: MqttMode.Plain);
SparkplugPrimaryHost.CanClaim(plain).ShouldBeFalse();
SparkplugPrimaryHost.DescribeUnusable(plain).ShouldNotBeNullOrWhiteSpace();
}
[Fact]
public void An_unset_flag_produces_no_complaint()
{
// DescribeUnusable must stay silent about a configuration that never asked for anything, or the
// driver warns on every ordinary Sparkplug deployment.
SparkplugPrimaryHost.CanClaim(Options(actAsPrimaryHost: false)).ShouldBeFalse();
SparkplugPrimaryHost.DescribeUnusable(Options(actAsPrimaryHost: false)).ShouldBeNull();
SparkplugPrimaryHost.DescribeUnusable(Options(actAsPrimaryHost: false, hostId: "")).ShouldBeNull();
}
[Fact]
public void Constructing_a_claim_without_a_host_id_throws_rather_than_half_working()
=> Should.Throw<ArgumentException>(() =>
new SparkplugPrimaryHost(Options(hostId: " "), "drv-1", UnmanagedDriverPrimaryRoleSource.Instance));
// ---- The gate ---------------------------------------------------------------------------------
[Fact]
public void A_driver_with_no_role_source_claims()
{
// The documented fallback for a standalone host or a test: with nothing publishing a role there
// is no peer to collide with, and defaulting closed would make the feature silently inert on the
// single-node deployment that is its commonest use.
UnmanagedDriverPrimaryRoleSource.Instance.ShouldActAsPrimary.ShouldBeTrue();
// Publishing into it is a no-op rather than a throw, so a caller need not special-case it.
UnmanagedDriverPrimaryRoleSource.Instance.PublishPrimaryRole(false);
UnmanagedDriverPrimaryRoleSource.Instance.ShouldActAsPrimary.ShouldBeTrue();
}
[Fact]
public async Task A_secondary_never_even_attempts_a_claim_connection()
{
// The whole point of gating. Asserted on ATTEMPTS, not on IsClaimed: both nodes point at the
// same unreachable broker here, so IsClaimed is false either way and an IsClaimed-only
// assertion would pass just as happily with the gate deleted. See ClaimAttemptsForTests.
var host = new SparkplugPrimaryHost(
Unreachable(), "drv-1", new FixedRole(shouldActAsPrimary: false));
await host.StartAsync(TestContext.Current.CancellationToken);
host.ClaimAttemptsForTests.ShouldBe(0);
host.IsClaimed.ShouldBeFalse();
await host.DisposeAsync();
}
[Fact]
public async Task A_primary_attempts_the_claim_and_survives_a_broker_it_cannot_reach()
{
// The falsifying control for the test above — same options, same broker, opposite verdict — so
// "0 attempts" there is a fact about the gate rather than about the fixture.
//
// It also pins the swallow: the claim is advisory and ingest is the data plane, so a broker that
// refuses it must cost a warning, not the driver. And IsClaimed must stay honest — a connection
// whose ONLINE never landed is not a claim.
var host = new SparkplugPrimaryHost(Unreachable(), "drv-1", new FixedRole(shouldActAsPrimary: true));
await Should.NotThrowAsync(() => host.StartAsync(TestContext.Current.CancellationToken));
host.ClaimAttemptsForTests.ShouldBe(1);
host.IsClaimed.ShouldBeFalse();
await host.DisposeAsync();
}
/// <summary>A broker that refuses instantly, so a connect attempt is observable but not slow.</summary>
private static MqttDriverOptions Unreachable() =>
Options() with { Host = "127.0.0.1", Port = 1, ConnectTimeoutSeconds = 1 };
[Fact]
public async Task Stopping_an_unclaimed_host_is_a_no_op()
{
var host = new SparkplugPrimaryHost(Options(), "drv-1", new FixedRole(shouldActAsPrimary: false));
await Should.NotThrowAsync(() => host.StopAsync(TestContext.Current.CancellationToken));
// Twice: teardown runs from both ShutdownAsync and DisposeAsync, so a second release that threw
// — or that published a second OFFLINE — would surface on every ordinary driver shutdown.
await Should.NotThrowAsync(async () => await host.DisposeAsync());
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);
}
}
}
}