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:
Joseph Doherty
2026-10-08 07:31:22 -04:00
parent ca0f1e7735
commit e77bca385a
9 changed files with 208 additions and 13 deletions
@@ -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",