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); + } }