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 |
|
| Severity | Medium |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Location | `AbLegacyDriver.cs:411-438`, `AbLegacyDriver.cs:386-409` |
|
| Location | `AbLegacyDriver.cs:411-438`, `AbLegacyDriver.cs:386-409` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `EnsureTagRuntimeAsync` and `EnsureParentRuntimeAsync` are
|
**Description:** `EnsureTagRuntimeAsync` and `EnsureParentRuntimeAsync` are
|
||||||
check-then-act: `device.Runtimes.TryGetValue(...)` then, after `await
|
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
|
`GetOrAdd`, or guard runtime creation under a per-device lock. Ensure the losing
|
||||||
runtime of any race is disposed.
|
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
|
### Driver.AbLegacy-008
|
||||||
|
|
||||||
|
|||||||
@@ -431,55 +431,84 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
private async Task<IAbLegacyTagRuntime> EnsureParentRuntimeAsync(
|
private async Task<IAbLegacyTagRuntime> EnsureParentRuntimeAsync(
|
||||||
AbLegacyDriver.DeviceState device, string parentName, CancellationToken ct)
|
AbLegacyDriver.DeviceState device, string parentName, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// Fast path: runtime already cached.
|
||||||
if (device.ParentRuntimes.TryGetValue(parentName, out var existing)) return existing;
|
if (device.ParentRuntimes.TryGetValue(parentName, out var existing)) return existing;
|
||||||
|
|
||||||
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
// Slow path: serialise creation per key so concurrent callers don't each create a
|
||||||
Gateway: device.ParsedAddress.Gateway,
|
// runtime and one of them gets overwritten + leaked. Only one caller initialises; the
|
||||||
Port: device.ParsedAddress.Port,
|
// others find the entry on the second TryGetValue inside the lock.
|
||||||
CipPath: device.ParsedAddress.CipPath,
|
var creationLock = device.GetCreationLock($"parent:{parentName}");
|
||||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
await creationLock.WaitAsync(ct).ConfigureAwait(false);
|
||||||
TagName: parentName,
|
|
||||||
Timeout: _options.Timeout));
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
if (device.ParentRuntimes.TryGetValue(parentName, out existing)) return existing;
|
||||||
|
|
||||||
|
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
||||||
|
Gateway: device.ParsedAddress.Gateway,
|
||||||
|
Port: device.ParsedAddress.Port,
|
||||||
|
CipPath: device.ParsedAddress.CipPath,
|
||||||
|
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||||
|
TagName: parentName,
|
||||||
|
Timeout: _options.Timeout));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
runtime.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
device.ParentRuntimes[parentName] = runtime;
|
||||||
|
return runtime;
|
||||||
}
|
}
|
||||||
catch
|
finally
|
||||||
{
|
{
|
||||||
runtime.Dispose();
|
creationLock.Release();
|
||||||
throw;
|
|
||||||
}
|
}
|
||||||
device.ParentRuntimes[parentName] = runtime;
|
|
||||||
return runtime;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IAbLegacyTagRuntime> EnsureTagRuntimeAsync(
|
private async Task<IAbLegacyTagRuntime> EnsureTagRuntimeAsync(
|
||||||
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
|
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// Fast path: runtime already cached.
|
||||||
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
||||||
|
|
||||||
var parsed = AbLegacyAddress.TryParse(def.Address)
|
// Slow path: serialise creation per tag name so concurrent callers for the same tag
|
||||||
?? throw new InvalidOperationException(
|
// (server read path + poll loop) don't both create a runtime and one gets leaked.
|
||||||
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");
|
var creationLock = device.GetCreationLock($"tag:{def.Name}");
|
||||||
|
await creationLock.WaitAsync(ct).ConfigureAwait(false);
|
||||||
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
|
||||||
Gateway: device.ParsedAddress.Gateway,
|
|
||||||
Port: device.ParsedAddress.Port,
|
|
||||||
CipPath: device.ParsedAddress.CipPath,
|
|
||||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
|
||||||
TagName: parsed.ToLibplctagName(),
|
|
||||||
Timeout: _options.Timeout));
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
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}'.");
|
||||||
|
|
||||||
|
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
||||||
|
Gateway: device.ParsedAddress.Gateway,
|
||||||
|
Port: device.ParsedAddress.Port,
|
||||||
|
CipPath: device.ParsedAddress.CipPath,
|
||||||
|
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||||
|
TagName: parsed.ToLibplctagName(),
|
||||||
|
Timeout: _options.Timeout));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
runtime.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
device.Runtimes[def.Name] = runtime;
|
||||||
|
return runtime;
|
||||||
}
|
}
|
||||||
catch
|
finally
|
||||||
{
|
{
|
||||||
runtime.Dispose();
|
creationLock.Release();
|
||||||
throw;
|
|
||||||
}
|
}
|
||||||
device.Runtimes[def.Name] = runtime;
|
|
||||||
return runtime;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
@@ -493,7 +522,16 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
public AbLegacyHostAddress ParsedAddress { get; } = parsedAddress;
|
public AbLegacyHostAddress ParsedAddress { get; } = parsedAddress;
|
||||||
public AbLegacyDeviceOptions Options { get; } = options;
|
public AbLegacyDeviceOptions Options { get; } = options;
|
||||||
public AbLegacyPlcFamilyProfile Profile { get; } = profile;
|
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);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
/// <summary>
|
/// <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
|
/// parent address (bit suffix stripped) — e.g. writes to N7:0/3 + N7:0/5 share a
|
||||||
/// single parent runtime for N7:0.
|
/// single parent runtime for N7:0.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, IAbLegacyTagRuntime> ParentRuntimes { get; } =
|
public System.Collections.Concurrent.ConcurrentDictionary<string, IAbLegacyTagRuntime> ParentRuntimes { get; } =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
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();
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
|
||||||
|
|
||||||
public SemaphoreSlim GetRmwLock(string parentName) =>
|
public SemaphoreSlim GetRmwLock(string parentName) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user