fix(ablegacy): dispose per-parent RMW locks on teardown (review symmetry)

DisposeRuntimes() now disposes and clears _rmwLocks, _creationLocks, and
_runtimeLocks so ReinitializeAsync/ShutdownAsync cycles don't orphan their
SemaphoreSlim instances. Mirrors the TwinCAT _bitRmwLocks fix already shipped.
This commit is contained in:
Joseph Doherty
2026-06-17 12:10:42 -04:00
parent 56ccaa797c
commit 098adf43d0
2 changed files with 52 additions and 0 deletions
@@ -928,6 +928,15 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
Runtimes.Clear();
foreach (var r in ParentRuntimes.Values) r.Dispose();
ParentRuntimes.Clear();
// Dispose + clear the per-parent RMW gates and the per-runtime/creation locks so
// ReinitializeAsync cycles don't orphan their SemaphoreSlim instances (each leaks a
// wait handle once contended).
foreach (var sem in _rmwLocks.Values) sem.Dispose();
_rmwLocks.Clear();
foreach (var sem in _creationLocks.Values) sem.Dispose();
_creationLocks.Clear();
foreach (var sem in _runtimeLocks.Values) sem.Dispose();
_runtimeLocks.Clear();
}
}
}
@@ -178,6 +178,49 @@ public sealed class AbLegacyBitRmwTests
Convert.ToInt32(factory.Tags["B3:0"].Value).ShouldBe(0xFF);
}
/// <summary>
/// Verifies that disposing (teardown) and re-initializing the driver does not orphan the
/// per-parent RMW-lock semaphores from the previous session. A bit write after the
/// reinit cycle must succeed, proving that <see cref="DeviceState.DisposeRuntimes"/> clears
/// the lock map so fresh semaphores are created for the new session rather than re-using
/// (already-disposed) handles from the old one.
/// </summary>
[Fact]
public async Task RMW_locks_are_disposed_and_fresh_after_driver_reinitialise()
{
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 },
};
var opts = new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbLegacyTagDefinition("Bit2", "ab://10.0.0.5/1,0", "N7:0/2", AbLegacyDataType.Bit)],
Probe = new AbLegacyProbeOptions { Enabled = false },
};
// First session: init + bit write — seeds an RMW lock entry for "N7:0".
var drv = new AbLegacyDriver(opts, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var firstResult = await drv.WriteAsync([new WriteRequest("Bit2", true)], CancellationToken.None);
firstResult.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
// Teardown — DisposeRuntimes must dispose + clear the RMW lock map.
await drv.DisposeAsync();
// Second session: re-init on a fresh driver instance (mirrors ReinitializeAsync behavior).
factory.Tags.Clear();
var drv2 = new AbLegacyDriver(opts, "drv-1", factory);
await drv2.InitializeAsync("{}", CancellationToken.None);
var secondResult = await drv2.WriteAsync([new WriteRequest("Bit2", false)], CancellationToken.None);
// Must succeed — a disposed/orphaned semaphore would throw ObjectDisposedException.
secondResult.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0);
await drv2.DisposeAsync();
}
/// <summary>
/// A non-zero libplctag status returned by the parent-word write is surfaced as a non-Good
/// OPC UA StatusCode — the PCCC device rejection propagates to the caller.