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:
@@ -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 > 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 > 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()
|
||||
|
||||
Reference in New Issue
Block a user