88e0977ae6
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).
96 lines
4.0 KiB
C#
96 lines
4.0 KiB
C#
using System.Diagnostics;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="S7OperationDeadline"/> — the wall-clock ceiling that bounds every
|
|
/// S7 async wire op (R2-01 read-leg gate). Async S7.Net reads/writes ignore the socket
|
|
/// ReadTimeout/WriteTimeout, so a frozen-but-established peer would otherwise block for minutes;
|
|
/// these pin the deadline behaviour without a live PLC.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class S7OperationDeadlineTests
|
|
{
|
|
private static readonly TimeSpan ShortTimeout = TimeSpan.FromMilliseconds(150);
|
|
|
|
/// <summary>An operation that completes within the deadline returns its value untouched.</summary>
|
|
[Fact]
|
|
public async Task Completes_within_deadline_returns_value()
|
|
{
|
|
var result = await S7OperationDeadline.RunAsync(
|
|
_ => Task.FromResult(42), ShortTimeout, "read", TestContext.Current.CancellationToken);
|
|
result.ShouldBe(42);
|
|
}
|
|
|
|
/// <summary>
|
|
/// An operation that ignores its token and never completes is cut at the deadline and
|
|
/// surfaces a <see cref="TimeoutException"/> — the frozen-established-peer shape. Uses a bare
|
|
/// <see cref="TaskCompletionSource"/> that never completes, so the token is genuinely ignored.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Token_ignoring_hang_times_out()
|
|
{
|
|
var neverCompletes = new TaskCompletionSource<int>().Task;
|
|
var sw = Stopwatch.StartNew();
|
|
|
|
var ex = await Should.ThrowAsync<TimeoutException>(async () =>
|
|
await S7OperationDeadline.RunAsync(
|
|
_ => neverCompletes, ShortTimeout, "read of 'DB1.DBW0'", TestContext.Current.CancellationToken));
|
|
|
|
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5));
|
|
ex.Message.ShouldContain("did not complete within 150 ms");
|
|
}
|
|
|
|
/// <summary>
|
|
/// An operation that honours its token (the well-behaved async-read case) is cancelled at the
|
|
/// deadline; the resulting cancellation is normalised to a <see cref="TimeoutException"/>,
|
|
/// NOT surfaced as an <see cref="OperationCanceledException"/>.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Token_honouring_hang_times_out_as_timeout_not_cancel()
|
|
{
|
|
await Should.ThrowAsync<TimeoutException>(async () =>
|
|
await S7OperationDeadline.RunAsync(
|
|
async token =>
|
|
{
|
|
await Task.Delay(Timeout.Infinite, token);
|
|
return 0;
|
|
},
|
|
ShortTimeout, "read", TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Caller cancellation (as opposed to the deadline firing) propagates as an
|
|
/// <see cref="OperationCanceledException"/> — the deadline wrapper must not swallow genuine
|
|
/// cancellation into a timeout.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Caller_cancellation_propagates_as_operation_cancelled()
|
|
{
|
|
using var cts = new CancellationTokenSource();
|
|
cts.CancelAfter(50);
|
|
|
|
// A long deadline so the CALLER token fires first, not the internal deadline.
|
|
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
|
await S7OperationDeadline.RunAsync(
|
|
async token =>
|
|
{
|
|
await Task.Delay(Timeout.Infinite, token);
|
|
return 0;
|
|
},
|
|
TimeSpan.FromSeconds(30), "read", cts.Token));
|
|
}
|
|
|
|
/// <summary>The result-less overload also bounds a hang and surfaces a <see cref="TimeoutException"/>.</summary>
|
|
[Fact]
|
|
public async Task Resultless_overload_times_out()
|
|
{
|
|
var neverCompletes = new TaskCompletionSource().Task;
|
|
await Should.ThrowAsync<TimeoutException>(async () =>
|
|
await S7OperationDeadline.RunAsync(
|
|
_ => neverCompletes, ShortTimeout, "write of 'DB1.DBW0'", TestContext.Current.CancellationToken));
|
|
}
|
|
}
|