using System.Buffers.Binary; using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; using System.Threading.Channels; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip; namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; /// /// Scriptable loopback EtherNet/IP endpoint. RegisterSession is answered automatically (session /// ) and UnRegisterSession is recorded but never answered. Every other frame is /// answered by the next queued step, else by ; a /// non-null result is written back verbatim (it may hold several concatenated frames). /// /// /// Each connection runs a reader loop and a responder loop, so frames are recorded the moment they /// arrive — even while a reply is still pending. therefore really does /// see a client that pipelines a second request before the first reply. /// internal sealed class FakeEipServer : IAsyncDisposable { public const uint Session = 0x11223344; private readonly TcpListener _listener = new(IPAddress.Loopback, 0); private readonly CancellationTokenSource _cts = new(); private readonly ConcurrentQueue>> _script = new(); private readonly List _received = []; private readonly List<(Predicate Match, TaskCompletionSource Tcs)> _waiters = []; private readonly ConcurrentBag _clients = []; private readonly ConcurrentBag _handlerErrors = []; private int _connections; private int _open; private int _outstanding; private int _maxOutstanding; public FakeEipServer() { _listener.Start(); _ = AcceptLoop(); } public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; /// Every frame received, in arrival order (snapshot). public IReadOnlyList Received { get { lock (_received) return [.. _received]; } } public int Connections => Volatile.Read(ref _connections); /// TCP connections accepted and not yet closed by the client (Gitea #579 leak checks). public int OpenConnections => Volatile.Read(ref _open); /// Largest number of non-session requests ever received but not yet answered. public int MaxOutstanding => Volatile.Read(ref _maxOutstanding); /// Exceptions thrown by handlers / script steps (a test assertion inside a handler lands here). public IReadOnlyCollection HandlerErrors => _handlerErrors; /// Default responder, used once the script is exhausted. public Func> HandlerAsync { get; set; } = _ => Task.FromResult(null); /// Synchronous convenience setter for . public Func Handler { set => HandlerAsync = req => Task.FromResult(value(req)); } /// Awaited before a RegisterSession is answered (the request is already recorded). public Func? RegisterSessionGate { get; set; } /// Never answer non-session requests. public bool Stall { get; set; } /// Zero the sender context (header bytes 12-19) of every reply frame, as ab_server does. public bool ZeroReplyContext { get; set; } /// Close the connection instead of answering the next non-session request. public bool DropOnNextRequest { get; set; } /// Queue one-shot responders, consumed one per non-session request. public void Script(params Func[] steps) { foreach (var s in steps) _script.Enqueue(req => Task.FromResult(s(req))); } /// Queue one-shot asynchronous responders (e.g. one that waits for the test to release it). public void ScriptAsync(params Func>[] steps) { foreach (var s in steps) _script.Enqueue(s); } /// Completes with the first received frame (past or future) matching . public Task WaitForFrameAsync(Predicate match, TimeSpan timeout) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); lock (_received) { var hit = _received.FirstOrDefault(f => match(f)); if (hit is not null) return Task.FromResult(hit); _waiters.Add((match, tcs)); } return tcs.Task.WaitAsync(timeout); } /// Completes once frames with encapsulation command have arrived. public async Task WaitForCommandCountAsync(ushort command, int count, TimeSpan timeout) { var seen = 0; await WaitForFrameAsync(f => Command(f) == command && ++seen >= count, timeout); } public static ushort Command(byte[] frame) => BinaryPrimitives.ReadUInt16LittleEndian(frame); private async Task AcceptLoop() { while (!_cts.IsCancellationRequested) { TcpClient c; try { c = await _listener.AcceptTcpClientAsync(_cts.Token); } catch { return; } Interlocked.Increment(ref _connections); Interlocked.Increment(ref _open); _clients.Add(c); _ = Serve(c); } } private async Task Serve(TcpClient c) { try { await ServeCore(c); } finally { Interlocked.Decrement(ref _open); } } private async Task ServeCore(TcpClient c) { using var _ = c; var s = c.GetStream(); var inbox = Channel.CreateUnbounded(); var responder = Respond(c, s, inbox.Reader); var head = new byte[EncapsulationHeader.Size]; while (!_cts.IsCancellationRequested) { if (!await ReadExact(s, head)) break; var len = head[2] | (head[3] << 8); var frame = new byte[EncapsulationHeader.Size + len]; head.CopyTo(frame, 0); if (!await ReadExact(s, frame.AsMemory(EncapsulationHeader.Size))) break; if (Command(frame) is not (EncapCommand.RegisterSession or EncapCommand.UnRegisterSession)) { var now = Interlocked.Increment(ref _outstanding); int max; while (now > (max = Volatile.Read(ref _maxOutstanding)) && Interlocked.CompareExchange(ref _maxOutstanding, now, max) != max) { } } Record(frame); inbox.Writer.TryWrite(frame); } inbox.Writer.TryComplete(); try { await responder; } catch { /* connection gone */ } } private async Task Respond(TcpClient c, NetworkStream s, ChannelReader inbox) { await foreach (var frame in inbox.ReadAllAsync(_cts.Token)) { byte[]? reply; switch (Command(frame)) { case EncapCommand.RegisterSession: if (RegisterSessionGate is { } gate) await gate(); reply = (byte[])frame.Clone(); BinaryPrimitives.WriteUInt32LittleEndian(reply.AsSpan(4), Session); break; case EncapCommand.UnRegisterSession: reply = null; break; default: if (DropOnNextRequest) { DropOnNextRequest = false; c.Close(); return; } if (Stall) continue; // stays outstanding forever var step = _script.TryDequeue(out var scripted) ? scripted : HandlerAsync; try { reply = await step(frame); } catch (Exception ex) { _handlerErrors.Add(ex); reply = null; } Interlocked.Decrement(ref _outstanding); // before the write: the client cannot send break; // its next request until it has this reply } if (reply is null) continue; if (ZeroReplyContext) ZeroContexts(reply); await s.WriteAsync(reply, _cts.Token); } } private void Record(byte[] frame) { List> fire = []; lock (_received) { _received.Add(frame); for (var i = _waiters.Count - 1; i >= 0; i--) { if (!_waiters[i].Match(frame)) continue; fire.Add(_waiters[i].Tcs); _waiters.RemoveAt(i); } } foreach (var t in fire) t.TrySetResult(frame); } private static void ZeroContexts(byte[] buffer) { for (var off = 0; off + EncapsulationHeader.Size <= buffer.Length; off += EncapsulationHeader.Size + BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(off + 2))) buffer.AsSpan(off + 12, 8).Clear(); } private async Task ReadExact(NetworkStream s, Memory buf) { var got = 0; while (got < buf.Length) { int n; try { n = await s.ReadAsync(buf[got..], _cts.Token); } catch { return false; } if (n == 0) return false; got += n; } return true; } // ---- reply builders ------------------------------------------------------------------------- /// The CIP payload of a SendRRData / SendUnitData request. public static byte[] RequestCip(byte[] request) => EncapsulationCodec.ParseReply(request, expectConnected: Command(request) == EncapCommand.SendUnitData).Cip.ToArray(); /// The connected sequence count of a SendUnitData request. public static ushort RequestSequence(byte[] request) => EncapsulationCodec.ParseReply(request, expectConnected: true).Sequence; /// A SendRRData reply echoing the request's session + context, carrying . public static byte[] RRReply(byte[] request, byte[] cipReply) => RRReplyWithContext(request, BinaryPrimitives.ReadUInt64LittleEndian(request.AsSpan(12)), cipReply); /// A SendRRData reply with an explicit sender context. public static byte[] RRReplyWithContext(byte[] request, ulong ctx, byte[] cipReply) => EncapsulationCodec.SendRRData(BinaryPrimitives.ReadUInt32LittleEndian(request.AsSpan(4)), ctx, cipReply, 0); /// A SendUnitData reply echoing the request's sequence count, carrying . public static byte[] UnitReply(byte[] request, byte[] cipReply) => UnitReplyWithSequence(request, RequestSequence(request), cipReply); /// A SendUnitData reply with an explicit sequence count (T→O connection id 0x5A5A5A5A). public static byte[] UnitReplyWithSequence(byte[] request, ushort sequence, byte[] cipReply) => EncapsulationCodec.SendUnitData(BinaryPrimitives.ReadUInt32LittleEndian(request.AsSpan(4)), BinaryPrimitives.ReadUInt64LittleEndian(request.AsSpan(12)), 0x5A5A5A5A, sequence, cipReply); public async ValueTask DisposeAsync() { await _cts.CancelAsync(); _listener.Stop(); foreach (var c in _clients) { try { c.Dispose(); } catch { /* already gone */ } } } }