namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
///
/// Bounds a single S7 wire operation with a wall-clock deadline, surfacing an overrun as a
/// .
///
///
///
/// S7.Net's ReadTimeout / WriteTimeout set the underlying
/// 's ReceiveTimeout / SendTimeout,
/// which govern only synchronous socket calls — the async 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 open 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 (S7Driver.EnsureConnectedAsync's
/// CancelAfter) never covered.
///
///
/// The timed-out operation surfaces as a , which
/// S7Driver.IsS7ConnectionFatal classifies connection-fatal → the handle is marked
/// dead → the next EnsureConnectedAsync reopens. The end-to-end proof is the live gate
/// S7_1500ConnectTimeoutOutageTests (a real snap7 sim frozen with docker pause).
///
///
internal static class S7OperationDeadline
{
/// Runs under a wall-clock ceiling.
/// The operation result type.
/// The wire operation; receives a token cancelled at the deadline.
/// The wall-clock ceiling for the operation.
/// A short description of the operation for the timeout message (e.g. read of 'DB1.DBW0').
/// The caller's cancellation token — its cancellation propagates as an , not a timeout.
/// The operation result when it completes within the deadline.
/// The operation did not complete within .
public static async Task RunAsync(
Func> 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));
}
}
/// Runs a result-less under a wall-clock ceiling.
/// The wire operation; receives a token cancelled at the deadline.
/// The wall-clock ceiling for the operation.
/// A short description of the operation for the timeout message.
/// The caller's cancellation token.
/// A task that completes when the operation completes within the deadline.
/// The operation did not complete within .
public static Task RunAsync(
Func 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.";
}