diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
index ffc96f46..28b6fbe7 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
@@ -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);
}
+ ///
+ /// 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.
+ ///
+ private async Task 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);
+ }
+
///
/// Evict the runtime for from the device's cache and dispose it so
/// the next read/write call re-creates and re-initializes a fresh libplctag handle. Called
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs
index a982e84e..c65004f0 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs
@@ -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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
}