diff --git a/mbproxy/tests/Mbproxy.Tests/Bcd/BcdCodecTests.cs b/mbproxy/tests/Mbproxy.Tests/Bcd/BcdCodecTests.cs
index ee362f9..038d872 100644
--- a/mbproxy/tests/Mbproxy.Tests/Bcd/BcdCodecTests.cs
+++ b/mbproxy/tests/Mbproxy.Tests/Bcd/BcdCodecTests.cs
@@ -44,6 +44,20 @@ public sealed class BcdCodecTests
.ParamName.ShouldBe("value");
}
+ ///
+ /// Phase 12 (W3 test gap #11) — locks the boundary contract for the `(uint)value > Max16`
+ /// range check. `int.MinValue` cast to `uint` becomes `0x80000000`, which is well above
+ /// `Max16` (= 9999), so the throw fires cleanly without arithmetic surprise. Prevents
+ /// regressions if the bounds check is ever rewritten with a two-sided int comparison
+ /// that would underflow on extreme negatives.
+ ///
+ [Fact]
+ public void Encode16_IntMinValue_Throws_OutOfRange_NoArithmeticSurprise()
+ {
+ Should.Throw(() => BcdCodec.Encode16(int.MinValue))
+ .ParamName.ShouldBe("value");
+ }
+
// ── Decode16 ────────────────────────────────────────────────────────────
[Fact]
diff --git a/mbproxy/tests/Mbproxy.Tests/Configuration/ConfigReconcilerTests.cs b/mbproxy/tests/Mbproxy.Tests/Configuration/ConfigReconcilerTests.cs
index a8ea22b..01c1ea0 100644
--- a/mbproxy/tests/Mbproxy.Tests/Configuration/ConfigReconcilerTests.cs
+++ b/mbproxy/tests/Mbproxy.Tests/Configuration/ConfigReconcilerTests.cs
@@ -280,6 +280,72 @@ public sealed class ConfigReconcilerTests : IAsyncDisposable
// under concurrent load.
Assert.Equal(5, counters.ReloadAppliedCount);
}
+
+ ///
+ /// Phase 12 (W3 test gap #16) — stress-test the W2.3 ConcurrentDictionary fix and the
+ /// W2.1 coalescing-accessor wiring. Many concurrent Apply calls drive add/remove of
+ /// many distinct PLCs; without W2.3's ConcurrentDictionary the inner Task.WhenAll
+ /// continuations would corrupt the dictionary and crash with KeyNotFoundException or
+ /// ArgumentException. The test asserts: all applies complete, no exceptions are
+ /// thrown, and the reload counter is exactly the apply count.
+ ///
+ [Fact(Timeout = 30_000)]
+ public async Task Apply_ManyConcurrentReloads_With_PlcChurn_NoCorruption()
+ {
+ // Empty initial — first Apply will Add all PLCs.
+ var initial = MakeOptions([]);
+ var monitor = new FakeOptionsMonitor(initial);
+
+ var supervisors = new System.Collections.Concurrent.ConcurrentDictionary(StringComparer.Ordinal);
+
+ var counters = new ServiceCounters();
+ var reconciler = BuildReconciler(monitor, counters);
+ _reconcilers.Add(reconciler);
+ reconciler.Attach(supervisors, initial);
+
+ // Build 8 different option snapshots, each a different PLC roster.
+ // Each Apply will trigger Add+Remove churn against the live supervisor dict —
+ // exactly the path that W2.3's ConcurrentDictionary was needed for.
+ const int snapshots = 8;
+ const int plcsPerSnapshot = 4;
+ var snaps = new MbproxyOptions[snapshots];
+ var allPlcs = new List();
+ for (int s = 0; s < snapshots; s++)
+ {
+ var plcsForSnap = new PlcOptions[plcsPerSnapshot];
+ for (int p = 0; p < plcsPerSnapshot; p++)
+ {
+ plcsForSnap[p] = MakePlc($"PLC-{s}-{p}", PickFreePort());
+ allPlcs.Add(plcsForSnap[p]);
+ }
+ snaps[s] = MakeOptions(plcsForSnap);
+ }
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(25));
+
+ // Fire 16 concurrent applies cycling through the 8 snapshots so each is
+ // submitted twice. Inner per-PLC Task.WhenAll continuations from W2.3 will run
+ // in parallel and stress-test the dictionary mutation safety.
+ var tasks = Enumerable.Range(0, 16)
+ .Select(i => Task.Run(() => reconciler.ApplyAsync(snaps[i % snapshots], cts.Token), cts.Token))
+ .ToArray();
+
+ var results = await Task.WhenAll(tasks);
+
+ Assert.All(results, r => Assert.True(r, "every Apply must succeed"));
+ Assert.Equal(16, counters.ReloadAppliedCount);
+
+ // Final dictionary state: all keys present must come from the last-applied snapshot.
+ // The "last-applied snapshot" depends on scheduling so we just verify NO orphan
+ // entries — every supervisor in the dict must correspond to some snapshot's PLCs.
+ var validNames = new HashSet(allPlcs.Select(p => p.Name));
+ foreach (var name in supervisors.Keys)
+ Assert.Contains(name, validNames);
+
+ // Track supervisors for cleanup.
+ foreach (var s in supervisors.Values)
+ _supervisors.Add(s);
+ }
}
///
diff --git a/mbproxy/tests/Mbproxy.Tests/Configuration/HotReloadE2ETests.cs b/mbproxy/tests/Mbproxy.Tests/Configuration/HotReloadE2ETests.cs
index 3027874..2782661 100644
--- a/mbproxy/tests/Mbproxy.Tests/Configuration/HotReloadE2ETests.cs
+++ b/mbproxy/tests/Mbproxy.Tests/Configuration/HotReloadE2ETests.cs
@@ -363,6 +363,68 @@ public sealed class HotReloadE2ETests : IAsyncLifetime
await host.StopAsync(stopCts.Token);
}
+ // ── Phase 12 (W3 test gap #10) — ReadCoalescing.Enabled hot-reload flip ─────────────
+
+ ///
+ /// W3 — verifies that flipping Mbproxy.Resilience.ReadCoalescing.Enabled at
+ /// runtime via hot-reload propagates to the live
+ /// snapshot. The W2.1 fix wires the accessor through to add/restart supervisors;
+ /// the multiplexer reads it per-PDU. Proving the IOptionsMonitor sees the new value
+ /// is sufficient — the per-PDU read path is unit-tested at the multiplexer level.
+ ///
+ [Fact(Timeout = 8_000)]
+ public async Task E2E_ReadCoalescingEnabled_FlipAtRuntime_PropagatesToOptionsMonitor()
+ {
+ int port = PickFreePort();
+ int adminPort = PickFreePort();
+
+ WriteConfigWithCoalescing(_configPath, port, adminPort, enabled: true);
+
+ using var host = BuildHost(_configPath);
+ using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ await host.StartAsync(startCts.Token);
+ await WaitForAsync(() => CanConnect(port), TimeSpan.FromSeconds(5),
+ "listener should be reachable after startup");
+
+ var monitor = host.Services
+ .GetRequiredService>();
+ monitor.CurrentValue.Resilience.ReadCoalescing.Enabled.ShouldBeTrue(
+ "initial config sets Enabled=true");
+
+ // Flip to false and re-save.
+ WriteConfigWithCoalescing(_configPath, port, adminPort, enabled: false);
+
+ await WaitForAsync(
+ () => monitor.CurrentValue.Resilience.ReadCoalescing.Enabled == false,
+ TimeSpan.FromSeconds(5),
+ "IOptionsMonitor.CurrentValue must reflect Enabled=false after hot-reload");
+
+ using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ await host.StopAsync(stopCts.Token);
+ }
+
+ private static void WriteConfigWithCoalescing(
+ string path, int listenPort, int adminPort, bool enabled)
+ {
+ var doc = new
+ {
+ Mbproxy = new
+ {
+ AdminPort = adminPort,
+ BcdTags = new { Global = Array.Empty