ce32c5cee8
Resolves the four critical correctness defects + the ShutdownCoordinator double-stop ordering bug called out in codereviews/2026-05-14/Overview.md. Tests: 362 pass / 0 fail (baseline 358 + 4 new W1 regression tests). W1.1 — Context swap on running multiplexer. PlcMultiplexer._ctx becomes volatile with a new ReplaceContext() method that re-registers the cache stats provider on the (preserved) counters. PlcListener exposes its multiplexer; PlcListenerSupervisor.ReplaceContextAsync swaps the running mux first, then disposes the old cache. Hot-reload tag-list changes and the cache-flush-on-reload contract now actually take effect on the next PDU instead of waiting for the next listener fault. W1.2 — Coalescing factory leak. When the InFlightByKey factory soft-fails (allocator saturation or duplicate TxId), the cleanup path now TryRemoves the stub and walks every party on it (including late attachers) to deliver Modbus exception 0x04. Previously only the leader got the exception; late attachers waited forever for a response that no backend round-trip would ever fire. W1.3 — Backend-reader head-of-line block. UpstreamPipe gains TrySendResponse for non-blocking enqueue. The per-PLC backend reader's fan-out loop uses it instead of awaiting SendResponseAsync, so a wedged upstream's full bounded response channel can no longer stall the single backend reader and starve every other client on that PLC. New responseDropForFullUpstream counter on ProxyCounters / CounterSnapshot records the drops. W1.4 — Stranded outbound frames after cascade. TearDownBackendAsync acquires _connectGate and drains any frames left in _outboundChannel after the writer task faulted/cancelled, releasing their proxy TxIds back to the allocator. Without this, a fresh EnsureBackendConnectedAsync racing the cascade would send stranded frames with old TxIds onto the new backend socket; the responses would arrive with no correlation entry and the upstream peers would hang on the watchdog until BackendRequestTimeoutMs. W1.5 — Delete ShutdownCoordinator (Option B). Drain logic moved into ProxyWorker.StopAsync. AdminEndpointHost is no longer registered as IHostedService; ProxyWorker drives its lifecycle directly so admin starts after listeners are bound and stops AFTER the in-flight drain (the design's documented contract). Admin is resolved lazily in ExecuteAsync to break the circular DI graph (Admin -> StatusSnapshotBuilder -> ProxyWorker). GracefulShutdownTimeoutMs is now read fresh from IOptionsMonitor.CurrentValue at stop time, so a hot-reloaded value is honoured. Removes ShutdownCoordinator + tests. New tests: PlcMultiplexerTests.ReplaceContext_NewTagMap_VisibleOnNextPdu PlcMultiplexerTests.ReplaceContext_NewCache_NextReadGoesToBackend_NotOldCache UpstreamPipeTests.TrySendResponse_WhenChannelFull_ReturnsFalse_WithoutBlocking UpstreamPipeTests.TrySendResponse_AfterDispose_ReturnsFalse Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
105 lines
4.0 KiB
C#
105 lines
4.0 KiB
C#
using System.Net;
|
|
using System.Net.Sockets;
|
|
using Mbproxy.Proxy.Multiplexing;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
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.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class UpstreamPipeTests
|
|
{
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
private static async Task<(Socket clientSide, Socket serverSide)> AcceptedSocketPairAsync()
|
|
{
|
|
// Build a loopback listener and connect a client to get a real socket pair.
|
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
try
|
|
{
|
|
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
var clientSide = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
var connectTask = clientSide.ConnectAsync(IPAddress.Loopback, port);
|
|
var serverSide = await listener.AcceptSocketAsync();
|
|
await connectTask;
|
|
return (clientSide, serverSide);
|
|
}
|
|
finally
|
|
{
|
|
listener.Stop();
|
|
}
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// W1.3 — 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.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task TrySendResponse_WhenChannelFull_ReturnsFalse_WithoutBlocking()
|
|
{
|
|
var (client, server) = await AcceptedSocketPairAsync();
|
|
try
|
|
{
|
|
// Construct the pipe but do NOT call RunWriteLoopAsync — the channel will not
|
|
// be drained, so it fills after `ResponseChannelCapacity` (= 16) writes.
|
|
var pipe = new UpstreamPipe(server, "TEST", NullLogger.Instance);
|
|
|
|
int successes = 0;
|
|
int failures = 0;
|
|
|
|
for (int i = 0; i < 100; i++)
|
|
{
|
|
bool ok = pipe.TrySendResponse(new byte[] { 0, 0 });
|
|
if (ok) successes++;
|
|
else failures++;
|
|
}
|
|
|
|
successes.ShouldBe(16,
|
|
"the channel's bounded capacity is 16; first 16 writes must succeed");
|
|
failures.ShouldBe(84,
|
|
"after capacity is reached, every further TrySendResponse must return false (not block)");
|
|
|
|
await pipe.DisposeAsync();
|
|
}
|
|
finally
|
|
{
|
|
try { client.Dispose(); } catch { }
|
|
try { server.Dispose(); } catch { }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// W1.3 — once the pipe has been disposed, <see cref="UpstreamPipe.TrySendResponse"/>
|
|
/// returns <c>false</c> regardless of channel state, never throws.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task TrySendResponse_AfterDispose_ReturnsFalse()
|
|
{
|
|
var (client, server) = await AcceptedSocketPairAsync();
|
|
try
|
|
{
|
|
var pipe = new UpstreamPipe(server, "TEST", NullLogger.Instance);
|
|
await pipe.DisposeAsync();
|
|
|
|
bool ok = pipe.TrySendResponse(new byte[] { 0, 0 });
|
|
ok.ShouldBeFalse("a disposed pipe must reject sends without throwing");
|
|
}
|
|
finally
|
|
{
|
|
try { client.Dispose(); } catch { }
|
|
try { server.Dispose(); } catch { }
|
|
}
|
|
}
|
|
}
|