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."; }