fix(focas): a returned status never ends a device's batch; round-2 minors (#740 review round 2)

N1: linkDown is set only when NO reply arrived, i.e. a timeout, a transport exception or
a connect failure. A status the CNC returned proves it answered. EW_REJECT and every
unlisted rc map to BadCommunicationError, so a deterministic rc on one tag used to fail
every later tag of a reachable control on every poll. The comms-classed status still
degrades health. The coalescer already treated a returned refusal this way; it is
unchanged.

Tests: a returned EW_REJECT on tag 1 leaves tags 2 and 3 read and Good (red before). A
timeout still fails the rest without reading them (positive control).

Minors:
- m1: the _lifecycleLock doc now covers the health transitions it also guards.
- m2: the NoteFixedTreeFailed summary says a refusal credits the answer.
- m3: ReadAsync stamps a failed batch's exception with the generation the batch started
  under, and HandlePollError degrades that generation only.
- m4: the fixed-tree "blip" absence check runs unconditionally under a wide budget, and
  asserts that both ticks landed inside it.
- m5: direct unit cases for the coalescer link-down paths. A transport fault on the first
  range fails the second range and the per-tag tag without reading them. A connect
  failure costs one connect per batch. CncRelay.Up() retries the re-bind.

Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 14:55:37 -04:00
parent 95b75c2e58
commit 09e0fe1841
5 changed files with 190 additions and 22 deletions
@@ -59,8 +59,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private IReadOnlyDictionary<string, DeviceState> Devices => Volatile.Read(ref _devices);
/// <summary>
/// Guards the publish/retire of the device table against an overlapping lifecycle call. Held only for
/// synchronous swaps — never across an await, and nothing a loop runs ever takes it.
/// Guards the publish/retire of the device table against an overlapping lifecycle call, and makes each
/// generation-checked health transition atomic (<see cref="UpdateHealth"/>). Held only for synchronous swaps
/// and single health transitions — never across an await, and nothing is called under it.
/// </summary>
private readonly object _lifecycleLock = new();
@@ -200,7 +201,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
internal void HandlePollError(Exception ex)
{
_logger.LogWarning(ex, "FOCAS poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
DegradeHealth(CurrentGeneration, ex.Message);
// The generation the failed batch started under, when ReadAsync stamped it; otherwise the live one.
var generation = ex.Data.Contains(BatchGenerationKey) && ex.Data[BatchGenerationKey] is int g ? g : CurrentGeneration;
DegradeHealth(generation, ex.Message);
}
/// <inheritdoc />
@@ -696,19 +699,35 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
if (byDevice is null) return results;
if (byDevice.Count == 1)
try
{
foreach (var (device, indices) in byDevice)
await ReadDeviceAsync(device, indices, fullReferences, results, now, generation, cancellationToken).ConfigureAwait(false);
if (byDevice.Count == 1)
{
foreach (var (device, indices) in byDevice)
await ReadDeviceAsync(device, indices, fullReferences, results, now, generation, cancellationToken).ConfigureAwait(false);
}
else
{
await Task.WhenAll(byDevice.Select(kv =>
ReadDeviceAsync(kv.Key, kv.Value, fullReferences, results, now, generation, cancellationToken))).ConfigureAwait(false);
}
}
else
catch (Exception ex) when (ex is not OperationCanceledException)
{
await Task.WhenAll(byDevice.Select(kv =>
ReadDeviceAsync(kv.Key, kv.Value, fullReferences, results, now, generation, cancellationToken))).ConfigureAwait(false);
// Stamp the generation this batch STARTED under (review round 2, m3), so the poll engine's onError
// (HandlePollError) cannot degrade a generation that re-initialised after the batch began.
ex.Data[BatchGenerationKey] = generation;
throw;
}
return results;
}
/// <summary><see cref="Exception.Data"/> key carrying the lifecycle generation a failed batch started under.</summary>
internal const string BatchGenerationKey = "FocasDriver.BatchGeneration";
/// <summary>Test seam — the live lifecycle generation.</summary>
internal int LifecycleGeneration => CurrentGeneration;
/// <summary>
/// Reads one device's share of a batch, in request order: the PMC coalescing pre-pass first, then every
/// index it did not serve through the per-tag path. Each result lands at its own index, so concurrent
@@ -718,10 +737,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <b>One link failure ends the device's batch (#740 review I1).</b> Concurrency alone does not bound a
/// batch: <c>Task.WhenAll</c> waits for the slowest device, and a CNC that accepts TCP and then stalls costs
/// the full call timeout on EVERY tag — so a 10-tag stalled device held the healthy devices' values back by
/// ten timeouts. After the first timeout / transport failure (or a comms-class status) on this device, its
/// remaining tags in the batch fail fast with <c>BadCommunicationError</c>, exactly as the coalescer's
/// ten timeouts. After the first timeout / transport failure / connect failure on this device — i.e. NO
/// reply arrived — its remaining tags in the batch fail fast with <c>BadCommunicationError</c>, exactly as the coalescer's
/// <c>FailMembers</c> does for a merged range. Fixed-tree tags are still served from the cache: they need no
/// link. A CNC REFUSAL does not end the batch — the link answered.
/// link. Any status the CNC RETURNED — a refusal, or a comms-classed rc such as EW_REJECT — does not end the
/// batch: a reply proves the link answered.
/// </remarks>
private async Task ReadDeviceAsync(
DeviceState device, List<int> indices, IReadOnlyList<string> fullReferences,
@@ -792,8 +812,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
else if (IsCommsStatus(status))
{
// Degrade, but do NOT end the batch (#740 review round 2, N1): this status came back in a reply,
// so the CNC answered — EW_REJECT and every unlisted rc map to BadCommunicationError. Ending the
// batch here starved every later tag of a reachable control forever (authored order is fixed).
// A session that is really dead throws or times out on the next tag, which does end it.
DegradeHealth(generation, $"FOCAS status 0x{status:X8} reading {reference}");
linkDown = true;
}
else
{
@@ -2059,8 +2082,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
/// <summary>
/// A failed fixed-tree tick. A CNC REFUSAL (non-OK rc, transport fine) is not a comms loss and clears any
/// failure without crediting a read; a transport failure degrades the driver only once the device has been
/// A failed fixed-tree tick. A CNC REFUSAL (non-OK rc, transport fine) is not a comms loss: it clears any
/// failure AND credits the answer to LastSuccessfulRead (the CNC replied); a transport failure degrades the driver only once the device has been
/// silent past <see cref="FixedTreeStaleBudget"/> (measured from its last answer, or from loop start when
/// it never answered), so one dropped tick on a slow control does not flap <c>/hosts</c>.
/// </summary>