fix(abcip): a reader racing a re-init neither leaks a runtime nor degrades
Found by the H13 stress test (200 re-inits under a concurrent reader): a read that resolved the device just before the table swap could cold-create a runtime into the retired state after its teardown had disposed it (leaked native handle / lease), and could hit a per-tag SemaphoreSlim the teardown had disposed, which the generic catch reported as a comms fault (Degraded). - DeviceState.Retire marks the state retired before disposing handles; each handle is removed-then-disposed exactly once. A creator re-checks the flag after its TryAdd (fenced, Dekker-paired with Retire) and disposes the orphan. - A failure on a retired device is the teardown's doing: BadCommunicationError for that one call, no evict, no health signal. - Per-tag/RMW gates are no longer disposed (async-only waits hold no handle), matching the ConnectGate rationale. Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -561,7 +561,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
// Never-published states hold no probe loop and no runtime yet; dispose for symmetry.
|
||||
if (builtDevices is not null)
|
||||
foreach (var state in builtDevices.Values) state.DisposeHandles();
|
||||
foreach (var state in builtDevices.Values) state.Retire();
|
||||
if (ex is OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("AbCip driver {DriverInstanceId} initialization abandoned: {Reason}",
|
||||
@@ -631,7 +631,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
state.ProbeCts?.Dispose();
|
||||
state.ProbeCts = null;
|
||||
state.ProbeTask = null;
|
||||
state.DisposeHandles();
|
||||
state.Retire();
|
||||
}
|
||||
return generation;
|
||||
}
|
||||
@@ -1155,6 +1155,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, ate.Message);
|
||||
return ReadOutcome.Refused;
|
||||
}
|
||||
catch (Exception) when (device.Retired)
|
||||
{
|
||||
// A re-init / shutdown retired this device under the read: its handles were disposed by the teardown, not
|
||||
// lost on the wire — no evict (the teardown owns them), no health signal.
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadCommunicationError, null, now);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Transport exception — evict so the next read creates a fresh handle. An AbTransportException
|
||||
@@ -1271,6 +1278,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
WarnRefusalOnce(parent.Name, "whole-UDT read", group.ParentName, parent.DeviceHostAddress, ate.Message);
|
||||
return ReadOutcome.Refused;
|
||||
}
|
||||
catch (Exception) when (device.Retired)
|
||||
{
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
EvictRuntime(device, parent.Name);
|
||||
@@ -1492,6 +1504,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
results[i] = new WriteResult(AbCipStatusMapper.MapStatus(ate.Status));
|
||||
WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, ate.Message);
|
||||
}
|
||||
catch (Exception) when (device.Retired)
|
||||
{
|
||||
// The device was retired under this write by a re-init / shutdown — not a comms fault.
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Transport / wire error — evict so the next write creates a fresh handle.
|
||||
@@ -1554,6 +1571,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
DeviceState device, string parentTagName, CancellationToken ct)
|
||||
{
|
||||
if (device.ParentRuntimes.TryGetValue(parentTagName, out var existing)) return existing;
|
||||
ThrowIfRetired(device);
|
||||
|
||||
var runtime = _tagFactory.Create(device.BuildCreateParams(parentTagName, _options.Timeout));
|
||||
try
|
||||
@@ -1567,10 +1585,25 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
// Two concurrent callers can both miss the cache + both initialize a runtime; only the
|
||||
// first TryAdd wins. Dispose the loser so it doesn't leak a native tag handle.
|
||||
if (device.ParentRuntimes.TryAdd(parentTagName, runtime))
|
||||
return runtime;
|
||||
runtime.Dispose();
|
||||
return device.ParentRuntimes[parentTagName];
|
||||
if (!device.ParentRuntimes.TryAdd(parentTagName, runtime))
|
||||
{
|
||||
runtime.Dispose();
|
||||
return device.ParentRuntimes[parentTagName];
|
||||
}
|
||||
Interlocked.MemoryBarrier(); // pairs with DeviceState.Retire
|
||||
if (device.Retired)
|
||||
{
|
||||
if (device.ParentRuntimes.TryRemove(parentTagName, out var orphan)) orphan.Dispose();
|
||||
ThrowIfRetired(device);
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/// <summary>Throws when a teardown has retired <paramref name="device"/> (a reader raced a re-init / shutdown).</summary>
|
||||
private static void ThrowIfRetired(DeviceState device)
|
||||
{
|
||||
if (device.Retired)
|
||||
throw new ObjectDisposedException(nameof(DeviceState), $"AbCip device {device.Options.HostAddress} was retired by a re-initialization or shutdown.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1607,6 +1640,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// Re-check under the gate: a sibling cold tag may have connected (or a concurrent write
|
||||
// may have materialised this very handle) while this caller waited.
|
||||
if (device.Runtimes.TryGetValue(def.Name, out var raced)) return raced;
|
||||
ThrowIfRetired(device);
|
||||
|
||||
// Inside an open backoff window a data-path caller fails fast — no runtime
|
||||
// create, no Forward-Open. The malformed-path config error above still surfaces (a permanent
|
||||
@@ -1662,10 +1696,20 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// The gate makes a cold create single-flight, but a concurrent EvictRuntime (a failed read
|
||||
// on this handle) can still land between the re-check and the add; only the first TryAdd
|
||||
// wins. Dispose the loser so it doesn't leak a native tag handle.
|
||||
if (device.Runtimes.TryAdd(def.Name, runtime))
|
||||
return runtime;
|
||||
runtime.Dispose();
|
||||
return device.Runtimes[def.Name];
|
||||
if (!device.Runtimes.TryAdd(def.Name, runtime))
|
||||
{
|
||||
runtime.Dispose();
|
||||
return device.Runtimes[def.Name];
|
||||
}
|
||||
// A teardown that retired this state while we created would never see this handle: take it back out
|
||||
// (TryRemove — exactly one of us disposes it) rather than leak it into a dead table.
|
||||
Interlocked.MemoryBarrier(); // pairs with DeviceState.Retire
|
||||
if (device.Retired)
|
||||
{
|
||||
if (device.Runtimes.TryRemove(def.Name, out var orphan)) orphan.Dispose();
|
||||
ThrowIfRetired(device);
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -2230,19 +2274,38 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
AuthoredType: authoredType,
|
||||
UseConnectedMessaging: Profile.SupportsConnectedMessaging);
|
||||
|
||||
/// <summary>Disposes all runtime tag handles and clears the caches.</summary>
|
||||
private volatile bool _retired;
|
||||
|
||||
/// <summary>
|
||||
/// True once a teardown retired this state. A reader that resolved the device just before the swap can
|
||||
/// still be running against it: its failures are the teardown's doing, not the device's (no Degraded), and a
|
||||
/// runtime it cold-creates afterwards is disposed by the creator instead of leaking into a dead table.
|
||||
/// </summary>
|
||||
public bool Retired => _retired;
|
||||
|
||||
/// <summary>Marks the state retired, then disposes every runtime handle (see <see cref="DisposeHandles"/>).</summary>
|
||||
public void Retire()
|
||||
{
|
||||
_retired = true;
|
||||
// Dekker pairing with the creator's post-add check: the flag is globally visible before the tables are
|
||||
// enumerated, and the creator fences between its add and its read of the flag — so at least one of the two
|
||||
// sees the other's write and the handle is disposed.
|
||||
Interlocked.MemoryBarrier();
|
||||
DisposeHandles();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes every runtime tag handle, each exactly once (removed before it is disposed, so a racing
|
||||
/// creator's own cleanup cannot dispose it a second time). The per-runtime / RMW gates are deliberately NOT
|
||||
/// disposed, for the same reason as <see cref="ConnectGate"/>: an in-flight reader of a retired state may
|
||||
/// still release one, and a <see cref="SemaphoreSlim"/> only awaited asynchronously holds no wait handle.
|
||||
/// </summary>
|
||||
public void DisposeHandles()
|
||||
{
|
||||
foreach (var r in Runtimes.Values) r.Dispose();
|
||||
Runtimes.Clear();
|
||||
foreach (var r in ParentRuntimes.Values) r.Dispose();
|
||||
ParentRuntimes.Clear();
|
||||
// Dispose + clear the per-runtime/RMW gates so ReinitializeAsync cycles don't orphan
|
||||
// their SemaphoreSlim instances (each leaks a wait handle once contended).
|
||||
foreach (var sem in _runtimeLocks.Values) sem.Dispose();
|
||||
_runtimeLocks.Clear();
|
||||
foreach (var sem in _rmwLocks.Values) sem.Dispose();
|
||||
_rmwLocks.Clear();
|
||||
foreach (var key in Runtimes.Keys)
|
||||
if (Runtimes.TryRemove(key, out var r)) r.Dispose();
|
||||
foreach (var key in ParentRuntimes.Keys)
|
||||
if (ParentRuntimes.TryRemove(key, out var r)) r.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user