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
168 lines
7.5 KiB
C#
168 lines
7.5 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
|
|
|
/// <summary>
|
|
/// R07:P1 — once a pass has marked the connection dead (a wire op blew its deadline on a frozen
|
|
/// peer), the rest of that pass must not go to the wire: each further op would pay the full
|
|
/// timeout under the gate, so a 20-span pass took ~100 s and the comms-loss watchdog's clock only
|
|
/// started after it. The remaining items are stamped <c>BadCommunicationError</c> without I/O.
|
|
/// Every assertion counts wire operations; nothing is timed.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class S7DeadHandleShortCircuitTests
|
|
{
|
|
private const uint BadCommunicationError = 0x80050000u;
|
|
|
|
private static S7DriverOptions Options(int maxGap, params S7TagDefinition[] tags) => new()
|
|
{
|
|
Host = "192.0.2.1",
|
|
Timeout = TimeSpan.FromSeconds(5),
|
|
Probe = new S7ProbeOptions { Enabled = false },
|
|
SpanReadMaxGapBytes = maxGap,
|
|
RawTags = S7RawTags.Entries(tags),
|
|
};
|
|
|
|
/// <summary>Five isolated words — far enough apart that each is its own span.</summary>
|
|
private static S7TagDefinition[] FarApartWords() =>
|
|
[.. Enumerable.Range(0, 5).Select(i => new S7TagDefinition($"W{i}", $"DB{i + 1}.DBW0", S7DataType.UInt16, Writable: true))];
|
|
|
|
private static string[] Refs(int n) => [.. Enumerable.Range(0, n).Select(i => $"W{i}")];
|
|
|
|
private static async Task<(S7Driver, ScriptedS7PlcFactory)> StartAsync(S7DriverOptions options)
|
|
{
|
|
var factory = new ScriptedS7PlcFactory();
|
|
var drv = new S7Driver(options, "s7-dead-short-circuit", factory);
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
return (drv, factory);
|
|
}
|
|
|
|
/// <summary>Per-tag path: the first timeout ends the pass's wire traffic.</summary>
|
|
[Fact]
|
|
public async Task Per_tag_pass_stops_issuing_wire_ops_once_the_handle_is_dead()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var (drv, factory) = await StartAsync(Options(maxGap: -1, FarApartWords()));
|
|
using var _ = drv;
|
|
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
|
|
|
|
var before = factory.WireOps;
|
|
var r = await drv.ReadAsync(Refs(5), ct);
|
|
|
|
(factory.WireOps - before).ShouldBe(1, "only the op that discovered the dead handle may go to the wire");
|
|
r.ShouldAllBe(s => s.StatusCode == BadCommunicationError);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
|
|
// Positive control: the next pass reopens and serves every tag.
|
|
factory.Read = (_, _) => Task.FromResult<object?>((ushort)1);
|
|
var recovered = await drv.ReadAsync(Refs(5), ct);
|
|
recovered.ShouldAllBe(s => s.StatusCode == 0u);
|
|
factory.Created.Count.ShouldBe(2);
|
|
}
|
|
|
|
/// <summary>Span path: arrays are block reads even alone, so five arrays are five spans.</summary>
|
|
[Fact]
|
|
public async Task Span_pass_stops_issuing_wire_ops_once_the_handle_is_dead()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
S7TagDefinition[] arrays =
|
|
[.. Enumerable.Range(0, 5).Select(i => new S7TagDefinition($"W{i}", $"DB{i + 1}.DBW0", S7DataType.UInt16, Writable: false, ArrayCount: 2))];
|
|
var (drv, factory) = await StartAsync(Options(maxGap: 16, arrays));
|
|
using var _ = drv;
|
|
factory.ReadBytes = (_, _, _, _, _) => throw new TimeoutException("frozen peer");
|
|
|
|
var before = factory.WireOps;
|
|
var r = await drv.ReadAsync(Refs(5), ct);
|
|
|
|
(factory.WireOps - before).ShouldBe(1);
|
|
r.ShouldAllBe(s => s.StatusCode == BadCommunicationError);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Multi-var path: a chunk that dies on a transport loss used to fall back to one per-tag read
|
|
/// per member — each paying the timeout again. Now it marks the handle dead and the fallback is
|
|
/// short-circuited.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Multi_var_transport_loss_does_not_fall_back_to_per_tag_wire_reads()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var (drv, factory) = await StartAsync(Options(maxGap: 0, FarApartWords()));
|
|
using var _ = drv;
|
|
factory.ReadMulti = (_, _) => throw new TimeoutException("frozen peer");
|
|
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
|
|
|
|
var before = factory.WireOps;
|
|
var r = await drv.ReadAsync(Refs(5), ct);
|
|
|
|
(factory.WireOps - before).ShouldBe(1);
|
|
r.ShouldAllBe(s => s.StatusCode == BadCommunicationError);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Positive control for the classification: a multi-var REFUSAL (not a transport loss) still
|
|
/// falls back per tag, so the members the CPU will serve are served.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Multi_var_refusal_still_falls_back_per_tag()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var (drv, factory) = await StartAsync(Options(maxGap: 0, FarApartWords()));
|
|
using var _ = drv;
|
|
// The live snap7 shape of a multi-var with one refused item.
|
|
factory.ReadMulti = (_, _) => throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData,
|
|
new global::S7.Net.PlcException(global::S7.Net.ErrorCode.WrongNumberReceivedBytes));
|
|
factory.Read = (_, _) => Task.FromResult<object?>((ushort)9);
|
|
|
|
var before = factory.WireOps;
|
|
var r = await drv.ReadAsync(Refs(5), ct);
|
|
|
|
(factory.WireOps - before).ShouldBe(6, "one multi-var request, then one per-tag read each");
|
|
r.ShouldAllBe(s => s.StatusCode == 0u);
|
|
factory.Created.Count.ShouldBe(1, "a refusal is not a reason to reopen");
|
|
}
|
|
|
|
/// <summary>A refusal does not short-circuit the pass: the CPU answered, the handle is fine.</summary>
|
|
[Fact]
|
|
public async Task A_refusal_does_not_short_circuit_the_pass()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var (drv, factory) = await StartAsync(Options(maxGap: -1, FarApartWords()));
|
|
using var _ = drv;
|
|
factory.Read = (addr, _) => addr == "DB1.DBW0"
|
|
? throw new Exception("Received error from PLC: Object does not exist.")
|
|
: Task.FromResult<object?>((ushort)3);
|
|
|
|
var before = factory.WireOps;
|
|
var r = await drv.ReadAsync(Refs(5), ct);
|
|
|
|
(factory.WireOps - before).ShouldBe(5);
|
|
r.Skip(1).ShouldAllBe(s => s.StatusCode == 0u);
|
|
}
|
|
|
|
/// <summary>Write batch: the first transport loss fails the rest of the batch without I/O.</summary>
|
|
[Fact]
|
|
public async Task Write_batch_stops_issuing_wire_ops_once_the_handle_is_dead()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var (drv, factory) = await StartAsync(Options(maxGap: -1, FarApartWords()));
|
|
using var _ = drv;
|
|
factory.Write = (_, _, _) => throw new System.IO.IOException("connection reset");
|
|
|
|
var before = factory.WireOps;
|
|
var w = await drv.WriteAsync([.. Refs(5).Select(r => new WriteRequest(r, (ushort)1))], ct);
|
|
|
|
(factory.WireOps - before).ShouldBe(1);
|
|
w.ShouldAllBe(x => x.StatusCode == BadCommunicationError);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
|
|
// Positive control: the next batch reopens and writes.
|
|
factory.Write = (_, _, _) => Task.CompletedTask;
|
|
(await drv.WriteAsync([.. Refs(5).Select(r => new WriteRequest(r, (ushort)1))], ct))
|
|
.ShouldAllBe(x => x.StatusCode == 0u);
|
|
}
|
|
}
|