1d83cde6bc
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
105 lines
4.3 KiB
C#
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;
|
|
}
|
|
}
|