fix(ablegacy): never operate on a handle evicted while waiting for its gate

Review r0 M3. The indirect sweep disposed stale resolutions without holding
their gates, and any reader/writer that fetched a handle before waiting on its
gate could then use an instance a sibling had evicted and disposed - a
spurious comms fault and Degraded. The sweep now evicts under each key's gate,
and read/write re-resolve the handle once the gate is held (reconnecting
single-flight if it was evicted; a retired state never reconnects).

Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:22:17 -04:00
parent d758dd1e16
commit 16c2045cab
2 changed files with 59 additions and 1 deletions
@@ -680,6 +680,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
await opLock.WaitAsync(ct).ConfigureAwait(false);
try
{
runtime = await CurrentRuntimeUnderLockAsync(device, def, runtime, ct).ConfigureAwait(false);
await runtime.ReadAsync(ct).ConfigureAwait(false);
status = runtime.GetStatus();
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
@@ -942,6 +943,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
await opLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
runtime = await CurrentRuntimeUnderLockAsync(device, def, runtime, cancellationToken).ConfigureAwait(false);
if (arrayValue is not null) runtime.EncodeArray(arrayFileType, arrayValue);
else runtime.EncodeValue(def.DataType, parsed.BitIndex, w.Value);
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
@@ -1356,7 +1358,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
{
if (!key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|| string.Equals(key, resolvedName, StringComparison.OrdinalIgnoreCase)) continue;
EvictRuntime(device, key);
// Under the key's own gate (review 07 r0 M3), so no reader is mid-operation on the handle we dispose.
var gate = device.GetRuntimeLock(key);
await gate.WaitAsync(ct).ConfigureAwait(false);
try { EvictRuntime(device, key); } finally { gate.Release(); }
device.ForgetGates(key);
}
}
@@ -1601,6 +1606,23 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
.ConfigureAwait(false);
}
/// <summary>
/// Re-resolves a tag's runtime once its operation gate is HELD (review 07 r0 M3): the handle fetched before
/// the wait may since have been evicted and disposed (a failed sibling call, or an indirect index sweep), and
/// using it would fail spuriously as a comms fault. Returns the live cached handle, or connects a fresh one.
/// </summary>
private async Task<IAbLegacyTagRuntime> CurrentRuntimeUnderLockAsync(
DeviceState device, AbLegacyTagDefinition def, IAbLegacyTagRuntime fetched, CancellationToken ct)
{
if (device.Runtimes.TryGetValue(def.Name, out var live))
return live;
// Evicted between the fetch and the lock: a native runtime refused at init is cached too, so absence means
// it really was released. Connect again (single-flight per device).
// A retired (torn-down) state never reconnects: the in-flight call finishes on what it has.
if (device.IsRetired) return fetched;
return await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
}
/// <summary>
/// Evict the runtime for <paramref name="tagName"/> from the device's cache and dispose it so
/// the next read/write call re-creates and re-initializes a fresh libplctag handle. Called