fix(driver-ablegacy): resolve Medium code-review finding (Driver.AbLegacy-007)
Runtimes and ParentRuntimes changed from Dictionary to ConcurrentDictionary. EnsureTagRuntimeAsync and EnsureParentRuntimeAsync now use a per-key GetCreationLock semaphore with a double-checked pattern: fast-path read requires no lock; slow-path create+initialize+store is serialised per key so a concurrent caller waits rather than creating a duplicate runtime that would be leaked when DisposeRuntimes runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -194,7 +194,7 @@ shared libplctag `Tag` handle is never touched by two threads at once.
|
||||
| Severity | Medium |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Location | `AbLegacyDriver.cs:411-438`, `AbLegacyDriver.cs:386-409` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `EnsureTagRuntimeAsync` and `EnsureParentRuntimeAsync` are
|
||||
check-then-act: `device.Runtimes.TryGetValue(...)` then, after `await
|
||||
@@ -209,7 +209,7 @@ corrupt internal state. `ParentRuntimes` has the identical pattern.
|
||||
`GetOrAdd`, or guard runtime creation under a per-device lock. Ensure the losing
|
||||
runtime of any race is disposed.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-22 — `Runtimes` and `ParentRuntimes` changed to `ConcurrentDictionary`; `EnsureTagRuntimeAsync` and `EnsureParentRuntimeAsync` now hold a per-key `GetCreationLock` semaphore around the double-checked create+initialize+store sequence so exactly one runtime is created per key and no race-loser is leaked.
|
||||
|
||||
### Driver.AbLegacy-008
|
||||
|
||||
|
||||
@@ -431,8 +431,18 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
private async Task<IAbLegacyTagRuntime> EnsureParentRuntimeAsync(
|
||||
AbLegacyDriver.DeviceState device, string parentName, CancellationToken ct)
|
||||
{
|
||||
// Fast path: runtime already cached.
|
||||
if (device.ParentRuntimes.TryGetValue(parentName, out var existing)) return existing;
|
||||
|
||||
// Slow path: serialise creation per key so concurrent callers don't each create a
|
||||
// runtime and one of them gets overwritten + leaked. Only one caller initialises; the
|
||||
// others find the entry on the second TryGetValue inside the lock.
|
||||
var creationLock = device.GetCreationLock($"parent:{parentName}");
|
||||
await creationLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (device.ParentRuntimes.TryGetValue(parentName, out existing)) return existing;
|
||||
|
||||
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
@@ -452,12 +462,26 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
device.ParentRuntimes[parentName] = runtime;
|
||||
return runtime;
|
||||
}
|
||||
finally
|
||||
{
|
||||
creationLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IAbLegacyTagRuntime> EnsureTagRuntimeAsync(
|
||||
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
|
||||
{
|
||||
// Fast path: runtime already cached.
|
||||
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
||||
|
||||
// Slow path: serialise creation per tag name so concurrent callers for the same tag
|
||||
// (server read path + poll loop) don't both create a runtime and one gets leaked.
|
||||
var creationLock = device.GetCreationLock($"tag:{def.Name}");
|
||||
await creationLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (device.Runtimes.TryGetValue(def.Name, out existing)) return existing;
|
||||
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address)
|
||||
?? throw new InvalidOperationException(
|
||||
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");
|
||||
@@ -481,6 +505,11 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
device.Runtimes[def.Name] = runtime;
|
||||
return runtime;
|
||||
}
|
||||
finally
|
||||
{
|
||||
creationLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
@@ -493,7 +522,16 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
public AbLegacyHostAddress ParsedAddress { get; } = parsedAddress;
|
||||
public AbLegacyDeviceOptions Options { get; } = options;
|
||||
public AbLegacyPlcFamilyProfile Profile { get; } = profile;
|
||||
public Dictionary<string, IAbLegacyTagRuntime> Runtimes { get; } =
|
||||
|
||||
/// <summary>
|
||||
/// Per-tag cached runtimes. <see cref="System.Collections.Concurrent.ConcurrentDictionary{TKey,TValue}"/>
|
||||
/// avoids the check-then-act race present on a plain <c>Dictionary</c>: two concurrent
|
||||
/// <c>EnsureTagRuntimeAsync</c> callers for the same key both miss the lookup on a
|
||||
/// plain dict and both create + store a runtime, leaking the loser. Access is guarded
|
||||
/// by a per-key creation semaphore (<see cref="GetCreationLock"/>) so exactly one
|
||||
/// runtime is created per tag name.
|
||||
/// </summary>
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, IAbLegacyTagRuntime> Runtimes { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
@@ -501,9 +539,20 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
/// parent address (bit suffix stripped) — e.g. writes to N7:0/3 + N7:0/5 share a
|
||||
/// single parent runtime for N7:0.
|
||||
/// </summary>
|
||||
public Dictionary<string, IAbLegacyTagRuntime> ParentRuntimes { get; } =
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, IAbLegacyTagRuntime> ParentRuntimes { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Per-key creation locks for <see cref="Runtimes"/> and <see cref="ParentRuntimes"/>.
|
||||
/// A caller holds this before the TryGetValue + Create + InitializeAsync + TryAdd
|
||||
/// sequence so that a concurrent caller waits rather than creating a duplicate runtime
|
||||
/// that would be leaked on <see cref="DisposeRuntimes"/>.
|
||||
/// </summary>
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _creationLocks = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public SemaphoreSlim GetCreationLock(string key) =>
|
||||
_creationLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
|
||||
|
||||
public SemaphoreSlim GetRmwLock(string parentName) =>
|
||||
|
||||
Reference in New Issue
Block a user