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:
Joseph Doherty
2026-08-17 05:33:45 -04:00
parent a61b3c98a2
commit 4f86073333
@@ -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 />