Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/FreezableTcpProxy.cs
T
Joseph Doherty 1d83cde6bc perf(s7): fail the rest of a pass fast once the handle is dead (R07:P1)
No read or write loop checked _plcDead between items: on a frozen-but-
established peer the first op blew its deadline and marked the handle dead,
and every remaining span / tag / write still went to the wire and could wait
its own deadline under _gate (a 20-span pass ~100 s), with Degraded and the
comms-loss watchdog's clock deferred until the pass ended.

- The span loop, the multi-var chunk loop, the per-tag loop and the write
  loop stamp BadCommunicationError without I/O once _plcDead is set.
- A multi-var chunk that dies on a transport loss (socket / IO / timeout /
  ConnectionError) marks the handle dead instead of falling back to one
  doomed per-tag read per member. The framing class is deliberately excluded
  there: snap7/S7.Net report a multi-var with one refused item as
  ReadData(WrongNumberReceivedBytes) over a healthy stream.
- Live gate via an in-test freezable TCP proxy (counts request frames, not
  seconds). Mutation shows S7.Net 0.20 already self-closes on a cancelled op,
  so the gate pins the end-to-end property; the driver guard itself is proven
  by the unit tests (an op that ignores its token keeps the socket open).

Refs #742
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:26:14 -04:00

105 lines
4.3 KiB
C#

using System.Net;
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests;
/// <summary>
/// An in-test TCP forwarder in front of the S7 fixture that can be FROZEN: while frozen it keeps
/// every socket open but forwards nothing in either direction — the frozen-but-established peer
/// (cable pull, firewall DROP) that only a wire-op deadline can detect, produced without touching
/// the shared fixture. It counts the ISO-on-TCP (TPKT) request frames the client sends while frozen,
/// so a test can assert how many wire operations went out without timing anything.
/// </summary>
internal sealed class FreezableTcpProxy : IAsyncDisposable
{
private readonly string _targetHost;
private readonly int _targetPort;
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
private readonly CancellationTokenSource _cts = new();
private readonly List<TcpClient> _sockets = [];
private volatile bool _frozen;
private int _framesWhileFrozen;
/// <summary>Starts a proxy forwarding to <paramref name="targetHost"/>:<paramref name="targetPort"/>.</summary>
/// <param name="targetHost">The fixture host.</param>
/// <param name="targetPort">The fixture port.</param>
public FreezableTcpProxy(string targetHost, int targetPort)
{
_targetHost = targetHost;
_targetPort = targetPort;
_listener.Start();
_ = AcceptLoopAsync();
}
/// <summary>The local port clients connect to.</summary>
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
/// <summary>TPKT request frames the client sent while the proxy was frozen.</summary>
public int FramesWhileFrozen => Volatile.Read(ref _framesWhileFrozen);
/// <summary>Stops forwarding (sockets stay open).</summary>
public void Freeze() => _frozen = true;
/// <summary>Resumes forwarding for connections made from now on.</summary>
public void Unfreeze() => _frozen = false;
private async Task AcceptLoopAsync()
{
try
{
while (!_cts.IsCancellationRequested)
{
var client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false);
var upstream = new TcpClient();
await upstream.ConnectAsync(_targetHost, _targetPort, _cts.Token).ConfigureAwait(false);
lock (_sockets) { _sockets.Add(client); _sockets.Add(upstream); }
// A connection accepted while frozen stays frozen for its whole life (its peer is "gone").
var frozenAtBirth = _frozen;
_ = PumpAsync(client.GetStream(), upstream.GetStream(), countFrames: true, frozenAtBirth);
_ = PumpAsync(upstream.GetStream(), client.GetStream(), countFrames: false, frozenAtBirth);
}
}
catch { /* disposed */ }
}
private async Task PumpAsync(NetworkStream from, NetworkStream to, bool countFrames, bool frozenAtBirth)
{
var buffer = new byte[8192];
var pending = new List<byte>();
try
{
while (!_cts.IsCancellationRequested)
{
var n = await from.ReadAsync(buffer, _cts.Token).ConfigureAwait(false);
if (n == 0) return;
var frozen = _frozen || frozenAtBirth;
if (countFrames && frozen)
{
// Count complete TPKT frames: 0x03 0x00 then a big-endian total length.
pending.AddRange(buffer.AsSpan(0, n).ToArray());
while (pending.Count >= 4)
{
var len = (pending[2] << 8) | pending[3];
if (len < 4 || pending.Count < len) break;
pending.RemoveRange(0, len);
Interlocked.Increment(ref _framesWhileFrozen);
}
}
if (frozen) continue; // swallow — the peer never answers
await to.WriteAsync(buffer.AsMemory(0, n), _cts.Token).ConfigureAwait(false);
}
}
catch { /* socket closed / disposed */ }
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
_cts.Cancel();
_listener.Stop();
lock (_sockets) foreach (var s in _sockets) s.Dispose();
_cts.Dispose();
return ValueTask.CompletedTask;
}
}