diff --git a/.github/workflows/v2-ci.yml b/.github/workflows/v2-ci.yml
index b9263da5..d2e81dd4 100644
--- a/.github/workflows/v2-ci.yml
+++ b/.github/workflows/v2-ci.yml
@@ -57,7 +57,8 @@ jobs:
# Self-maintaining — a new *.Tests project is covered automatically, no matrix to widen —
# and matches CLAUDE.md's own `dotnet test ZB.MOM.WW.OtOpcUa.slnx` guidance. Excludes only
# the env-gated tiers: E2E (nightly, needs the docker-dev fleet) and LiveIntegration (needs
- # a real gateway/PLC/VPN). The skip-gated *.IntegrationTests Assert.Skip when their fixtures
+ # a real gateway/PLC/VPN). Category=SlowUnit (Mqtt socket-timeout tests, #606) is deliberately NOT
+ # excluded here — it is a local-dev-loop opt-out only. The skip-gated *.IntegrationTests Assert.Skip when their fixtures
# are unreachable, so they run harmlessly here (and are counted by the skip-gate below).
- name: dotnet test (whole solution, minus E2E + LiveIntegration)
run: >
diff --git a/CLAUDE.md b/CLAUDE.md
index 1c68f757..60374f76 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -359,6 +359,10 @@ dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests # a single tes
dotnet test --filter "FullyQualifiedName~MyTestClass.MyMethod" # a single test
```
+`Category=SlowUnit` (#606) tags the 16 wall-clock-bound Driver.Mqtt unit tests (blackhole connect-timeout /
+reconnect-backoff windows, ~1 min of the project's 73 s). CI runs them (its filter excludes only E2E +
+LiveIntegration); for a fast local loop add `--filter "Category!=SlowUnit"`.
+
CVE-2025-6965 / GHSA-2m69-gcr7-jv3q (transitive native `SQLitePCLRaw.lib.e_sqlite3`)
is **resolved** — the temporary `NuGetAuditSuppress` was removed once the patched bundle
shipped. `Core.AlarmHistorian` now carries a surgical direct pin of
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs
index 9311df52..16a2e4a3 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs
@@ -30,6 +30,7 @@ public sealed class MqttConnectionTests
/// actually wedges a client — a refused port returns ECONNREFUSED instantly and would pass
/// whether or not any deadline logic existed.
///
+ [Trait("Category", "SlowUnit")]
[Fact]
public async Task ConnectAsync_BlackholeBroker_FailsWithinDeadline_NoHang()
{
@@ -95,6 +96,7 @@ public sealed class MqttConnectionTests
/// IMqttClient held across failures. A failed attempt must therefore leave the
/// connection usable rather than poisoned.
///
+ [Trait("Category", "SlowUnit")]
[Fact]
public async Task ConnectAsync_CanBeRetriedOnTheSameInstanceAfterAFailedAttempt()
{
@@ -592,6 +594,7 @@ public sealed class MqttConnectionTests
/// InitializeAsync) — it must not silently leave a background reconnect worker
/// hammering an endpoint the caller has already given up on.
///
+ [Trait("Category", "SlowUnit")]
[Fact]
public async Task ConnectAsync_InitialAttemptFails_LeavesNoBackgroundReconnectStorm()
{
@@ -729,6 +732,7 @@ public sealed class MqttConnectionTests
/// Faulted) — and the backoff keeps the retry rate low enough that a handful of
/// attempts, not hundreds, land in the observation window.
///
+ [Trait("Category", "SlowUnit")]
[Fact]
public async Task BrokerStaysDown_RetriesUnderBackoff_WithoutHotLooping()
{
@@ -759,6 +763,7 @@ public sealed class MqttConnectionTests
/// Dispose during a reconnect backoff must stop the supervisor dead — no leaked task, no
/// later attempt resurrecting a connection the caller has torn down.
///
+ [Trait("Category", "SlowUnit")]
[Fact]
public async Task Dispose_DuringReconnectBackoff_StopsTheSupervisor_NoFurtherAttempts()
{
@@ -875,6 +880,7 @@ public sealed class MqttConnectionTests
/// against a perfectly healthy connection, at Debug level, inflating attempt so the
/// NEXT genuine outage waits max backoff instead of min.
///
+ [Trait("Category", "SlowUnit")]
[Fact]
public async Task CallerReconnectsWhileTheSupervisorIsInBackoff_SupervisorStandsDownSilently()
{
@@ -917,6 +923,7 @@ public sealed class MqttConnectionTests
/// A stood-down supervisor answers it one min-backoff (2 s) later. The 5 s window separates the
/// two by a clear margin; widening the burn only widens the separation.
///
+ [Trait("Category", "SlowUnit")]
[Fact]
public async Task AfterACallerWinsTheRace_TheNextGenuineOutageStillRecoversAtMinBackoff()
{
@@ -971,6 +978,7 @@ public sealed class MqttConnectionTests
/// dispose-elapsed assertion cannot tell those apart — DisposeAsync's own bounded join
/// returns promptly either way, leaving the supervisor alive behind it.
///
+ [Trait("Category", "SlowUnit")]
[Fact]
public async Task ReSubscribeThatHangsIgnoringItsToken_DoesNotParkTheSupervisor()
{
@@ -1085,6 +1093,7 @@ public sealed class MqttConnectionTests
/// An unrecoverable rejection must not leave a supervisor grinding away every backoff period
/// against a broker that will never accept these credentials.
///
+ [Trait("Category", "SlowUnit")]
[Fact]
public async Task ConnectAsync_RefusedCredentials_StartsNoReconnectSupervisor()
{
@@ -1156,6 +1165,7 @@ public sealed class MqttConnectionTests
/// The supervisor's own attempts go through the same CONNACK check. A broker that starts
/// refusing mid-life (credentials revoked, client banned) must stop the loop, not feed it.
///
+ [Trait("Category", "SlowUnit")]
[Fact]
public async Task Reconnect_BrokerStartsRefusing_FaultsAndStopsTheSupervisor()
{
@@ -1179,6 +1189,7 @@ public sealed class MqttConnectionTests
}
/// The mirror case: a transient refusal keeps the supervisor retrying under backoff.
+ [Trait("Category", "SlowUnit")]
[Fact]
public async Task Reconnect_TransientRefusal_KeepsRetryingUnderBackoff()
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs
index ba49a6c1..f57d0c3b 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs
@@ -211,6 +211,7 @@ public sealed class MqttDriverDiscoveryTests
/// fails must land rather than letting the failure escape
/// with the health surface still claiming Healthy.
///
+ [Trait("Category", "SlowUnit")]
[Fact]
public async Task ReinitializeAsync_SessionChangingDelta_Rebuilds_AndFaultsOnUnreachableBroker()
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverSessionLifecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverSessionLifecycleTests.cs
index 38bdeaa0..5622a682 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverSessionLifecycleTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverSessionLifecycleTests.cs
@@ -83,6 +83,7 @@ public sealed class MqttDriverSessionLifecycleTests
/// The control for the test above: the normalization must not quietly rewrite a legal
/// value, or a deployment's authored connect budget would silently become the default.
///
+ [Trait("Category", "SlowUnit")]
[Fact]
public async Task InitializeAsync_WithAnAuthoredConnectTimeout_KeepsIt()
{
@@ -126,6 +127,7 @@ public sealed class MqttDriverSessionLifecycleTests
/// can drop a session (which is what this test needs from it) but cannot PUBLISH.
///
///
+ [Trait("Category", "SlowUnit")]
[Fact]
public async Task ABrokerDrop_MarksCachedRefsBadNoCommunication_AndTheIdenticalReplayRecovers()
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugPrimaryHostTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugPrimaryHostTests.cs
index cfdcdd5a..48657c28 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugPrimaryHostTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugPrimaryHostTests.cs
@@ -243,6 +243,7 @@ public sealed class SparkplugPrimaryHostTests
/// one-second role sample, forever. It now backs off. The clock is frozen, so the real one-second
/// loop ticks several times without the backoff window ever opening; advancing the clock opens it.
///
+ [Trait("Category", "SlowUnit")]
[Fact]
public async Task A_failing_claim_backs_off_instead_of_redialling_every_tick()
{
@@ -313,6 +314,7 @@ public sealed class SparkplugPrimaryHostTests
/// 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.
///
+ [Trait("Category", "SlowUnit")]
[Fact]
public async Task Disposing_while_a_reconcile_is_in_flight_does_not_fault_on_a_disposed_gate()
{