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