mbproxy: strip historical phase/wave/plan references from source comments

Comments described the *history* of how the code arrived (phase numbers,
wave IDs, review IDs, dated TODOs) instead of what it does today. That
scaffolding rotted as the codebase evolved. Cleaned 60 source files +
.gitignore; behaviour unchanged (387/387 tests still pass).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-14 13:04:30 -04:00
parent b3b8313e9c
commit 1a2856526a
60 changed files with 750 additions and 811 deletions
@@ -330,10 +330,10 @@ public sealed class AdminEndpointTests
System.IO.File.Move(tmp, path, overwrite: true);
}
// ── Phase 12 (W3 test gap) — non-GET methods rejected ──────────────────
// ── non-GET methods rejected ─────────────────────────────────────────
/// <summary>
/// W3 — verifies the admin endpoint rejects non-GET methods (POST / PUT / DELETE)
/// Verifies the admin endpoint rejects non-GET methods (POST / PUT / DELETE)
/// with HTTP 405 Method Not Allowed. The design intentionally exposes only `GET /`
/// and `GET /status.json`; this test guards against an accidental MapPost/Map* being
/// added later.
@@ -10,8 +10,8 @@ namespace Mbproxy.Tests.Bcd;
/// NOTE on allocation profile:
/// BcdCodec is a purely static class operating on value types (ushort, int, tuples).
/// It allocates only when constructing exception objects (the error path), never on
/// the success path. TryGet / hot-path decode callers in Phase 04 will be
/// allocation-free for valid BCD registers.
/// the success path. TryGet / hot-path decode callers are allocation-free for valid
/// BCD registers.
/// </summary>
[Trait("Category", "Unit")]
public sealed class BcdCodecTests
@@ -45,8 +45,8 @@ public sealed class BcdCodecTests
}
/// <summary>
/// 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
/// 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.
@@ -99,9 +99,9 @@ public sealed class BcdTagMapBuilderTests
}
/// <summary>
/// Phase 12 (W2.11) — duplicates within Global itself are now detected
/// pre-collapse and produce a DuplicateAddress error. (Before W2.11 the input
/// dictionary silently collapsed to last-write-wins, leaving the validator dead.)
/// Duplicates within Global itself are detected pre-collapse and produce a
/// DuplicateAddress error. (A naive input dictionary would silently collapse
/// to last-write-wins, leaving the validator dead.)
/// </summary>
[Fact]
public void Build_DuplicateAddressInGlobal_ReturnsDuplicateAddressError()
@@ -122,9 +122,9 @@ public sealed class BcdTagMapBuilderTests
}
/// <summary>
/// Phase 12 (W2.11) — duplicates within the per-PLC Add list itself are now detected
/// pre-collapse. (Cross-list collisions Global vs Add remain the legitimate width-
/// override pattern and are NOT errors — see the next test.)
/// Duplicates within the per-PLC Add list itself are detected pre-collapse.
/// (Cross-list collisions Global vs Add remain the legitimate width-override
/// pattern and are NOT errors — see the next test.)
/// </summary>
[Fact]
public void Build_DuplicateAddress_Within_AddList_ReturnsDuplicateAddressError()
@@ -147,9 +147,9 @@ public sealed class BcdTagMapBuilderTests
}
/// <summary>
/// Phase 12 (W2.11) — same-address entries appearing in BOTH Global AND Add are
/// the documented width-override pattern (design.md "Hybrid tag resolution"). They
/// must NOT be flagged as duplicates; Add wins.
/// Same-address entries appearing in BOTH Global AND Add are the documented
/// width-override pattern (design.md "Hybrid tag resolution"). They must NOT
/// be flagged as duplicates; Add wins.
/// </summary>
[Fact]
public void Build_AddOverridesGlobalAtSameAddress_NoDuplicateError_AddWins()
@@ -282,12 +282,12 @@ public sealed class ConfigReconcilerTests : IAsyncDisposable
}
/// <summary>
/// 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.
/// Stress-tests the live supervisor dictionary and the coalescing-accessor wiring.
/// Many concurrent Apply calls drive add/remove of many distinct PLCs; the inner
/// Task.WhenAll continuations must not corrupt the dictionary or crash with
/// KeyNotFoundException or ArgumentException. The test asserts: all applies
/// complete, no exceptions are thrown, and the reload counter is exactly the
/// apply count.
/// </summary>
[Fact(Timeout = 30_000)]
public async Task Apply_ManyConcurrentReloads_With_PlcChurn_NoCorruption()
@@ -305,7 +305,7 @@ public sealed class ConfigReconcilerTests : IAsyncDisposable
// 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.
// exactly the path that the ConcurrentDictionary guards against corruption.
const int snapshots = 8;
const int plcsPerSnapshot = 4;
var snaps = new MbproxyOptions[snapshots];
@@ -324,8 +324,8 @@ public sealed class ConfigReconcilerTests : IAsyncDisposable
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.
// submitted twice. Inner per-PLC Task.WhenAll continuations 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();
@@ -320,10 +320,10 @@ public sealed class HotReloadE2ETests : IAsyncLifetime
await host.StopAsync(stopCts.Token);
}
// ── Phase 12 (W3 test gap) — cache flush on tag-list reload ─────────────────────────
// ── cache flush on tag-list reload ──────────────────────────────────────────────────
/// <summary>
/// W2.8 / W3 — verifies that a tag-list reload for a PLC with a cacheable tag emits
/// Verifies that a tag-list reload for a PLC with a cacheable tag emits
/// <c>mbproxy.cache.flushed</c>. The cache count is 0 (no real backend to populate
/// it), but the event must still fire — it's the operator's signal that the in-memory
/// cache state was reset by a config reload.
@@ -363,13 +363,13 @@ public sealed class HotReloadE2ETests : IAsyncLifetime
await host.StopAsync(stopCts.Token);
}
// ── Phase 12 (W3 test gap #10) — ReadCoalescing.Enabled hot-reload flip ─────────────
// ── ReadCoalescing.Enabled hot-reload flip ──────────────────────────────────────────
/// <summary>
/// W3 — verifies that flipping <c>Mbproxy.Resilience.ReadCoalescing.Enabled</c> at
/// Verifies that flipping <c>Mbproxy.Resilience.ReadCoalescing.Enabled</c> at
/// runtime via hot-reload propagates to the live <see cref="IOptionsMonitor{T}"/>
/// 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
/// snapshot. The accessor is wired 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.
/// </summary>
[Fact(Timeout = 8_000)]
@@ -156,10 +156,10 @@ public sealed class ReloadValidatorTests
Assert.Contains(errors, e => e.Contains("non-empty"));
}
// ── Phase 12 (W2.10) — Cache.AllowLongTtl gate ──────────────────────────────────────
// ── Cache.AllowLongTtl gate ─────────────────────────────────────────────────────────
/// <summary>
/// W2 — per-tag CacheTtlMs > 60_000 without Cache.AllowLongTtl is rejected.
/// Per-tag CacheTtlMs > 60_000 without Cache.AllowLongTtl is rejected.
/// </summary>
[Fact]
public void Validate_PerTagCacheTtl_Above60s_Without_AllowLongTtl_Fails()
@@ -181,7 +181,7 @@ public sealed class ReloadValidatorTests
}
/// <summary>
/// W2 — same value passes when AllowLongTtl is true (operator opt-in).
/// Same value passes when AllowLongTtl is true (operator opt-in).
/// </summary>
[Fact]
public void Validate_PerTagCacheTtl_Above60s_With_AllowLongTtl_Passes()
@@ -203,9 +203,9 @@ public sealed class ReloadValidatorTests
}
/// <summary>
/// W2 — per-PLC DefaultCacheTtlMs > 60_000 inherited by a tag with null CacheTtlMs is
/// caught by the resolved-value check even if the per-PLC default check itself passes
/// (it doesn't, but this validates the defensive resolved re-check from W2.10).
/// Per-PLC DefaultCacheTtlMs > 60_000 inherited by a tag with null CacheTtlMs is
/// caught by the resolved-value check even if the per-PLC default check itself
/// passes (it doesn't, but this validates the defensive resolved re-check).
/// </summary>
[Fact]
public void Validate_ResolvedTtl_FromPerPlcDefault_AboveCap_Fails()
@@ -233,7 +233,7 @@ public sealed class ReloadValidatorTests
Assert.Contains(errors, e => e.Contains("60_000"));
}
// ── Phase 12 (W2.18) — ConnectionOptions validation ─────────────────────────────────
// ── ConnectionOptions validation ────────────────────────────────────────────────────
[Fact]
public void Validate_ZeroBackendConnectTimeoutMs_Fails()
@@ -92,7 +92,7 @@ internal static class TestHostBuilderExtensions
builder.Services.AddSerilog(serilogLogger, dispose: false);
builder.AddMbproxyOptions();
// Phase 03: register the no-op pipeline and ProxyWorker (replaces HeartbeatWorker).
// Register the no-op pipeline and ProxyWorker.
builder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>();
builder.Services.AddHostedService<ProxyWorker>();
@@ -1,7 +1,6 @@
<!-- xunit version: v3 (xunit.v3 3.2.2) — chosen because a stable release exists on NuGet as of 2026-05-13 -->
<!-- NModbus 3.0.83 — chosen for small footprint, net10.0 compatibility, and synchronous/async FC03/FC16 API
that maps directly to the Modbus PDU function codes used in smoke and e2e tests.
Added in Phase 01 as the Modbus TCP client for all simulator-backed tests. -->
<!-- xunit v3 (xunit.v3 3.2.2). NModbus 3.0.83 — small footprint, net10.0 compatibility, and
synchronous/async FC03/FC16 API that maps directly to the Modbus PDU function codes used
in smoke and e2e tests. -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
@@ -16,14 +15,13 @@
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<!-- xunit v3: stable as of 2026-05-13 -->
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Shouldly" Version="4.3.0" />
<!-- NModbus: Modbus TCP client for simulator smoke tests and e2e tests (Phase 01+) -->
<!-- NModbus: Modbus TCP client for simulator smoke tests and e2e tests. -->
<PackageReference Include="NModbus" Version="3.0.83" />
</ItemGroup>
@@ -31,13 +29,12 @@
<ProjectReference Include="..\..\src\Mbproxy\Mbproxy.csproj" />
</ItemGroup>
<!-- Phase 12 (W2.21) — the linked appsettings.json from Mbproxy.csproj propagates to
the test bin via the project reference. Tests build their own in-memory
configurations and must not pick up the shipped template's example PLCs. The
Target deletes the inherited file from both the build output AND the publish
payload (W4 / T3 — adds Publish so a `dotnet publish` against this csproj for a
packaged self-test would not leak the template's example PLCs into the published
bundle). -->
<!-- The linked appsettings.json from Mbproxy.csproj propagates to the test bin via the
project reference. Tests build their own in-memory configurations and must not pick
up the shipped template's example PLCs. This target deletes the inherited file from
both the build output AND the publish payload so a `dotnet publish` against this
csproj for a packaged self-test would not leak the template's example PLCs into the
published bundle. -->
<Target Name="RemoveInheritedAppsettings" AfterTargets="Build;Publish">
<Delete Files="$(OutputPath)appsettings.json" Condition="Exists('$(OutputPath)appsettings.json')" />
<Delete Files="$(PublishDir)appsettings.json" Condition="'$(PublishDir)' != '' AND Exists('$(PublishDir)appsettings.json')" />
@@ -41,9 +41,8 @@ public sealed class BcdPduPipelineTests
}
/// <summary>
/// Phase 9: the rewriter consumes <see cref="PerPlcContext.CurrentRequest"/> rather
/// than a per-pair last-request slot. Tests build a synthetic <see cref="InFlightRequest"/>
/// to drive response decoding.
/// The rewriter consumes <see cref="PerPlcContext.CurrentRequest"/>. Tests build a
/// synthetic <see cref="InFlightRequest"/> to drive response decoding.
/// </summary>
private static InFlightRequest MakeInFlight(byte fc, ushort startAddress, ushort qty)
=> new(
@@ -51,9 +50,8 @@ public sealed class BcdPduPipelineTests
Fc: fc,
StartAddress: startAddress,
Qty: qty,
// Phase 9: always exactly one party. We don't have a real UpstreamPipe in
// pipeline unit tests; the rewriter never dereferences the party list, so a
// null-forgiving placeholder is safe.
// We don't have a real UpstreamPipe in pipeline unit tests; the rewriter
// never dereferences the party list, so an empty placeholder is safe.
InterestedParties: Array.Empty<InterestedParty>(),
SentAtUtc: DateTimeOffset.UtcNow);
@@ -107,9 +105,9 @@ public sealed class BcdPduPipelineTests
}
/// <summary>
/// Simulate sending an FC03/04 request then reading the response.
/// Phase 9: builds an <see cref="InFlightRequest"/> matching the request and attaches
/// it to the response-call context (replacing the per-pair last-request slot).
/// Simulate sending an FC03/04 request then reading the response. Builds an
/// <see cref="InFlightRequest"/> matching the request and attaches it to the
/// response-call context.
/// </summary>
private void SendRequestThenProcessResponse(
PerPlcContext ctx,
@@ -361,11 +359,11 @@ public sealed class BcdPduPipelineTests
}
/// <summary>
/// Phase 12 (W2.13) — a client writing a 32-bit BCD value where either word exceeds
/// 9999 must NOT be silently mutated by the `high*10000+low` reconstruction. Validation
/// rejects the slot, increments invalidBcdWarnings, and passes the raw bytes through.
/// Without W2.13 the codec would accept e.g. (high=9999, low=9999) → 99_989_999 →
/// re-encode as (high=9998, low=9999), silently losing 1 from the high word.
/// A client writing a 32-bit BCD value where either word exceeds 9999 must NOT be
/// silently mutated by the `high*10000+low` reconstruction. Validation rejects the
/// slot, increments invalidBcdWarnings, and passes the raw bytes through. (Otherwise
/// the codec would accept e.g. (high=9999, low=9999) → 99_989_999 → re-encode as
/// (high=9998, low=9999), silently losing 1 from the high word.)
/// </summary>
[Fact]
public void FC16_32Bit_ClientHighOrLowAbove9999_PassesThroughRaw_WithInvalidBcdWarning()
@@ -383,10 +381,10 @@ public sealed class BcdPduPipelineTests
}
/// <summary>
/// Phase 12 (W2.14) — a malformed FC16 request that claims qty=N but ships fewer than
/// 6+N*2 bytes must NOT be partially rewritten. Without W2.14 each individual slot's
/// per-slot bounds check would skip the OOB slot, leaving early slots rewritten and late
/// slots untouched (a half-rewritten request reaching the PLC).
/// A malformed FC16 request that claims qty=N but ships fewer than 6+N*2 bytes must
/// NOT be partially rewritten. Without the up-front length check, each individual
/// slot's per-slot bounds check would skip the OOB slot, leaving early slots rewritten
/// and late slots untouched (a half-rewritten request reaching the PLC).
/// </summary>
[Fact]
public void FC16_TruncatedRegisterData_PassesThroughRaw_NoPartialRewrite()
@@ -405,12 +403,11 @@ public sealed class BcdPduPipelineTests
}
/// <summary>
/// Phase 12 (W3 test gap #15) — DL205/DL260 caps FC03/FC04 reads at qty=128 (above
/// Modbus spec's 125; documented in DL260/dl205.md). The proxy must NOT truncate the
/// qty field — a request with qty &gt; 128 at non-BCD addresses must pass through
/// unchanged so the PLC's own validator returns exception 03 to the client. This is
/// the transparent-pass-through contract for FCs and addresses the rewriter doesn't
/// own.
/// DL205/DL260 caps FC03/FC04 reads at qty=128 (above Modbus spec's 125; documented
/// in DL260/dl205.md). The proxy must NOT truncate the qty field — a request with
/// qty &gt; 128 at non-BCD addresses must pass through unchanged so the PLC's own
/// validator returns exception 03 to the client. This is the transparent-pass-through
/// contract for FCs and addresses the rewriter doesn't own.
/// </summary>
[Fact]
public void FC03_Request_QtyAbove128_AtNonBcdAddress_PassesThroughUnchanged()
@@ -426,10 +423,9 @@ public sealed class BcdPduPipelineTests
}
/// <summary>
/// Phase 12 (W3 test gap) — symmetric inverse of the existing partial-overlap test:
/// the write range starts ON the high register of a 32-bit pair (low word is BEFORE
/// the write range). Must also be passed through raw with a partial warning, not
/// half-rewritten.
/// Symmetric inverse of the existing partial-overlap test: the write range starts ON
/// the high register of a 32-bit pair (low word is BEFORE the write range). Must also
/// be passed through raw with a partial warning, not half-rewritten.
/// </summary>
[Fact]
public void FC16_WriteStartsOnHighWord_Of32BitPair_PassesThroughRaw_WithPartialWarning()
@@ -448,10 +444,9 @@ public sealed class BcdPduPipelineTests
}
/// <summary>
/// Phase 12 (W3 test gap) — mixed slots in a single FC03 read: a 16-bit BCD tag, a
/// 32-bit BCD pair, and an unconfigured register. Each slot should be handled
/// independently — the 16-bit and 32-bit rewritten, the unconfigured register passed
/// through unchanged.
/// Mixed slots in a single FC03 read: a 16-bit BCD tag, a 32-bit BCD pair, and an
/// unconfigured register. Each slot should be handled independently — the 16-bit and
/// 32-bit rewritten, the unconfigured register passed through unchanged.
/// </summary>
[Fact]
public void FC03_Mixed_16Bit_32Bit_AndNonBcd_InOneRead_OnlyConfiguredSlotsRewritten()
@@ -488,9 +483,9 @@ public sealed class BcdPduPipelineTests
}
/// <summary>
/// Phase 12 (W3 test gap) — FC16 response handling. The response carries no register
/// values (just an echo of [fc][start][qty]) so the rewriter must pass it through
/// unchanged regardless of tag-map content.
/// FC16 response handling. The response carries no register values (just an echo of
/// [fc][start][qty]) so the rewriter must pass it through unchanged regardless of
/// tag-map content.
/// </summary>
[Fact]
public void FC16_Response_PassesThroughUnchanged_RegardlessOfTagMap()
@@ -15,18 +15,16 @@ using Xunit;
namespace Mbproxy.Tests.Proxy.Cache;
/// <summary>
/// End-to-end coverage of the Phase-11 response cache against the pymodbus DL205
/// simulator.
/// End-to-end coverage of the response cache against the pymodbus DL205 simulator.
///
/// <para><b>pymodbus 3.13 simulator quirk.</b> Like Phase 9 and Phase 10, these tests
/// serialise reads in the simulator-backed cases. The Phase-11 cache's behavioural
/// guarantee (a TTL-bounded cache hit returns the cached value without backend traffic)
/// is independent of the simulator's known concurrent-MBAP-frame bug — sequential reads
/// keep the sim in single-PDU mode, which is its known-good envelope.</para>
/// <para><b>pymodbus 3.13 simulator quirk.</b> These tests serialise reads in the
/// simulator-backed cases. The cache's behavioural guarantee (a TTL-bounded cache hit
/// returns the cached value without backend traffic) is independent of the simulator's
/// known concurrent-MBAP-frame bug — sequential reads keep the sim in single-PDU mode,
/// which is its known-good envelope.</para>
///
/// <para>The headline assertion lives here: 10 reads at 100 ms intervals with a 1 s TTL
/// must result in EXACTLY 1 backend round-trip. If this test fails, Phase 11 does not
/// ship — see <c>11-response-cache.md</c>.</para>
/// must result in EXACTLY 1 backend round-trip.</para>
/// </summary>
[Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))]
[Trait("Category", "E2E")]
@@ -153,8 +151,8 @@ public sealed class ResponseCacheE2ETests
/// <summary>
/// Mandatory regression. With no cache config anywhere (default deployment shape),
/// behaviour must be byte-identical to Phase 10. Sequential reads through the same
/// client produce one backend round-trip each — no elision.
/// behaviour must be byte-identical to the non-cached path: sequential reads through
/// the same client produce one backend round-trip each — no elision.
/// </summary>
[Fact(Timeout = 5_000)]
public async Task Cache_DisabledByDefault_BehaviourIs_ByteIdenticalTo_Phase10()
@@ -165,7 +163,7 @@ public sealed class ResponseCacheE2ETests
int adminPort = PickFreePort();
var config = MakeBaseConfig(proxyPort);
config["Mbproxy:AdminPort"] = adminPort.ToString();
// No Cache section, no CacheTtlMs on any tag — pure Phase-10 behaviour.
// No Cache section, no CacheTtlMs on any tag — non-cached behaviour.
config["Mbproxy:BcdTags:Global:0:Address"] = "1072";
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
@@ -465,7 +465,8 @@ public sealed class ResponseCacheMultiplexerTests
public async Task UncachedReads_BehaveIdentically_ToPhase10()
{
// Regression guard: PerPlcContext with Cache = null must behave byte-identically
// to Phase 10 — every FC03 read produces a backend round-trip (coalescing aside).
// to the non-cached path — every FC03 read produces a backend round-trip
// (coalescing aside).
int backendPort = PickFreePort();
await using var backend = new StubBackend(backendPort);
@@ -49,12 +49,12 @@ public sealed class MultiplexerE2ETests
// ── E2E 1: Five simultaneous upstream clients (connection-cap lift) ──────────────
/// <summary>
/// Headline test for Phase 9: prove that the multiplexer accepts the 5th upstream
/// client on the same proxy port — pre-Phase-9's 1:1 model would have failed at
/// backend connect (H2-ECOM100 cap = 4). Each client's request is serialised behind
/// the previous client's response so the pymodbus 3.13 simulator's concurrent-frame
/// bug never triggers; the multiplexer's connection ceiling, not its under-concurrency
/// behaviour, is what this test proves.
/// Headline test: prove that the multiplexer accepts the 5th upstream client on the
/// same proxy port — a 1:1 model would have failed at backend connect (H2-ECOM100
/// cap = 4). Each client's request is serialised behind the previous client's response
/// so the pymodbus 3.13 simulator's concurrent-frame bug never triggers; the
/// multiplexer's connection ceiling, not its under-concurrency behaviour, is what
/// this test proves.
/// </summary>
[Fact(Timeout = 5_000)]
public async Task E2E_FiveSimultaneousClients_AllReadHR1072_AllGetDecoded_1234()
@@ -82,8 +82,9 @@ public sealed class MultiplexerE2ETests
await using var hd = new AsyncHostDispose(host);
await Task.Delay(200, TestContext.Current.CancellationToken);
// Open five simultaneous TCP connections to the proxy first (each would have used
// a dedicated backend socket pre-Phase-9, blowing through the 4-client cap).
// Open five simultaneous TCP connections to the proxy first (under a 1:1 model
// each would have needed a dedicated backend socket, blowing through the
// 4-client cap).
var clients = new TcpClient[5];
try
{
@@ -380,8 +380,8 @@ public sealed class PlcMultiplexerTests
try
{
// Both clients use the same upstream TxId 0x0007 — the proxy must hand out
// distinct proxy TxIds on the backend wire. Phase 10: reads target DIFFERENT
// addresses so coalescing does not fuse them into a single backend request.
// distinct proxy TxIds on the backend wire. Reads target DIFFERENT addresses
// so coalescing does not fuse them into a single backend request.
await c1.SendAsync(BuildFc03ReadFrame(0x0007, 0, 1), SocketFlags.None);
await c2.SendAsync(BuildFc03ReadFrame(0x0007, 10, 1), SocketFlags.None);
@@ -625,12 +625,12 @@ public sealed class PlcMultiplexerTests
}
}
// ── Phase 12 Wave-1 regression tests ──────────────────────────────────────
// ── ReplaceContext live-swap regression tests ────────────────────────────────
/// <summary>
/// W1.1 — verifies that <see cref="PlcMultiplexer.ReplaceContext"/> swaps the live
/// per-PLC context on the running multiplexer, so the very next PDU's BCD rewriter
/// uses the new tag map (not the captured-at-construction map). Before W1.1 this
/// Verifies that <see cref="PlcMultiplexer.ReplaceContext"/> swaps the live per-PLC
/// context on the running multiplexer, so the very next PDU's BCD rewriter uses the
/// new tag map (not the captured-at-construction map). Without the live swap this
/// scenario would silently keep using the old map until the listener faulted and the
/// supervisor's Polly loop reconstructed everything.
/// </summary>
@@ -682,11 +682,11 @@ public sealed class PlcMultiplexerTests
}
/// <summary>
/// W1.1 — verifies that swapping in a fresh response cache via <see cref="PlcMultiplexer.ReplaceContext"/>
/// makes the running multiplexer consult the NEW cache for subsequent reads, not the
/// old cache that was disposed by the supervisor. Without W1.1 the running mux would
/// keep its constructor-captured cache reference and either return stale entries or
/// hit a disposed cache.
/// Verifies that swapping in a fresh response cache via
/// <see cref="PlcMultiplexer.ReplaceContext"/> makes the running multiplexer consult
/// the NEW cache for subsequent reads, not the old cache that was disposed by the
/// supervisor. Without the live swap the running mux would keep its constructor-
/// captured cache reference and either return stale entries or hit a disposed cache.
/// </summary>
[Fact]
public async Task ReplaceContext_NewCache_NextReadGoesToBackend_NotOldCache()
@@ -757,7 +757,7 @@ public sealed class PlcMultiplexerTests
}
}
// ── Phase 12 (W3 final-tier race tests) ──────────────────────────────────
// ── Final-tier race tests ─────────────────────────────────────────────────
/// <summary>
/// Reflection helper — drains the multiplexer's TxIdAllocator by calling
@@ -781,10 +781,10 @@ public sealed class PlcMultiplexerTests
}
/// <summary>
/// W3 #5 — TxId allocator saturation propagates as a Modbus exception 04 to the
/// upstream client (no hang, no crash). The 16-bit TxId space (65,536 slots) is
/// pre-saturated via reflection so the next request hits the
/// <c>!_allocator.TryAllocate</c> branch in <c>OnUpstreamFrameAsync</c> immediately.
/// TxId allocator saturation propagates as a Modbus exception 04 to the upstream
/// client (no hang, no crash). The 16-bit TxId space (65,536 slots) is pre-saturated
/// via reflection so the next request hits the <c>!_allocator.TryAllocate</c> branch
/// in <c>OnUpstreamFrameAsync</c> immediately.
/// </summary>
[Fact]
public async Task TxIdAllocator_Saturated_NextRequest_GetsException04_WithOriginalTxId()
@@ -835,11 +835,11 @@ public sealed class PlcMultiplexerTests
}
/// <summary>
/// W3 #6 — under TxId saturation, two concurrent identical FC03 reads must BOTH
/// receive exception 04 (one as the leader directly, the other either via a
/// coalesced fan-out from the W1.2 cleanup OR via its own independent saturation
/// path — either timing produces the same observable contract). Validates that no
/// pipe hangs forever waiting for a backend response that would never arrive.
/// Under TxId saturation, two concurrent identical FC03 reads must BOTH receive
/// exception 04 (one as the leader directly, the other either via a coalesced
/// fan-out from the saturation cleanup OR via its own independent saturation path —
/// either timing produces the same observable contract). Validates that no pipe
/// hangs forever waiting for a backend response that would never arrive.
/// </summary>
[Fact]
public async Task TxIdAllocator_Saturated_TwoConcurrentIdenticalReads_BothPipesGetException04()
@@ -877,7 +877,7 @@ public sealed class PlcMultiplexerTests
var rspA = await ReadOneFrameAsync(cA, TestContext.Current.CancellationToken);
var rspB = await ReadOneFrameAsync(cB, TestContext.Current.CancellationToken);
// Both must be exception 04 with the original TxId echoed — the W1.2 contract
// Both must be exception 04 with the original TxId echoed — the contract
// is "no late attacher hangs."
foreach (var (rsp, expectedTxId, label) in new[] {
(rspA, txA, "A"), (rspB, txB, "B") })
@@ -898,14 +898,14 @@ public sealed class PlcMultiplexerTests
}
/// <summary>
/// W3 #7 — backend-reader head-of-line block. One upstream pipe is wedged by the
/// test holding its socket-receive side without reading. The W1.3 fix routes the
/// fan-out through <c>TrySendResponse</c> so the per-PLC backend reader cannot be
/// stalled by a wedged pipe; responses to a healthy peer must keep flowing and the
/// wedged pipe's <c>responseDropForFullUpstream</c> counter must increment.
/// Backend-reader head-of-line block guard. One upstream pipe is wedged by the test
/// holding its socket-receive side without reading. The fan-out is routed through
/// <c>TrySendResponse</c> so the per-PLC backend reader cannot be stalled by a
/// wedged pipe; responses to a healthy peer must keep flowing and the wedged pipe's
/// <c>responseDropForFullUpstream</c> counter must increment.
///
/// <para>Pre-W1.3 the synchronous <c>await SendResponseAsync</c> inside the reader
/// would block on the wedged pipe's full bounded channel and starve every peer.</para>
/// <para>A synchronous <c>await SendResponseAsync</c> inside the reader would block
/// on the wedged pipe's full bounded channel and starve every peer.</para>
/// </summary>
[Fact]
public async Task SlowUpstream_DoesNotStallPeerResponses_DropCounterIncrements()
@@ -943,8 +943,8 @@ public sealed class PlcMultiplexerTests
await cB.SendAsync(BuildFc03ReadFrame(txB, 0, 1), SocketFlags.None);
// B's response must arrive within a few hundred ms even with A wedged. If
// the W1.3 fix were missing, the reader would be blocked on A's channel and
// B would time out.
// the non-blocking enqueue path were missing, the reader would be blocked on
// A's channel and B would time out.
var rspB = await ReadOneFrameAsync(cB, TestContext.Current.CancellationToken)
.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
ushort echoB = (ushort)((rspB[0] << 8) | rspB[1]);
@@ -972,7 +972,7 @@ public sealed class PlcMultiplexerTests
}
/// <summary>
/// W3 #8 — watchdog↔response race. The W1 design uses claim-then-dispatch:
/// Watchdog↔response race. The design uses claim-then-dispatch:
/// <c>CorrelationMap.TryRemove</c> is the single source of truth, so exactly ONE
/// of (response delivered, watchdog timeout) wins for any given proxy TxId. This
/// test exercises the race window directly: a stub backend that responds at almost
@@ -992,13 +992,13 @@ public sealed class PlcMultiplexerTests
// = 400 the tick is 100 ms. Configure the backend to delay 350-450 ms for each
// request so some land before, some after the timeout.
int backendPort = PickFreePort();
// Phase 12 (W4 / T2) — deterministic alternation rather than seeded Random. Random
// with a fixed seed is not stable across .NET major versions (Microsoft has changed
// the implementation, e.g. legacy → Xoshiro128 in .NET 6), so a runtime upgrade
// could land all samples on one side of the watchdog deadline and break the
// "both branches must fire" assertion below. Counter-based alternation guarantees
// 15 fast (350 ms, beats watchdog) and 15 slow (450 ms, loses to watchdog) responses
// across 30 iterations, regardless of runtime.
// Deterministic alternation rather than seeded Random. Random with a fixed seed is
// not stable across .NET major versions (Microsoft has changed the implementation,
// e.g. legacy → Xoshiro128 in .NET 6), so a runtime upgrade could land all samples
// on one side of the watchdog deadline and break the "both branches must fire"
// assertion below. Counter-based alternation guarantees 15 fast (350 ms, beats
// watchdog) and 15 slow (450 ms, loses to watchdog) responses across 30 iterations,
// regardless of runtime.
int reqCount = 0;
var slowBackend = new SlowResponseBackend(backendPort, () =>
{
@@ -1062,13 +1062,13 @@ public sealed class PlcMultiplexerTests
}
/// <summary>
/// W3 #9 — cascade racing with new accepts. Stress-test: while the backend is repeatedly
/// Cascade racing with new accepts. Stress-test: while the backend is repeatedly
/// killed and resurrected (forcing repeated cascade cycles), new upstream clients
/// connect and disconnect concurrently. The contract verified is the
/// no-crash-under-churn property: the multiplexer must survive arbitrary interleavings
/// of teardown and new-pipe-attach without throwing into the host or leaking sockets.
///
/// <para>The originally-flagged race window — a new pipe added between
/// <para>The race window — a new pipe added between
/// <c>_pipes.Values.ToArray()</c> and <c>_pipes.Clear()</c> in <c>TearDownBackendAsync</c>
/// — leaves the new pipe alive but orphaned from <c>_pipes</c>. Its read loop will
/// receive normal traffic until the next cascade or its socket closes. This test
@@ -1165,7 +1165,7 @@ public sealed class PlcMultiplexerTests
/// <summary>
/// Backend stub that delays each response by a caller-supplied amount. Used by the
/// W3 #8 watchdog race test.
/// watchdog-vs-response race test.
/// </summary>
private sealed class SlowResponseBackend : IAsyncDisposable
{
@@ -10,9 +10,9 @@ namespace Mbproxy.Tests.Proxy.Multiplexing;
/// <summary>
/// Verifies that <see cref="BcdPduPipeline"/> correlates FC03/FC04 responses through
/// <see cref="PerPlcContext.CurrentRequest"/> (Phase 9) rather than the pre-Phase-9
/// per-pair last-request slot. Concurrent in-flight requests from different upstream
/// clients must decode against their own request range without cross-talk.
/// <see cref="PerPlcContext.CurrentRequest"/>. Concurrent in-flight requests from
/// different upstream clients must decode against their own request range without
/// cross-talk.
/// </summary>
[Trait("Category", "Unit")]
public sealed class RewriterCorrelationTests
@@ -9,8 +9,8 @@ namespace Mbproxy.Tests.Proxy.Multiplexing;
/// <summary>
/// Unit tests for <see cref="UpstreamPipe"/>'s response-channel contract — particularly
/// the Phase 12 (W1.3) <see cref="UpstreamPipe.TrySendResponse"/> non-blocking enqueue
/// added so the per-PLC backend reader cannot be stalled by one slow upstream client.
/// the <see cref="UpstreamPipe.TrySendResponse"/> non-blocking enqueue, which exists
/// so the per-PLC backend reader cannot be stalled by one slow upstream client.
/// </summary>
[Trait("Category", "Unit")]
public sealed class UpstreamPipeTests
@@ -40,7 +40,7 @@ public sealed class UpstreamPipeTests
// ── Tests ─────────────────────────────────────────────────────────────────
/// <summary>
/// W1.3 — when no write-loop is draining the response channel, repeated
/// When no write-loop is draining the response channel, repeated
/// <see cref="UpstreamPipe.TrySendResponse"/> calls must succeed up to the channel's
/// bounded capacity and return <c>false</c> on every subsequent call without blocking.
/// This is the non-blocking contract the per-PLC backend reader relies on.
@@ -80,7 +80,7 @@ public sealed class UpstreamPipeTests
}
/// <summary>
/// W1.3 — once the pipe has been disposed, <see cref="UpstreamPipe.TrySendResponse"/>
/// Once the pipe has been disposed, <see cref="UpstreamPipe.TrySendResponse"/>
/// returns <c>false</c> regardless of channel state, never throws.
/// </summary>
[Fact]
@@ -51,13 +51,12 @@ public sealed class ProxyForwardingTests
}
// ── 2a. FC03 read HR1072 — with BCD configured → decoded 1234 ──────────────────────
// Replaced Phase 03 placeholder: Forward_FC03_HR1072_Returns_RawBCD_0x1234
[Fact(Timeout = 5_000)]
public async Task Forward_FC03_HR1072_Returns_Decoded_1234()
{
// Phase 04: BcdPduPipeline is active. When BCD tag 1072 (width=16) is configured,
// the proxy decodes the raw 0x1234 nibbles and the client receives binary 1234.
// BcdPduPipeline is active. When BCD tag 1072 (width=16) is configured, the proxy
// decodes the raw 0x1234 nibbles and the client receives binary 1234.
if (_sim.SkipReason is not null)
Assert.Skip(_sim.SkipReason);
@@ -230,9 +229,9 @@ public sealed class ProxyForwardingTests
public async Task BackendConnectFailure_ClosesUpstreamCleanly()
{
// Point the proxy at port 1 on loopback — guaranteed unreachable.
// After Phase 9 the multiplexer lazily connects to the backend on the first
// upstream PDU, so we have to actually send a request before the proxy attempts
// the (failing) backend connect that closes the upstream.
// The multiplexer lazily connects to the backend on the first upstream PDU, so
// we have to actually send a request before the proxy attempts the (failing)
// backend connect that closes the upstream.
const int badBackendPort = 1;
const int backendTimeoutMs = 500; // short timeout for test speed
@@ -352,7 +351,7 @@ public sealed class ProxyForwardingTests
new Serilog.LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(),
dispose: false);
builder.AddMbproxyOptions();
// BCD rewriter pipeline — used by the Phase 04 tests in this file.
// BCD rewriter pipeline — used by the BCD-decode tests in this file.
builder.Services.AddSingleton<IPduPipeline, BcdPduPipeline>();
builder.Services.AddHostedService<ProxyWorker>();
return builder.Build();
@@ -12,11 +12,10 @@ using Xunit;
namespace Mbproxy.Tests.Proxy.Supervision;
/// <summary>
/// Integration tests for the backend-connect Polly retry path. Phase 9 moved backend
/// connect ownership from <c>PlcConnectionPair.CreateAsync</c> into
/// <see cref="PlcMultiplexer"/>. These tests exercise the same Polly pipeline by driving
/// upstream-to-multiplexer frames against a bad/intermittent backend and observing the
/// resulting connect-success/connect-failed counters.
/// Integration tests for the backend-connect Polly retry path. Backend connect
/// ownership lives in <see cref="PlcMultiplexer"/>. These tests exercise the Polly
/// pipeline by driving upstream-to-multiplexer frames against a bad/intermittent
/// backend and observing the resulting connect-success/connect-failed counters.
/// </summary>
[Trait("Category", "Unit")]
public sealed class BackendConnectRetryTests
@@ -176,13 +176,13 @@ public sealed class SupervisorTests
// ── Test 4: runtime fault triggers recovery ──────────────────────────────────────────
/// <summary>
/// Phase 12 (W3 test gap #4) — replaces the previous placeholder. Genuinely faults
/// the running listener mid-life by stopping its underlying <see cref="TcpListener"/>
/// via reflection (the only externally-observable hook to force the accept loop's
/// <see cref="Socket.AcceptAsync"/> to throw <see cref="ObjectDisposedException"/>).
/// The supervisor must observe the fault, transition to <see cref="SupervisorState.Recovering"/>,
/// and re-bind on the next Polly attempt — emitting one
/// <c>mbproxy.listener.recovered</c> event and bumping <c>RecoveryAttempts</c>.
/// Genuinely faults the running listener mid-life by stopping its underlying
/// <see cref="TcpListener"/> via reflection (the only externally-observable hook
/// to force the accept loop's <see cref="Socket.AcceptAsync"/> to throw
/// <see cref="ObjectDisposedException"/>). The supervisor must observe the fault,
/// transition to <see cref="SupervisorState.Recovering"/>, and re-bind on the next
/// Polly attempt — emitting one <c>mbproxy.listener.recovered</c> event and bumping
/// <c>RecoveryAttempts</c>.
/// </summary>
[Fact]
public async Task Supervisor_RuntimeFault_OnRunningListener_RecoversAndRebinds()
@@ -67,8 +67,8 @@ public sealed class SimulatorSmokeTests
/// <summary>
/// Reads holding register 1072 via FC03 and expects raw BCD value
/// <c>0x1234</c> (4660 decimal). This register represents decimal 1234 stored as
/// BCD nibbles. Phase 04's e2e test will read the same register through the proxy
/// and assert binary 1234 — proving the proxy rewrote the response.
/// BCD nibbles. The end-to-end rewriter test reads the same register through the
/// proxy and asserts binary 1234 — proving the proxy rewrote the response.
/// </summary>
[Fact(Timeout = 5_000)]
public async Task Simulator_FC03_ReturnsBCD_RawValueAtHR1072_0x1234()