fix(galaxy,s7): R2 review minors — subscribe-once deadline, probe vs backoff, write-fault ownership, null array elements, write opt-in
- Galaxy: deadline bounds the wait, not the subscribe RPC; BadTimeout for all tags and late handles unsubscribed; doc comment relocated - S7: probe connect runs on the connect deadline so a timeout opens the backoff window - S7: a successful write only retires a Faulted the write side owns - S7: null array elements rejected (BadTypeMismatch); multi-PDU array writes documented non-atomic - S7 integration: array write test gated on S7_SIM_ALLOW_WRITES=1 Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -85,6 +85,31 @@ public sealed class S7ArrayWriteAndHealthTests
|
||||
Should.Throw<S7Driver.S7WriteValueException>(() => S7Driver.EncodeArrayBlock(tag, addr, new object[] { 1, "x" })); // format
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A null element must not silently coerce to 0 (Convert.ToXxx(null) == 0) and land on the PLC: it is a
|
||||
/// client error, and nothing reaches the wire. The valid write in the same batch is the positive control.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_array_with_a_null_element_is_BadTypeMismatch_and_writes_nothing()
|
||||
{
|
||||
var tag = Arr(S7DataType.Int16, "DB1.DBW0", 2);
|
||||
Should.Throw<S7Driver.S7WriteValueException>(() =>
|
||||
S7Driver.EncodeArrayBlock(tag, S7AddressParser.Parse("DB1.DBW0"), new object?[] { 1, null }));
|
||||
|
||||
var factory = new RecordingFactory();
|
||||
using var drv = new S7Driver(Options(tag), "s7-arr-null", factory);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var res = await drv.WriteAsync(
|
||||
[new WriteRequest("Arr", new object?[] { 1, null }), new WriteRequest("Arr", new short[] { 7, 8 })],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
res[0].StatusCode.ShouldBe(BadTypeMismatch);
|
||||
res[1].StatusCode.ShouldBe(0u);
|
||||
factory.Created[0].BlockWrites.ShouldHaveSingleItem().Bytes.ShouldBe(new byte[] { 0, 7, 0, 8 });
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>Bool and wide-type arrays are refused with NotSupported (BadNotSupported on the wire).</summary>
|
||||
[Theory]
|
||||
[InlineData(S7DataType.Bool, "DB1.DBX0.0")]
|
||||
@@ -260,6 +285,30 @@ public sealed class S7ArrayWriteAndHealthTests
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Faulted that a READ pass raised (access denied on a read) is not the write side's to retire: a
|
||||
/// successful write must leave it in place until a clean read pass clears it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Successful_write_does_not_clear_a_read_side_Faulted()
|
||||
{
|
||||
var (drv, factory) = await TwoTagDriver();
|
||||
using var _ = drv;
|
||||
var plc = factory.Created[0];
|
||||
|
||||
plc.NextReadThrows = new PlcException(ErrorCode.ReadData, "Accessing object not allowed");
|
||||
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
|
||||
var res = await drv.WriteAsync([new WriteRequest("B", (ushort)2)], TestContext.Current.CancellationToken);
|
||||
res[0].StatusCode.ShouldBe(0u); // the write really got through
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted); // ... but it does not own this fault
|
||||
|
||||
// Positive control: the read side retires its own fault.
|
||||
(await drv.ReadAsync(["B"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>A transient (Degraded) write failure is still erased by a clean read, as before.</summary>
|
||||
[Fact]
|
||||
public async Task Degraded_write_failure_is_still_cleared_by_a_clean_read()
|
||||
|
||||
@@ -442,6 +442,46 @@ public sealed class S7DriverReconnectTests
|
||||
drv.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// STAB-21 vs the probe. On a black-holed host the probe's connect used to be cut by the (shorter) probe
|
||||
/// deadline, which is not a caller cancel but was treated as one, so NO failure was recorded and every
|
||||
/// probe tick paid a fresh connect attempt. The connect must run on the connect deadline and a timeout
|
||||
/// must open the backoff window: far fewer attempts than ticks.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Probe_connect_timeout_on_a_black_holed_host_opens_the_backoff_window()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(400),
|
||||
Probe = new S7ProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(20),
|
||||
Timeout = TimeSpan.FromMilliseconds(80),
|
||||
},
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)),
|
||||
};
|
||||
var factory = new FakeS7PlcFactory();
|
||||
using var drv = new S7Driver(opts, "s7-probe-blackhole", factory);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
// Kill the live handle (probe fault), then black-hole every reconnect.
|
||||
factory.OpenHangsUntilCancelledCount = int.MaxValue;
|
||||
factory.Created[0].ReadStatusThrows = new System.Net.Sockets.SocketException();
|
||||
|
||||
// First reconnect attempt must have been made and failed (positive control), then observe a window
|
||||
// well inside the 1 s first backoff window.
|
||||
await WaitUntilAsync(() => factory.Created.Count >= 2, TimeSpan.FromSeconds(5));
|
||||
factory.Created.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||
var atFirstAttempt = factory.Created.Count;
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(900));
|
||||
|
||||
// Without the fix this is ~10+ (one 80 ms-cut attempt per tick); with it the window suppresses them.
|
||||
(factory.Created.Count - atFirstAttempt).ShouldBeLessThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
private static S7DriverOptions ProbeEnabledOptions() => new()
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
|
||||
Reference in New Issue
Block a user