|
|
|
@@ -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
|
|
|
|
|
{
|
|
|
|
|