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
@@ -219,4 +219,40 @@ public sealed class AbLegacyHealthEvidenceTests
await drv.ShutdownAsync(CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
private sealed class DisposeAwareTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
{
public override Task ReadAsync(CancellationToken ct) =>
Disposed ? throw new ObjectDisposedException("tag") : base.ReadAsync(ct);
}
/// <summary>
/// Review 07 r0 M3: a reader that fetched a handle and then waited on its gate while another path evicted and
/// disposed it used to read the disposed instance — a spurious comms fault and Degraded. It now re-resolves
/// the handle once the gate is held.
/// </summary>
[Fact]
public async Task A_handle_evicted_while_a_reader_waited_for_its_gate_is_re_resolved()
{
var f = new SwitchableFactory { Make = p => new DisposeAwareTag(p) { Value = 4 } };
var drv = NewDriver(f, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var state = drv.GetDeviceState(Host)!;
var first = f.Tags["N7:0"];
var gate = state.GetRuntimeLock("X");
await gate.WaitAsync();
var read = drv.ReadAsync(["X"], CancellationToken.None); // fetches `first`, then parks on the gate
await Task.Delay(30);
state.Runtimes.TryRemove("X", out var evicted).ShouldBeTrue();
evicted!.Dispose();
gate.Release();
var r = await read;
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
f.Tags["N7:0"].ShouldNotBeSameAs(first); // a fresh handle served it
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(CancellationToken.None);
}
}