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; /// /// Unit tests for 's response-channel contract — particularly /// the non-blocking enqueue, which exists /// so the per-PLC backend reader cannot be stalled by one slow upstream client. /// [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 ───────────────────────────────────────────────────────────────── /// /// When no write-loop is draining the response channel, repeated /// calls must succeed up to the channel's /// bounded capacity and return false on every subsequent call without blocking. /// This is the non-blocking contract the per-PLC backend reader relies on. /// [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 { } } } /// /// Once the pipe has been disposed, /// returns false regardless of channel state, never throws. /// [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 { } } } }