merge: MQTT driver+transport review findings resolved (Driver.Mqtt-006/-007, Driver.Mqtt.Transport-002)
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
| Review date | 2026-08-10 |
|
| Review date | 2026-08-10 |
|
||||||
| Commit reviewed | `b990adae` |
|
| Commit reviewed | `b990adae` |
|
||||||
| Status | Reviewed |
|
| Status | Reviewed |
|
||||||
| Open findings | 1 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Checklist coverage
|
## Checklist coverage
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ existing assertion depends on the branch being silent (713 tests green).
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport/MqttClientOptionsFactory.cs:97-100` |
|
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport/MqttClientOptionsFactory.cs:97-100` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** Credentials are attached only when `Username` is non-empty:
|
**Description:** Credentials are attached only when `Username` is non-empty:
|
||||||
`if (!string.IsNullOrEmpty(options.Username)) builder.WithCredentials(options.Username, options.Password ?? "")`.
|
`if (!string.IsNullOrEmpty(options.Username)) builder.WithCredentials(options.Username, options.Password ?? "")`.
|
||||||
@@ -129,4 +129,15 @@ credential is visible; optionally send password-only credentials under `V500`, w
|
|||||||
permits it. Do **not** log the password itself — nothing on this path formats it today, and the
|
permits it. Do **not** log the password itself — nothing on this path formats it today, and the
|
||||||
options record's `PrintMembers` override exists to keep it that way.
|
options record's `PrintMembers` override exists to keep it that way.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution (2026-08-10):** Fixed `5d4dd17d` — the recommendation's first half only. A password
|
||||||
|
configured with a blank username now logs a warning saying the connection is being made
|
||||||
|
**anonymously** and naming the two ways to make that intentional; the credential is still not sent,
|
||||||
|
and the secret is never formatted into the message. The optional half (a password-only CONNECT
|
||||||
|
under `V500`) was deliberately **not** taken: 3.1.1 forbids it, so it cannot be the uniform
|
||||||
|
behaviour of a factory that serves both versions, and adding it under `V500` alone would change the
|
||||||
|
wire behaviour of deployments that connect happily today — the same "the explicit config stays
|
||||||
|
authoritative, say so rather than second-guess it" line Driver.Mqtt.Transport-001 settled on.
|
||||||
|
TDD: the warning assertion failed RED (`log.CountContaining("password") should be 1 but was 0`)
|
||||||
|
before the change. Paired with a falsifying control — a config carrying **neither** credential must
|
||||||
|
stay silent, or the fix would warn on every ordinary anonymous deployment and the first test would
|
||||||
|
still pass. Suite green (734).
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
| Review date | 2026-08-10 |
|
| Review date | 2026-08-10 |
|
||||||
| Commit reviewed | `b990adae` |
|
| Commit reviewed | `b990adae` |
|
||||||
| Status | Reviewed |
|
| Status | Reviewed |
|
||||||
| Open findings | 2 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Checklist coverage
|
## Checklist coverage
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@ with a comment naming the extension method that needs it. Build verified.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugPrimaryHost.cs:278` |
|
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugPrimaryHost.cs:278` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `DisposeAsync` calls `_gate.Dispose()`. `StopAsync` cancels the reconcile loop but
|
**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
|
does not await it, so a `ReconcileAsync` already in flight can be sitting in
|
||||||
@@ -236,7 +236,16 @@ stated rationale — the gate holds no timer and no surviving registration), or
|
|||||||
the reconcile loop before dispose returns. The former is the smaller change and the one already
|
the reconcile loop before dispose returns. The former is the smaller change and the one already
|
||||||
argued for in this module.
|
argued for in this module.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**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
|
### Driver.Mqtt-007
|
||||||
|
|
||||||
@@ -245,7 +254,7 @@ argued for in this module.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Error handling & resilience |
|
| Category | Error handling & resilience |
|
||||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs:406-424` (`OnReconnectedAsync`) |
|
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs:406-424` (`OnReconnectedAsync`) |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** When the reconnect path finds live subscribed topics but resolves **zero** filters
|
**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
|
for them, it logs a warning and returns normally. The type's own contract, stated two paragraphs
|
||||||
@@ -263,7 +272,19 @@ which is an argument for leaving the code alone, not for leaving the contract am
|
|||||||
*this* deaf-reconnect shape is exempt from the type's own rule. Do not leave the two answers side by
|
*this* deaf-reconnect shape is exempt from the type's own rule. Do not leave the two answers side by
|
||||||
side unexplained.
|
side unexplained.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**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
|
### Driver.Mqtt-008
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,18 @@ public static class MqttClientOptionsFactory
|
|||||||
{
|
{
|
||||||
builder = builder.WithCredentials(options.Username, options.Password ?? string.Empty);
|
builder = builder.WithCredentials(options.Username, options.Password ?? string.Empty);
|
||||||
}
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(options.Password))
|
||||||
|
{
|
||||||
|
// A password with no username is dropped, and the drop is the direction an operator cannot
|
||||||
|
// see: the config reads as authenticated and the CONNECT goes out anonymous. MQTT 3.1.1 may
|
||||||
|
// not carry a password without a username, so synthesizing one is not available on every
|
||||||
|
// protocol version this factory serves — and inventing a password-only CONNECT under 5.0
|
||||||
|
// only would change the wire behaviour of deployments that connect happily today. Say so
|
||||||
|
// once, here, and leave the configured values authoritative. Never format the secret.
|
||||||
|
logger?.LogWarning(
|
||||||
|
"MQTT: a password is configured with no username, so it is NOT being sent — this connection "
|
||||||
|
+ "will be made anonymously. Set the username, or clear the password to make that intentional.");
|
||||||
|
}
|
||||||
|
|
||||||
builder = builder.WithTlsOptions(tls => ConfigureTls(tls, options, logger));
|
builder = builder.WithTlsOptions(tls => ConfigureTls(tls, options, logger));
|
||||||
|
|
||||||
|
|||||||
@@ -397,6 +397,15 @@ public sealed class MqttSubscriptionManager
|
|||||||
/// rest keep flowing. That is the plan's "SUBACK failure → per-ref Bad, not hang", scoped
|
/// rest keep flowing. That is the plan's "SUBACK failure → per-ref Bad, not hang", scoped
|
||||||
/// to the case where it does not cost the whole driver.
|
/// to the case where it does not cost the whole driver.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Live topics resolving to zero filters is a total failure too</b> — it re-establishes
|
||||||
|
/// nothing, so it throws by the same rule rather than warning and returning. It costs no
|
||||||
|
/// other tag (every live topic is in the set that resolved to nothing) and it is not
|
||||||
|
/// permanent, because the retry runs against whatever <see cref="Register"/> has published
|
||||||
|
/// by then. It is reachable only when a deploy lands inside a subscribe round-trip:
|
||||||
|
/// <see cref="Register"/> prunes <c>_subscribedTopics</c> of the topics its new table no
|
||||||
|
/// longer carries, but an in-flight SUBSCRIBE records its own topic after that prune ran.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="cancellationToken">The connection's lifetime token; cancelled by its dispose.</param>
|
/// <param name="cancellationToken">The connection's lifetime token; cancelled by its dispose.</param>
|
||||||
/// <returns>A task that completes when the re-subscribe has been answered.</returns>
|
/// <returns>A task that completes when the re-subscribe has been answered.</returns>
|
||||||
@@ -421,24 +430,21 @@ public sealed class MqttSubscriptionManager
|
|||||||
var filters = BuildFilters(live, _defaultQos);
|
var filters = BuildFilters(live, _defaultQos);
|
||||||
if (filters.Count == 0)
|
if (filters.Count == 0)
|
||||||
{
|
{
|
||||||
// Silence here is how the wildcard defect hid: no exception, no log, no SUBSCRIBE. If any
|
// Silence here is how the wildcard defect hid: no exception, no log, no SUBSCRIBE. Live
|
||||||
// topic was live, resolving zero filters for it is an anomaly and must be loud.
|
// topics resolving to zero filters re-establishes nothing, which is the total-failure case
|
||||||
|
// by a different route, so it gets the total-failure answer — see the remarks.
|
||||||
if (subscribed.Count > 0)
|
if (subscribed.Count > 0)
|
||||||
{
|
{
|
||||||
_logger?.LogWarning(
|
throw new InvalidOperationException(
|
||||||
"MQTT driver '{DriverId}': reconnect resolved NO filters for {TopicCount} live topic(s) "
|
$"MQTT driver '{_driverId}': reconnect resolved NO filters for {subscribed.Count} live "
|
||||||
+ "({Topics}); those subscriptions are not being re-established.",
|
+ $"topic(s) ({string.Join(", ", subscribed)}); tearing the session down rather than "
|
||||||
_driverId,
|
+ "serving a connection that receives nothing.");
|
||||||
subscribed.Count,
|
|
||||||
string.Join(", ", subscribed));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger?.LogDebug(
|
|
||||||
"MQTT driver '{DriverId}': reconnect had no live subscriptions to re-establish.",
|
|
||||||
_driverId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogDebug(
|
||||||
|
"MQTT driver '{DriverId}': reconnect had no live subscriptions to re-establish.",
|
||||||
|
_driverId);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ public sealed class SparkplugPrimaryHost : IAsyncDisposable
|
|||||||
private readonly ILogger? _logger;
|
private readonly ILogger? _logger;
|
||||||
private readonly TimeProvider _timeProvider;
|
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 readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
|
|
||||||
private CancellationTokenSource? _loopCts;
|
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);
|
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)
|
private async Task ReconcileLoopAsync(CancellationToken ct)
|
||||||
|
|||||||
@@ -366,6 +366,43 @@ public sealed class MqttConnectionTests
|
|||||||
built.Credentials.GetUserName(built).ShouldBe("svc");
|
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>
|
/// <summary>The ctor must not touch the network — the universal-browser throwaway-instance pattern.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Ctor_IsConnectionFree()
|
public void Ctor_IsConnectionFree()
|
||||||
|
|||||||
@@ -583,6 +583,51 @@ public sealed class MqttSubscriptionManagerTests
|
|||||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(Good);
|
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.
|
// 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
|
// 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>
|
/// <summary>When set, the whole subscribe call throws instead of answering.</summary>
|
||||||
public Exception? Throw { get; set; }
|
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(
|
public Task<IReadOnlyList<MqttSubscribeOutcome>> SubscribeAsync(
|
||||||
IReadOnlyList<MqttTopicSubscription> filters, CancellationToken cancellationToken)
|
IReadOnlyList<MqttTopicSubscription> filters, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Calls.Add(filters);
|
Calls.Add(filters);
|
||||||
|
DuringSubscribe?.Invoke();
|
||||||
if (Throw is not null) throw Throw;
|
if (Throw is not null) throw Throw;
|
||||||
IReadOnlyList<MqttSubscribeOutcome> outcomes =
|
IReadOnlyList<MqttSubscribeOutcome> outcomes =
|
||||||
[.. filters.Select(f => new MqttSubscribeOutcome(f.Topic, !Reject(f.Topic), Reject(f.Topic) ? "NotAuthorized" : "GrantedQoS1"))];
|
[.. filters.Select(f => new MqttSubscribeOutcome(f.Topic, !Reject(f.Topic), Reject(f.Topic) ? "NotAuthorized" : "GrantedQoS1"))];
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using MQTTnet;
|
using MQTTnet;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -254,10 +255,105 @@ public sealed class SparkplugPrimaryHostTests
|
|||||||
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
|
private sealed class FixedRole(bool shouldActAsPrimary) : IDriverPrimaryRoleSource
|
||||||
{
|
{
|
||||||
public bool ShouldActAsPrimary { get; private set; } = shouldActAsPrimary;
|
public bool ShouldActAsPrimary { get; private set; } = shouldActAsPrimary;
|
||||||
|
|
||||||
public void PublishPrimaryRole(bool value) => ShouldActAsPrimary = value;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user