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
@@ -812,6 +812,9 @@ public sealed class GalaxyDriver
return ReadViaSubscribeOnceAsync(dialledRefs, cancellationToken);
}
/// <summary>Upper bound on one subscribe-once read; internal so tests can shrink it.</summary>
internal TimeSpan ReadViaSubscribeDeadline { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Production read path. MxAccess has no one-shot Read RPC — every value comes
/// through the event stream. We synthesise a Read by:
@@ -826,9 +829,6 @@ public sealed class GalaxyDriver
/// caller's cancellation token fires, return a Bad-status snapshot in input order
/// so the caller still sees one snapshot per requested reference.
/// </summary>
/// <summary>Upper bound on one subscribe-once read; internal so tests can shrink it.</summary>
internal TimeSpan ReadViaSubscribeDeadline { get; set; } = TimeSpan.FromSeconds(30);
private async Task<IReadOnlyList<DataValueSnapshot>> ReadViaSubscribeOnceAsync(
IReadOnlyList<string> fullReferences, CancellationToken callerToken)
{
@@ -868,11 +868,36 @@ public sealed class GalaxyDriver
var bufferedIntervalMs = _options.MxAccess.PublishingIntervalMs;
IReadOnlyList<SubscribeResult> results;
// The RPC gets only the CALLER's token: the internal deadline bounds our wait, not the RPC, so a slow
// gateway can never surface an OperationCanceledException to a caller that did not cancel, and the
// handles it eventually creates are known (and unsubscribed below) rather than lost to a cancelled call.
var subscribeTask = _subscriber!.SubscribeBulkAsync(fullReferences, bufferedIntervalMs, callerToken);
try
{
results = await _subscriber!
.SubscribeBulkAsync(fullReferences, bufferedIntervalMs, cancellationToken)
.ConfigureAwait(false);
results = await subscribeTask.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!callerToken.IsCancellationRequested)
{
// Internal deadline fired while the subscribe RPC was still in flight.
pump.OnDataChange -= handler;
var subscriber = _subscriber!;
var logger = _logger;
_ = subscribeTask.ContinueWith(t =>
{
if (!t.IsCompletedSuccessfully) return Task.CompletedTask;
var late = t.Result.Where(r => r.WasSuccessful && r.ItemHandle > 0).Select(r => r.ItemHandle).ToArray();
return late.Length == 0
? Task.CompletedTask
: subscriber.UnsubscribeBulkAsync(late, CancellationToken.None).ContinueWith(
u => logger.LogWarning(u.Exception,
"GalaxyDriver.ReadViaSubscribeOnceAsync late UnsubscribeBulk failed for {Count} handle(s).", late.Length),
TaskContinuationOptions.OnlyOnFaulted);
}, TaskScheduler.Default).Unwrap();
var now = DateTime.UtcNow;
var timedOut = new DataValueSnapshot[fullReferences.Count];
for (var i = 0; i < timedOut.Length; i++)
timedOut[i] = new DataValueSnapshot(null, StatusCodeMap.BadTimeout, null, now);
return timedOut;
}
catch
{