Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7OperationDeadline.cs
T
Joseph Doherty 88e0977ae6
v2-ci / build (pull_request) Successful in 5m5s
v2-ci / unit-tests (pull_request) Failing after 13m50s
fix(s7): bound async wire ops with a wall-clock deadline (R2-01 read leg)
The R2-01 live gate (S7_1500ConnectTimeoutOutageTests, docker-pause blackhole)
surfaced a real gap the offline fakes couldn't: S7.Net's ReadTimeout/WriteTimeout
map to the TcpClient's ReceiveTimeout/SendTimeout, which govern only SYNCHRONOUS
socket calls — the async read/write paths S7.Net uses ignore them. On an
established-but-frozen peer (frozen PLC / firewall DROP / cable pulled mid-flow,
TCP session still open) a read blocked until the OS TCP stack gave up (minutes),
silently wedging the poll loop: no Bad tick, no reconnect. STAB-14 fixed only the
CONNECT leg (EnsureConnectedAsync CancelAfter); this is its READ-leg sibling.

- New S7OperationDeadline: bounds every data-plane wire op with a wall-clock
  ceiling (= _options.Timeout), surfacing an overrun as TimeoutException. Applied
  in S7PlcAdapter to Read/ReadBytes/Write/WriteBytes/ReadStatus. OpenAsync is left
  to EnsureConnectedAsync's own CancelAfter (not double-bounded).
- IsS7ConnectionFatal now classifies TimeoutException fatal → handle marked dead →
  next EnsureConnectedAsync reopens (connect-timeout fix takes over from there).
- Tests: 5 S7OperationDeadline unit tests (deadline / token-honouring / caller-
  cancel-passthrough / resultless), 1 driver-reaction test (read TimeoutException
  → reopen). Driver.S7.Tests 260/260.
- Live gate S7_1500ConnectTimeoutOutageTests now GREEN against the real snap7 sim
  (8s: baseline Good -> pause blackhole -> Bad tick -> unpause -> recovered Good).
2026-07-15 07:11:42 -04:00

81 lines
4.9 KiB
C#

namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// Bounds a single S7 wire operation with a wall-clock deadline, surfacing an overrun as a
/// <see cref="TimeoutException"/>.
/// </summary>
/// <remarks>
/// <para>
/// S7.Net's <c>ReadTimeout</c> / <c>WriteTimeout</c> set the underlying
/// <see cref="System.Net.Sockets.TcpClient"/>'s <c>ReceiveTimeout</c> / <c>SendTimeout</c>,
/// which govern only <b>synchronous</b> socket calls — the <c>async</c> read/write paths
/// S7.Net actually uses ignore them entirely. Without an explicit ceiling, a read on an
/// established-but-frozen connection (a frozen PLC, a firewall DROP, or a cable pulled
/// mid-flow — where the TCP session stays <i>open</i> but no reply ever arrives) blocks until
/// the OS TCP stack gives up, which can be minutes. That silently wedges the driver's poll
/// loop: no Bad tick, no reconnect — the STAB-14 "silently killed poll loop" failure, on the
/// READ leg that the connect-timeout fix (<c>S7Driver.EnsureConnectedAsync</c>'s
/// <c>CancelAfter</c>) never covered.
/// </para>
/// <para>
/// The timed-out operation surfaces as a <see cref="TimeoutException"/>, which
/// <c>S7Driver.IsS7ConnectionFatal</c> classifies connection-fatal → the handle is marked
/// dead → the next <c>EnsureConnectedAsync</c> reopens. The end-to-end proof is the live gate
/// <c>S7_1500ConnectTimeoutOutageTests</c> (a real snap7 sim frozen with <c>docker pause</c>).
/// </para>
/// </remarks>
internal static class S7OperationDeadline
{
/// <summary>Runs <paramref name="operation"/> under a <paramref name="timeout"/> wall-clock ceiling.</summary>
/// <typeparam name="T">The operation result type.</typeparam>
/// <param name="operation">The wire operation; receives a token cancelled at the deadline.</param>
/// <param name="timeout">The wall-clock ceiling for the operation.</param>
/// <param name="what">A short description of the operation for the timeout message (e.g. <c>read of 'DB1.DBW0'</c>).</param>
/// <param name="ct">The caller's cancellation token — its cancellation propagates as an <see cref="OperationCanceledException"/>, not a timeout.</param>
/// <returns>The operation result when it completes within the deadline.</returns>
/// <exception cref="TimeoutException">The operation did not complete within <paramref name="timeout"/>.</exception>
public static async Task<T> RunAsync<T>(
Func<CancellationToken, Task<T>> operation, TimeSpan timeout, string what, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(operation);
using var opCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
opCts.CancelAfter(timeout);
try
{
// WaitAsync guarantees control returns at the deadline even if the underlying op ignores
// its token; passing opCts.Token additionally lets an op that DOES honour cancellation
// unwind cleanly instead of leaking a wedged socket read behind an abandoned task.
return await operation(opCts.Token).WaitAsync(timeout, ct).ConfigureAwait(false);
}
catch (TimeoutException)
{
// WaitAsync's own deadline fired (the op ignored the token).
throw new TimeoutException(FormatMessage(what, timeout));
}
catch (OperationCanceledException) when (opCts.IsCancellationRequested && !ct.IsCancellationRequested)
{
// OUR deadline fired and the op honoured opCts.Token — not the caller's cancellation.
throw new TimeoutException(FormatMessage(what, timeout));
}
}
/// <summary>Runs a result-less <paramref name="operation"/> under a <paramref name="timeout"/> wall-clock ceiling.</summary>
/// <param name="operation">The wire operation; receives a token cancelled at the deadline.</param>
/// <param name="timeout">The wall-clock ceiling for the operation.</param>
/// <param name="what">A short description of the operation for the timeout message.</param>
/// <param name="ct">The caller's cancellation token.</param>
/// <returns>A task that completes when the operation completes within the deadline.</returns>
/// <exception cref="TimeoutException">The operation did not complete within <paramref name="timeout"/>.</exception>
public static Task RunAsync(
Func<CancellationToken, Task> operation, TimeSpan timeout, string what, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(operation);
return RunAsync(
async token => { await operation(token).ConfigureAwait(false); return true; },
timeout, what, ct);
}
private static string FormatMessage(string what, TimeSpan timeout) =>
$"S7 {what} did not complete within {(int)timeout.TotalMilliseconds} ms.";
}