test(s7): pin the span/multi-var cancellation reopen contract, one-bad-decode-in-chunk, and multi-var shape mismatch
This commit is contained in:
@@ -36,6 +36,11 @@ public sealed class S7SpanReadParityTests
|
||||
private const uint StatusBadDeviceFailure = 0x808B0000u;
|
||||
/// <summary>OPC UA <c>BadNodeIdUnknown</c> — what an unresolvable reference stamps.</summary>
|
||||
private const uint StatusBadNodeIdUnknown = 0x80340000u;
|
||||
/// <summary>
|
||||
/// OPC UA <c>BadCommunicationError</c> — what the driver's catch-all failure mapping stamps,
|
||||
/// which is where a malformed multi-var answer (an <c>InvalidDataException</c>) lands.
|
||||
/// </summary>
|
||||
private const uint StatusBadCommunicationError = 0x80050000u;
|
||||
|
||||
// ── The mixed tag set ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -373,6 +378,137 @@ public sealed class S7SpanReadParityTests
|
||||
factory.ReadBytesCalls.ShouldBe(2);
|
||||
}
|
||||
|
||||
// ── Malformed multi-var answers ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// ONE undecodable item in an otherwise-good multi-var chunk spoils only its own tag: its
|
||||
/// chunk-mates keep the values the batch already carried, and NOBODY is re-read individually.
|
||||
/// A per-ITEM decode fault is not a chunk fault — the whole-chunk fallback exists for a
|
||||
/// failed REQUEST, and firing it here would re-read tags that already have good values.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Health ends <c>Healthy</c> rather than <c>Degraded</c> because the decode loop stamps
|
||||
/// health per item in chunk order, so the good item after the bad one re-stamps it. That is
|
||||
/// the same last-writer-wins behaviour the per-tag loop has always had; it is pinned here so
|
||||
/// a change to it is a deliberate one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task One_undecodable_multi_var_item_stamps_only_its_own_tag_Bad()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var images = Images();
|
||||
var factory = new ImageS7PlcFactory(images) { NullItemIndex = 1 };
|
||||
using var driver = new S7Driver(Options(ScatteredWords(3), maxGap: 2), "s7-mv-item-fault", factory);
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
var results = await driver.ReadAsync(ScatteredRefs(3), ct);
|
||||
|
||||
// The one item the CPU answered with nothing.
|
||||
results[1].StatusCode.ShouldBe(StatusBadCommunicationError);
|
||||
results[1].Value.ShouldBeNull();
|
||||
|
||||
// Its chunk-mates are untouched, values and all (S{i} lives at DB1.DBW{i * 8}).
|
||||
var db1 = images[(S7NetDataType.DataBlock, 1)];
|
||||
static ushort Word(byte[] image, int at) => (ushort)((image[at] << 8) | image[at + 1]);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe(Word(db1, 0));
|
||||
results[2].StatusCode.ShouldBe(0u);
|
||||
results[2].Value.ShouldBe(Word(db1, 16));
|
||||
|
||||
// One batch, and NO per-tag fallback at all — not even for the tag that failed to decode,
|
||||
// which would have failed identically on the per-tag path.
|
||||
factory.MultiVarCalls.ShouldBe(1);
|
||||
factory.StringReadCalls.ShouldBe(0);
|
||||
factory.ReadBytesCalls.ShouldBe(0);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An answer of the wrong SHAPE — one item short — cannot be mapped back onto the request's
|
||||
/// indexes, so the driver refuses to guess which tag got which value and falls the whole
|
||||
/// chunk back to the per-tag path, which still serves every tag Good.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_short_multi_var_answer_falls_back_to_per_tag_reads_and_still_serves_good()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var factory = new ImageS7PlcFactory(Images()) { ShortAnswer = true };
|
||||
using var driver = new S7Driver(Options(ScatteredWords(5), maxGap: 2), "s7-mv-shape", factory);
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
var results = await driver.ReadAsync(ScatteredRefs(5), ct);
|
||||
|
||||
results.ShouldAllBe(s => s.StatusCode == 0u);
|
||||
results.ShouldAllBe(s => s.Value != null);
|
||||
factory.MultiVarCalls.ShouldBe(1);
|
||||
// Every tag of the rejected chunk was re-read individually — including the four the short
|
||||
// answer DID carry, because a mis-shaped answer says nothing trustworthy about any of them.
|
||||
factory.StringReadCalls.ShouldBe(5);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
// ── Cancellation mid-read (STAB-15a) ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Cancellation observed while a SPAN's block read is in flight abandons a half-read
|
||||
/// ISO-on-TCP response on the single gated stream, so the handle can't be reused: the OCE
|
||||
/// must propagate (the caller cancelled — nobody consumes the batch) AND the next read must
|
||||
/// open a fresh connection. The per-tag path's sibling of this is pinned by
|
||||
/// <c>S7DriverReconnectTests.Cancelled_read_marks_handle_dead_and_next_read_reopens</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_cancelled_span_block_read_marks_the_handle_dead_and_the_next_read_reopens()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var factory = new ImageS7PlcFactory(Images()) { HangReadBytes = true };
|
||||
using var driver = new S7Driver(Options(NarrowTags(), maxGap: 16), "s7-span-cancel", factory);
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
factory.CreatedPlcCount.ShouldBe(1);
|
||||
|
||||
string[] refs = ["A", "B", "C"];
|
||||
using var cts = new CancellationTokenSource();
|
||||
var reading = driver.ReadAsync(refs, cts.Token);
|
||||
await factory.ReadBytesEntered.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
|
||||
await cts.CancelAsync();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () => await reading);
|
||||
|
||||
// The reopen is the observable half of the contract: the desynced handle was discarded, so
|
||||
// the next read builds a NEW connection rather than reusing it.
|
||||
factory.HangReadBytes = false;
|
||||
var recovered = await driver.ReadAsync(refs, ct);
|
||||
recovered.ShouldAllBe(s => s.StatusCode == 0u);
|
||||
factory.CreatedPlcCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same contract on the MULTI-VAR leftover path: a cancelled batch request propagates
|
||||
/// the OCE and marks the handle dead, so the next read reopens. Distinct from the per-chunk
|
||||
/// fallback — cancellation is the one request fault that must NOT be retried per tag.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_cancelled_multi_var_read_marks_the_handle_dead_and_the_next_read_reopens()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var factory = new ImageS7PlcFactory(Images()) { HangMultiVar = true };
|
||||
using var driver = new S7Driver(Options(ScatteredWords(5), maxGap: 2), "s7-mv-cancel", factory);
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
factory.CreatedPlcCount.ShouldBe(1);
|
||||
|
||||
var refs = ScatteredRefs(5);
|
||||
using var cts = new CancellationTokenSource();
|
||||
var reading = driver.ReadAsync(refs, cts.Token);
|
||||
await factory.MultiVarEntered.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
|
||||
await cts.CancelAsync();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () => await reading);
|
||||
|
||||
factory.HangMultiVar = false;
|
||||
var recovered = await driver.ReadAsync(refs, ct);
|
||||
recovered.ShouldAllBe(s => s.StatusCode == 0u);
|
||||
factory.CreatedPlcCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
// ── Configurability ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -473,9 +609,43 @@ public sealed class S7SpanReadParityTests
|
||||
/// <summary>When set, only the MULTI-VAR read throws this — the per-tag fallback still serves the image.</summary>
|
||||
public Exception? MultiVarFault { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, <c>ReadBytesAsync</c> hangs until its token is cancelled, so cancellation is
|
||||
/// the only way out of a block read (STAB-15a). Settable so a test can turn it back off
|
||||
/// after observing the cancellation and let the reopened handle read normally.
|
||||
/// </summary>
|
||||
public bool HangReadBytes { get; set; }
|
||||
|
||||
/// <summary>When true, <c>ReadMultipleVarsAsync</c> hangs until its token is cancelled (STAB-15a).</summary>
|
||||
public bool HangMultiVar { get; set; }
|
||||
|
||||
/// <summary>Completed the moment a hanging <c>ReadBytesAsync</c> is entered, so a test cancels a read that is genuinely in flight.</summary>
|
||||
public TaskCompletionSource ReadBytesEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Completed the moment a hanging <c>ReadMultipleVarsAsync</c> is entered.</summary>
|
||||
public TaskCompletionSource MultiVarEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>
|
||||
/// When set, the multi-var answer comes back the right shape but with THIS item's
|
||||
/// <c>Value</c> left null — the per-item decode fault, which must spoil only that tag.
|
||||
/// </summary>
|
||||
public int? NullItemIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, the multi-var answer comes back ONE item short — a shape the driver cannot
|
||||
/// map back onto the request's indexes, so it must reject the whole chunk.
|
||||
/// </summary>
|
||||
public bool ShortAnswer { get; init; }
|
||||
|
||||
/// <summary>How many connections this factory has been asked to build — the reopen observable.</summary>
|
||||
public int CreatedPlcCount;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
|
||||
=> new ImageS7Plc(this);
|
||||
{
|
||||
Interlocked.Increment(ref CreatedPlcCount);
|
||||
return new ImageS7Plc(this);
|
||||
}
|
||||
|
||||
private sealed class ImageS7Plc(ImageS7PlcFactory owner) : IS7Plc
|
||||
{
|
||||
@@ -489,11 +659,18 @@ public sealed class S7SpanReadParityTests
|
||||
public void Close() => IsConnected = false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken ct)
|
||||
public async Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken ct)
|
||||
{
|
||||
// Counted BEFORE the injected fault so a test can assert how many block reads were
|
||||
// ATTEMPTED — a failure test that counted only successes would read zero.
|
||||
Interlocked.Increment(ref owner.ReadBytesCalls);
|
||||
if (owner.HangReadBytes)
|
||||
{
|
||||
// Signal first, then hang: the test cancels only once the read is genuinely in
|
||||
// flight, so it exercises cancel-mid-I/O rather than racing a timer.
|
||||
owner.ReadBytesEntered.TrySetResult();
|
||||
await Task.Delay(System.Threading.Timeout.Infinite, ct).ConfigureAwait(false);
|
||||
}
|
||||
if (owner.ReadFault is { } fault) throw fault;
|
||||
var image = owner.Images[(area, db)];
|
||||
if (startByteAdr + count > image.Length)
|
||||
@@ -501,7 +678,7 @@ public sealed class S7SpanReadParityTests
|
||||
throw new PlcException(ErrorCode.ReadData,
|
||||
$"fake image overrun: {area} db{db} @{startByteAdr}+{count} exceeds {image.Length}");
|
||||
}
|
||||
return Task.FromResult(image[startByteAdr..(startByteAdr + count)]);
|
||||
return image[startByteAdr..(startByteAdr + count)];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -512,13 +689,18 @@ public sealed class S7SpanReadParityTests
|
||||
/// <c>ushort</c>, <c>DWord</c> a <c>uint</c>. Like the real library it populates each
|
||||
/// item's <c>Value</c> in place and hands the same list back.
|
||||
/// </remarks>
|
||||
public Task<IReadOnlyList<global::S7.Net.Types.DataItem>> ReadMultipleVarsAsync(
|
||||
public async Task<IReadOnlyList<global::S7.Net.Types.DataItem>> ReadMultipleVarsAsync(
|
||||
List<global::S7.Net.Types.DataItem> items, CancellationToken ct)
|
||||
{
|
||||
// Counted (and captured) BEFORE the injected fault so a fallback test can assert the
|
||||
// batch was ATTEMPTED — counting only successes would read zero.
|
||||
Interlocked.Increment(ref owner.MultiVarCalls);
|
||||
lock (owner.MultiVarItems) owner.MultiVarItems.Add([.. items]);
|
||||
if (owner.HangMultiVar)
|
||||
{
|
||||
owner.MultiVarEntered.TrySetResult();
|
||||
await Task.Delay(System.Threading.Timeout.Infinite, ct).ConfigureAwait(false);
|
||||
}
|
||||
if (owner.MultiVarFault is { } mvFault) throw mvFault;
|
||||
if (owner.ReadFault is { } fault) throw fault;
|
||||
|
||||
@@ -537,7 +719,12 @@ public sealed class S7SpanReadParityTests
|
||||
};
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<global::S7.Net.Types.DataItem>>(items);
|
||||
// The two malformed-answer shapes, injected AFTER decoding so the answer is as good
|
||||
// as any other batch's apart from the one defect under test.
|
||||
if (owner.NullItemIndex is { } nullAt) items[nullAt].Value = null;
|
||||
if (owner.ShortAnswer) return items.GetRange(0, items.Count - 1);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
Reference in New Issue
Block a user