fix(focas): build-and-swap the device table; make the lifecycle overlap-safe (R07:H13)

_devices was a plain Dictionary cleared and refilled in place by InitializeAsync /
ShutdownAsync while the retained poll engine, the fixed-tree and alarm loops and the
actor's health tick enumerated it ("Collection was modified"). It is now built whole and
published by reference swap, and a release swaps it out to empty before tearing it down.

Lane A's runtime overlaps lifecycle calls, so the driver now tolerates both shapes:
- a lifecycle generation is bumped by every Initialize/Shutdown; an initialise that finds
  itself superseded (abandoned by the runtime, then a shutdown ran) publishes nothing,
  starts no loops, writes no health and throws OperationCanceledException;
- a shutdown overlapping another (dispose beside a still-running shutdown) waits for the
  first's teardown via a release tail instead of returning on an empty table;
- released devices are Retired: EnsureConnectedAsync refuses to reconnect them, and a
  connect that completes after retirement disposes what it opened. Clients are disposed
  under the device ConnectGate (bounded).

Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 14:03:08 -04:00
parent 8a4cf8b823
commit a32894e93b
3 changed files with 424 additions and 112 deletions
@@ -43,7 +43,40 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly Func<string, FocasDriverBinding>? _rebind;
private readonly PollGroupEngine _poll;
private readonly ILogger<FocasDriver> _logger;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Host address → device state. Built whole in <see cref="InitializeAsync"/> and SWAPPED in (and swapped
/// out whole by a release), never cleared or refilled in place: the retained poll engine, the fixed-tree
/// and alarm loops and the actor's health tick all read it concurrently with a re-initialise, and a
/// <see cref="Dictionary{TKey,TValue}"/> mutated under an enumerator throws "Collection was modified".
/// Readers take one <c>Volatile.Read</c> snapshot and use it for the whole operation.
/// </summary>
private IReadOnlyDictionary<string, DeviceState> _devices = EmptyDevices;
private static readonly IReadOnlyDictionary<string, DeviceState> EmptyDevices =
new Dictionary<string, DeviceState>(StringComparer.OrdinalIgnoreCase);
/// <summary>The live device table (one consistent snapshot).</summary>
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.
/// </summary>
private readonly object _lifecycleLock = new();
/// <summary>
/// Bumped by every <see cref="InitializeAsync"/> and <see cref="ShutdownAsync"/>. An initialise that finds
/// the counter moved on by the time it would publish has been superseded (the runtime abandoned it and
/// tore the driver down) and must start nothing.
/// </summary>
private int _lifecycleGeneration;
/// <summary>
/// Completes when every device table released so far has finished its teardown. A shutdown that overlaps
/// another (Lane A's dispose beside a still-running shutdown) awaits it, so it never returns while the
/// first one is still joining loops and disposing clients.
/// </summary>
private Task _releaseTail = Task.CompletedTask;
// v3: RawPath → definition, case-sensitive ordinal. Built in InitializeAsync from _options.RawTags
// via the pure FocasTagDefinitionFactory mapper (the reference is always a RawPath).
// Built whole in InitializeAsync and SWAPPED in: the retained poll engine keeps calling
@@ -196,9 +229,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// release the previous device states' loops + clients BEFORE they are overwritten (a live driver can be
// re-initialised without a teardown first). Not ShutdownAsync: that also drops _poll
// and subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init).
//
// The generation is taken FIRST: an initialise the runtime abandons (it timed out and moved on to a
// shutdown) is superseded by that shutdown's bump, and checks for it before publishing anything.
var generation = Interlocked.Increment(ref _lifecycleGeneration);
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
ResetAlarmHealth();
Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null));
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Initializing, null, null));
var devices = new Dictionary<string, DeviceState>(StringComparer.OrdinalIgnoreCase);
try
{
// adopt a changed config. Options and the backend client factory are re-derived by ONE
@@ -222,7 +260,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var addr = FocasHostAddress.TryParse(device.HostAddress)
?? throw new InvalidOperationException(
$"FOCAS device has invalid HostAddress '{device.HostAddress}' — expected 'focas://{{ip}}[:{{port}}]'.");
_devices[device.HostAddress] = new DeviceState(addr, device);
devices[device.HostAddress] = new DeviceState(addr, device);
}
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
// blob is mapped by the pure factory; the entry's WriteIdempotent flag + DeviceName are
@@ -256,7 +294,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
throw new InvalidOperationException(
$"FOCAS tag '{entry.RawPath}' references device '{entry.DeviceName}' " +
$"which is not in the Devices list. Check for a typo (e.g. wrong port or hostname).");
if (!_devices.TryGetValue(deviceHost, out var device))
if (!devices.TryGetValue(deviceHost, out var device))
throw new InvalidOperationException(
$"FOCAS tag '{entry.RawPath}' references device '{entry.DeviceName}' " +
$"which is not in the Devices list. Check for a typo (e.g. wrong port or hostname).");
@@ -309,75 +347,135 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// above; reusing the parsed record avoids per-tick allocs on subscription pollers.
parsedAddresses[entry.RawPath] = parsed;
}
// Publish the skip tally only once the whole table is built. An unknown-device throw above
// leaves the previous snapshot standing rather than a truncated one that would read as
// "we now skip fewer tags".
_skippedTags.Commit();
Volatile.Write(ref _parsedAddressesByTagName, parsedAddresses);
Volatile.Write(ref _tagsByRawPath, tagsByRawPath);
Volatile.Write(ref _alarmTags, alarmTags);
if (_options.Probe.Enabled)
// Publish everything at once, and only if no later lifecycle call has superseded this one. The check
// and the publish share the lock with the shutdown's swap-out, so either the shutdown sees (and
// releases) this table, or this initialise sees the shutdown and publishes nothing.
lock (_lifecycleLock)
{
foreach (var state in _devices.Values)
{
state.ProbeCts = new CancellationTokenSource();
var ct = state.ProbeCts.Token;
state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
}
if (generation != Volatile.Read(ref _lifecycleGeneration))
throw new OperationCanceledException(
$"FOCAS initialise of {_driverInstanceId} was superseded by a later shutdown/initialise; nothing was started.");
if (_options.HandleRecycle.Enabled)
{
foreach (var state in _devices.Values)
{
state.RecycleCts = new CancellationTokenSource();
var ct = state.RecycleCts.Token;
state.RecycleTask = Task.Run(() => RecycleLoopAsync(state, ct), ct);
}
}
// See _fixedTreeActive: an authored FIXED: tag arms the loop on its own, because the
// config flag defaults off and the operator who committed those tags has already said
// what they want.
_fixedTreeActive = _options.FixedTree.Enabled
|| _parsedAddressesByTagName.Values.Any(a => a.Kind == FocasAreaKind.Fixed);
if (_fixedTreeActive)
{
foreach (var state in _devices.Values)
{
state.FixedTreeCts = new CancellationTokenSource();
var ct = state.FixedTreeCts.Token;
state.FixedTreeTask = Task.Run(() => FixedTreeLoopAsync(state, ct), ct);
}
// Publish the skip tally only once the whole table is built. An unknown-device throw above
// leaves the previous snapshot standing rather than a truncated one that would read as
// "we now skip fewer tags".
_skippedTags.Commit();
Volatile.Write(ref _parsedAddressesByTagName, parsedAddresses);
Volatile.Write(ref _tagsByRawPath, tagsByRawPath);
Volatile.Write(ref _alarmTags, alarmTags);
// See _fixedTreeActive: an authored FIXED: tag arms the loop on its own, because the
// config flag defaults off and the operator who committed those tags has already said
// what they want.
_fixedTreeActive = _options.FixedTree.Enabled
|| parsedAddresses.Values.Any(a => a.Kind == FocasAreaKind.Fixed);
Volatile.Write(ref _devices, devices);
StartDeviceLoops(devices.Values);
}
// Restart the alarm poll a re-initialise paused, against the tables just rebuilt. A
// driver with no subscription yet starts nothing — the actor subscribes once it is Connected.
await _alarmProjection.ResumeAsync().ConfigureAwait(false);
// A shutdown that landed while the resume ran owns the projection now: put it back the way it left it.
if (generation != Volatile.Read(ref _lifecycleGeneration))
{
await _alarmProjection.PauseAsync().ConfigureAwait(false);
throw new OperationCanceledException(
$"FOCAS initialise of {_driverInstanceId} was superseded while resuming the alarm poll.");
}
Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
}
catch (OperationCanceledException) when (generation != Volatile.Read(ref _lifecycleGeneration))
{
// Superseded: whatever this attempt built but never published is released here (a published table
// is the superseding call's to release); health belongs to that call too.
if (!ReferenceEquals(Devices, devices)) await ReleaseTableAsync(devices.Values).ConfigureAwait(false);
throw;
}
catch (Exception ex)
{
Volatile.Write(ref _health, new DriverHealth(DriverState.Faulted, null, ex.Message));
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Faulted, null, ex.Message));
throw;
}
}
/// <summary>Writes <paramref name="health"/> unless a later lifecycle call has taken over.</summary>
private void WriteHealthIfCurrent(int generation, DriverHealth health)
{
lock (_lifecycleLock)
{
if (generation == Volatile.Read(ref _lifecycleGeneration)) Volatile.Write(ref _health, health);
}
}
/// <summary>Starts the probe / recycle / fixed-tree loops for a freshly built table. Caller holds the lifecycle lock.</summary>
private void StartDeviceLoops(IEnumerable<DeviceState> states)
{
foreach (var state in states)
{
if (_options.Probe.Enabled)
{
state.ProbeCts = new CancellationTokenSource();
var ct = state.ProbeCts.Token;
state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
if (_options.HandleRecycle.Enabled)
{
state.RecycleCts = new CancellationTokenSource();
var ct = state.RecycleCts.Token;
state.RecycleTask = Task.Run(() => RecycleLoopAsync(state, ct), ct);
}
if (_fixedTreeActive)
{
state.FixedTreeCts = new CancellationTokenSource();
var ct = state.FixedTreeCts.Token;
state.FixedTreeTask = Task.Run(() => FixedTreeLoopAsync(state, ct), ct);
}
}
}
/// <summary>
/// Cancels, bounded-joins and disposes every existing device state (probe / recycle / fixed-tree
/// loops + client) and the alarm projection, then clears the table. Device part of
/// <see cref="ShutdownAsync"/> only (no poll / subscription teardown).
/// Swaps the live device table out (to empty) and releases it: cancels, bounded-joins and disposes every
/// device state's loops and client, after pausing the alarm projection. Device part of
/// <see cref="ShutdownAsync"/> only (no poll / subscription teardown). Also waits for any release an
/// overlapping lifecycle call started earlier, so it never returns while that one is still running.
/// </summary>
private async Task ReleaseDeviceStatesAsync()
{
// Pause, not dispose: the subscription and what the server was told survive the rebuild.
await _alarmProjection.PauseAsync().ConfigureAwait(false);
if (_devices.Count == 0) return;
var loops = new List<Task>();
foreach (var state in _devices.Values)
IReadOnlyDictionary<string, DeviceState> old;
Task prior;
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
lock (_lifecycleLock)
{
old = _devices;
Volatile.Write(ref _devices, EmptyDevices);
prior = _releaseTail;
_releaseTail = done.Task;
}
try
{
if (old.Count > 0) await ReleaseTableAsync(old.Values).ConfigureAwait(false);
await prior.ConfigureAwait(false);
}
finally { done.TrySetResult(); }
}
/// <summary>
/// Retires and tears down one swapped-out table: retire every device first (so a connect still in flight
/// disposes what it opens), cancel every loop, join them (bounded), then dispose the CTSs and — under each
/// device's connect gate — its client.
/// </summary>
private async Task ReleaseTableAsync(IEnumerable<DeviceState> table)
{
var states = table.ToList();
var loops = new List<Task>();
foreach (var state in states)
{
state.Retire();
// Cancel-then-dispose can race in tight shutdown loops; swallowing is intentional
// but we log the cause so a noisy shutdown leaves a Debug trace.
try { state.ProbeCts?.Cancel(); }
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling probe CTS for {Host} failed", state.Options.HostAddress); }
try { state.RecycleCts?.Cancel(); }
@@ -388,15 +486,18 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (state.RecycleTask is { } rt) loops.Add(rt);
if (state.FixedTreeTask is { } ft) loops.Add(ft);
}
// Cancel every loop first, JOIN them (bounded), and only then dispose the CTSs and the
// client. Fire-and-forget loops used to outlive shutdown: one parked in EnsureConnectedAsync could
// finish its connect AFTER DisposeClient and leave a live socket on a shut-down driver.
if (loops.Count > 0)
{
var all = Task.WhenAll(loops);
var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false);
if (finished != all)
_logger.LogWarning("FOCAS re-initialise: {Count} background loop(s) did not stop within {Timeout}; proceeding",
_logger.LogWarning("FOCAS teardown: {Count} background loop(s) did not stop within {Timeout}; proceeding",
loops.Count, LoopJoinTimeout);
}
foreach (var state in _devices.Values)
foreach (var state in states)
{
state.ProbeCts?.Dispose();
state.ProbeCts = null;
@@ -407,9 +508,22 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
state.FixedTreeCts?.Dispose();
state.FixedTreeCts = null;
state.FixedTreeTask = null;
state.DisposeClient();
await DisposeClientGatedAsync(state).ConfigureAwait(false);
}
_devices.Clear();
}
/// <summary>
/// Disposes a device's client under its <see cref="DeviceState.ConnectGate"/>, so it can neither null a
/// client a concurrent <see cref="EnsureConnectedAsync"/> just installed nor race that connect. The wait
/// is bounded: a connect wedged past <see cref="LoopJoinTimeout"/> may not wedge teardown too (the device
/// is retired, so that connect disposes its own client when it finally returns).
/// </summary>
private async Task DisposeClientGatedAsync(DeviceState state)
{
var entered = await state.ConnectGate.WaitAsync(LoopJoinTimeout).ConfigureAwait(false);
try { state.DisposeClient(); }
catch (Exception ex) { _logger.LogDebug(ex, "FOCAS client dispose failed for {Host}", state.Options.HostAddress); }
finally { if (entered) state.ConnectGate.Release(); }
}
/// <inheritdoc />
@@ -423,58 +537,28 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
internal static readonly TimeSpan LoopJoinTimeout = TimeSpan.FromSeconds(5);
/// <inheritdoc />
// Safe to overlap with itself (Lane A disposes beside a still-running shutdown — the second call waits for
// the first's teardown via _releaseTail) and with an abandoned InitializeAsync (the generation bump below
// supersedes it, so it publishes nothing).
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
var generation = Interlocked.Increment(ref _lifecycleGeneration);
await _poll.DisposeAsync().ConfigureAwait(false);
// Pause, not dispose — see _alarmProjection. A later InitializeAsync resumes it.
await _alarmProjection.PauseAsync().ConfigureAwait(false);
// cancel every loop first, JOIN them (bounded), and only then dispose the CTSs and the
// client. Fire-and-forget loops used to outlive shutdown: one parked in EnsureConnectedAsync could
// finish its connect AFTER DisposeClient and leave a live socket on a shut-down driver.
var loops = new List<Task>();
foreach (var state in _devices.Values)
// Pauses (not disposes) the alarm projection — see _alarmProjection; a later InitializeAsync resumes it.
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
lock (_lifecycleLock)
{
// Cancel-then-dispose can race in tight shutdown loops; swallowing is intentional
// but we now log the cause so a noisy shutdown leaves a Debug trace.
try { state.ProbeCts?.Cancel(); }
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling probe CTS for {Host} failed", state.Options.HostAddress); }
try { state.RecycleCts?.Cancel(); }
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling recycle CTS for {Host} failed", state.Options.HostAddress); }
try { state.FixedTreeCts?.Cancel(); }
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling fixed-tree CTS for {Host} failed", state.Options.HostAddress); }
if (state.ProbeTask is { } pt) loops.Add(pt);
if (state.RecycleTask is { } rt) loops.Add(rt);
if (state.FixedTreeTask is { } ft) loops.Add(ft);
// A later initialise owns the tables (and health) now — leave them alone.
if (generation != Volatile.Read(ref _lifecycleGeneration)) return;
Volatile.Write(ref _tagsByRawPath, new Dictionary<string, FocasTagDefinition>(StringComparer.Ordinal));
Volatile.Write(ref _alarmTags, new Dictionary<string, FocasAlarmTag>(StringComparer.Ordinal));
Volatile.Write(ref _parsedAddressesByTagName,
new ConcurrentDictionary<string, FocasAddress>(StringComparer.OrdinalIgnoreCase));
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
_fixedTreeActive = false;
ResetAlarmHealth();
Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null));
}
if (loops.Count > 0)
{
var all = Task.WhenAll(loops);
var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false);
if (finished != all)
_logger.LogWarning("FOCAS shutdown: {Count} background loop(s) did not stop within {Timeout}; proceeding with teardown",
loops.Count, LoopJoinTimeout);
}
foreach (var state in _devices.Values)
{
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
state.RecycleCts?.Dispose();
state.RecycleCts = null;
state.RecycleTask = null;
state.FixedTreeCts?.Dispose();
state.FixedTreeCts = null;
state.FixedTreeTask = null;
state.DisposeClient();
}
_devices.Clear();
Volatile.Write(ref _tagsByRawPath, new Dictionary<string, FocasTagDefinition>(StringComparer.Ordinal));
Volatile.Write(ref _alarmTags, new Dictionary<string, FocasAlarmTag>(StringComparer.Ordinal));
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
_parsedAddressesByTagName.Clear();
_fixedTreeActive = false;
ResetAlarmHealth();
Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null));
}
/// <inheritdoc />
@@ -499,12 +583,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
/// <summary>Gets the number of configured devices.</summary>
internal int DeviceCount => _devices.Count;
internal int DeviceCount => Devices.Count;
/// <summary>Test seam — the live device table snapshot (swapped whole, never mutated in place).</summary>
internal IReadOnlyDictionary<string, DeviceState> DeviceTable => Devices;
/// <summary>Gets the state of a device by host address.</summary>
/// <param name="hostAddress">The host address of the device.</param>
/// <returns>The device state if found; otherwise null.</returns>
internal DeviceState? GetDeviceState(string hostAddress) =>
_devices.TryGetValue(hostAddress, out var s) ? s : null;
Devices.TryGetValue(hostAddress, out var s) ? s : null;
/// <summary>Test seam — the resolved options the factory built this driver from.</summary>
internal FocasDriverOptions Options => _options;
@@ -592,7 +678,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
continue;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
if (!Devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
continue;
@@ -680,7 +766,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var reference = references[i];
if (_fixedTreeActive && TryReadFixedTree(reference, now) is not null) continue;
if (!_resolver.TryResolve(reference, out var def)) continue;
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
if (!Devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
var parsed = ResolveParsedAddress(def);
if (parsed.Kind != FocasAreaKind.Pmc || parsed.PmcLetter is null) continue;
(byDevice ??= []).TryAdd(device, []);
@@ -802,7 +888,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
continue;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
if (!Devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown);
continue;
@@ -890,7 +976,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Fixed-tree T1 — Identity + Axes subtrees, populated once per session
// from cnc_sysinfo + cnc_rdaxisname at init time and kept in DeviceState.
if (_fixedTreeActive
&& _devices.TryGetValue(device.HostAddress, out var state)
&& Devices.TryGetValue(device.HostAddress, out var state)
&& state.FixedTreeCache is { } cache)
{
var identity = deviceFolder.Folder("Identity", "Identity");
@@ -1142,7 +1228,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <inheritdoc />
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
[.. Devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
{
@@ -1510,7 +1596,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// </summary>
private DataValueSnapshot? TryReadFixedTree(string reference, DateTime now)
{
foreach (var state in _devices.Values)
foreach (var state in Devices.Values)
{
if (!reference.StartsWith(state.Options.HostAddress + "/", StringComparison.OrdinalIgnoreCase)) continue;
if (TryReadFixedTreeOnDevice(state, reference[(state.Options.HostAddress.Length + 1)..], now) is { } hit)
@@ -1776,7 +1862,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <returns>The device's active alarms.</returns>
internal async Task<IReadOnlyList<FocasActiveAlarm>> ReadDeviceAlarmsAsync(string hostAddress, CancellationToken ct)
{
if (!_devices.TryGetValue(hostAddress, out var state))
if (!Devices.TryGetValue(hostAddress, out var state))
throw new InvalidOperationException($"FOCAS device '{hostAddress}' is not configured.");
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
return await client.ReadAlarmsAsync(ct).ConfigureAwait(false);
@@ -1822,6 +1908,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false);
try
{
// A released device (re-initialise / shutdown) is never reconnected: whoever still holds it read
// the table before the swap, and a client opened now would be orphaned on a state nobody tears down.
if (device.IsRetired)
throw new ObjectDisposedException(nameof(DeviceState),
$"FOCAS device {device.Options.HostAddress} was released by a re-initialise or shutdown.");
// Another caller may have connected while this one waited.
if (device.Client is { IsConnected: true } connected) return connected;
@@ -1864,6 +1956,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
throw;
}
device.Backoff.RecordSuccess(); // recovery resets the window immediately
// Retired WHILE this connect ran (the release waits on this gate only up to its bound): the connect
// is ours to undo, or it leaves a live session on a shut-down driver.
if (device.IsRetired)
{
client.Dispose();
if (ReferenceEquals(device.Client, client)) device.Client = null;
throw new ObjectDisposedException(nameof(DeviceState),
$"FOCAS device {device.Options.HostAddress} was released while connecting.");
}
return client;
}
finally { device.ConnectGate.Release(); }
@@ -2020,6 +2121,17 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// </summary>
public ConcurrentDictionary<int, double> LastSpindleMotorSpeeds { get; } = new();
private int _retired;
/// <summary>
/// True once a re-initialise / shutdown has released this device. A retired device never connects
/// again, and a connect that was already running when it retired disposes what it opened.
/// </summary>
public bool IsRetired => Volatile.Read(ref _retired) != 0;
/// <summary>Marks the device released. Idempotent.</summary>
public void Retire() => Volatile.Write(ref _retired, 1);
/// <summary>Disposes the FOCAS client instance.</summary>
public void DisposeClient()
{