fix(ablegacy): any answer restores Healthy; writes feed health and the backoff
Review r0 I2 + M1/M2/M4/M7/M10. - I2: one health writer (ApplyHealth) for reads AND writes, worst wins per call: a comms fault => Degraded; otherwise any answer (Good value, device refusal, Good or refused write) => Healthy; LastSuccessfulRead advances on any answer. A refusal-only read used to leave a recovered driver Degraded, and writes never touched health at all. - M10: a no-reply write STATUS now degrades like the exception path did; a Good/refused write records an answer on the device's backoff, a no-reply one a fault (bit RMW included — it now reports its raw status). - M2: a write inside the device's backoff window fails fast with BadCommunicationError even when its handle is cached. - M1: ShutdownAsync/Dispose write their final Unknown only while the epoch they retired is still current (a newer initialise owns health otherwise). - M4: an indirect bit write releases the previous index value's parent-word handle (the per-word RMW gate is deliberately kept: removing a gate another writer may hold would let two RMWs of one word race). - M7: backoff decisions read an injectable Clock; the isolation tests drop their stopwatch asserts and cross the window on a manual clock. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -177,7 +177,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
// allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + runtimes BEFORE the
|
||||
// new table is published, or they are orphaned beyond the reach of ShutdownAsync. Deliberately NOT
|
||||
// ShutdownAsync: that also drops _poll and every subscription, which are owned by the shutdown path (the actor re-subscribes after every successful init).
|
||||
await ReleaseDeviceStatesAsync(RetireTables(bumpEpoch: false), "re-initialise").ConfigureAwait(false);
|
||||
await ReleaseDeviceStatesAsync(RetireTables(bumpEpoch: false).States, "re-initialise").ConfigureAwait(false);
|
||||
SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Initializing, null, null));
|
||||
_logger.LogInformation(
|
||||
"AbLegacy driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
|
||||
@@ -328,8 +328,9 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
/// is clearing.
|
||||
/// </summary>
|
||||
/// <param name="bumpEpoch">True for a teardown: supersedes any in-flight initialise.</param>
|
||||
/// <returns>The device states that were published.</returns>
|
||||
private List<DeviceState> RetireTables(bool bumpEpoch)
|
||||
/// <returns>The device states that were published, and the epoch as of the retirement (a teardown writes its final
|
||||
/// health only while that epoch is still current — review 07 r0 M1).</returns>
|
||||
private (List<DeviceState> States, long Epoch) RetireTables(bool bumpEpoch)
|
||||
{
|
||||
lock (_lifecycleLock)
|
||||
{
|
||||
@@ -340,7 +341,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
}
|
||||
var retired = _devices.Values.ToList();
|
||||
_devices = new Dictionary<string, DeviceState>(StringComparer.OrdinalIgnoreCase);
|
||||
return retired;
|
||||
return (retired, _lifecycleEpoch);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,15 +402,19 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Unpublish FIRST, so an in-flight initialise is superseded and new reads resolve nothing.
|
||||
var retired = RetireTables(bumpEpoch: true);
|
||||
var (retired, teardownEpoch) = RetireTables(bumpEpoch: true);
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
// Cancel the probe loops, JOIN them (bounded), then dispose the CTSs and runtimes.
|
||||
// The loop used to be fire-and-forget, so one parked in a probe-runtime create/read could
|
||||
// outlive shutdown and keep a libplctag handle alive past retirement.
|
||||
await ReleaseDeviceStatesAsync(retired, "shutdown").ConfigureAwait(false);
|
||||
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
|
||||
// A newer InitializeAsync that ran while this teardown was still joining owns health now.
|
||||
lock (_lifecycleLock)
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
{
|
||||
if (teardownEpoch == _lifecycleEpoch)
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -445,6 +450,13 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
/// </summary>
|
||||
internal const int FaultsToMarkDeviceDown = 2;
|
||||
|
||||
/// <summary>
|
||||
/// The clock every per-device backoff decision reads (window open? when does it end?). Wall-clock UTC in
|
||||
/// production; a test substitutes a manual clock so the window can be crossed deterministically rather than by
|
||||
/// sleeping (review 07 r0 M7).
|
||||
/// </summary>
|
||||
internal Func<DateTime> Clock { get; set; } = () => DateTime.UtcNow;
|
||||
|
||||
/// <summary>One resolved reference queued for its device's pass.</summary>
|
||||
private sealed record ReadItem(int Index, string Reference, AbLegacyTagDefinition Def, AbLegacyAddress Parsed);
|
||||
|
||||
@@ -552,19 +564,30 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
anyAnswered |= o.AnyAnswered;
|
||||
fault ??= o.FaultMessage;
|
||||
}
|
||||
if (fault is null && !anyGood && !anyAnswered) return;
|
||||
ApplyHealth(epoch, fault, anyGood, anyAnswered, now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The single health write for a read or write call (worst wins): any comms fault ⇒ Degraded; otherwise any
|
||||
/// answer — a Good value OR a device refusal, both prove the device replied — ⇒ Healthy (review 07 r0 I2: a
|
||||
/// refusal-only pass used to leave a recovered driver Degraded). <c>LastSuccessfulRead</c> advances whenever
|
||||
/// any device answered. A call that reached no wire changes nothing; a Faulted (config-level) state is kept.
|
||||
/// Epoch-guarded so a late call cannot rewrite the health of a teardown or newer initialise.
|
||||
/// </summary>
|
||||
private void ApplyHealth(long epoch, string? fault, bool anyGood, bool anyAnswered, DateTime now)
|
||||
{
|
||||
if (fault is null && !anyGood && !anyAnswered) return;
|
||||
lock (_lifecycleLock)
|
||||
{
|
||||
if (epoch != _lifecycleEpoch) return;
|
||||
var current = _health;
|
||||
var lastRead = anyAnswered ? now : current.LastSuccessfulRead;
|
||||
if (fault is not null)
|
||||
_health = new DriverHealth(DriverState.Degraded, lastRead, fault);
|
||||
else if (anyGood)
|
||||
_health = new DriverHealth(DriverState.Healthy, lastRead, null);
|
||||
else
|
||||
if (current.State == DriverState.Faulted)
|
||||
_health = current with { LastSuccessfulRead = lastRead };
|
||||
else if (fault is not null)
|
||||
_health = new DriverHealth(DriverState.Degraded, lastRead, fault);
|
||||
else
|
||||
_health = new DriverHealth(DriverState.Healthy, lastRead, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,7 +600,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
while (work.TryDequeue(out var unit))
|
||||
{
|
||||
var members = unit.Block?.Members ?? [unit.Single!];
|
||||
if (device.IsThrottled(DateTime.UtcNow))
|
||||
if (device.IsThrottled(Clock()))
|
||||
{
|
||||
foreach (var m in members)
|
||||
results[m.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now);
|
||||
@@ -613,7 +636,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
break;
|
||||
case OpVerdict.Fault:
|
||||
outcome.FaultMessage ??= message;
|
||||
device.RecordCommsFault(DateTime.UtcNow);
|
||||
device.RecordCommsFault(Clock());
|
||||
break;
|
||||
case OpVerdict.Throttled:
|
||||
outcome.FaultMessage ??= message;
|
||||
@@ -785,6 +808,9 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
var epoch = CurrentEpoch;
|
||||
var results = new WriteResult[writes.Count];
|
||||
var devices = _devices; // one snapshot of the published table for the whole call
|
||||
// Writes feed health too (review 07 r0 I2): a Good or device-refused write proves the device answered; a
|
||||
// no-reply failure is a comms fault. Collected per call and written once, worst wins.
|
||||
var outcome = new DeviceReadOutcome();
|
||||
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
@@ -817,6 +843,15 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inside the device's backoff window nothing is dialled — the write fails fast exactly as a read
|
||||
// does, instead of paying the full Timeout on a handle that is still cached (review 07 r0 M2).
|
||||
if (device.IsThrottled(Clock()))
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
|
||||
outcome.FaultMessage ??= $"device {device.Options.HostAddress} is in its reconnect backoff window";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Indirect: resolve the index first (non-atomic with the write - the index may change
|
||||
// between the two PLC accesses), then write the concrete address through the normal path.
|
||||
if (parsed.IsIndirect)
|
||||
@@ -826,8 +861,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
{
|
||||
results[i] = new WriteResult(res.Status);
|
||||
if (res.TransportFault)
|
||||
SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"transport fault resolving indirect address for {w.FullReference}"));
|
||||
{
|
||||
device.RecordCommsFault(Clock());
|
||||
outcome.FaultMessage ??= $"transport fault resolving indirect address for {w.FullReference}";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
def = res.Def;
|
||||
@@ -872,8 +909,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
// may reject it; we surface that real PCCC status rather than pre-rejecting at the driver.
|
||||
else if (def.DataType == AbLegacyDataType.Bit && parsed.BitIndex is int bit)
|
||||
{
|
||||
results[i] = new WriteResult(
|
||||
await WriteBitInWordAsync(device, parsed, bit, w.Value, cancellationToken).ConfigureAwait(false));
|
||||
var (code, raw) = await WriteBitInWordAsync(device, def, parsed, bit, w.Value, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
results[i] = new WriteResult(code);
|
||||
RecordWriteStatus(device, def, raw, ref outcome);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -896,36 +935,23 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
opLock.Release();
|
||||
}
|
||||
|
||||
if (status == 0)
|
||||
{
|
||||
device.RecordAnswered();
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.Good);
|
||||
}
|
||||
else if (IsDeviceRefusal((AbStatus)status))
|
||||
{
|
||||
// The device answered and refused this write deterministically: keep the handle, warn once.
|
||||
device.RecordAnswered();
|
||||
WarnRefusalOnce(device, def, "write", $"device status {(AbStatus)status}");
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(status));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Evict the stale handle so the next call re-creates it (mirrors AbCip).
|
||||
EvictRuntime(device, def.Name);
|
||||
device.RecordCommsFault(DateTime.UtcNow);
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(status));
|
||||
}
|
||||
results[i] = new WriteResult(status == 0 ? AbLegacyStatusMapper.Good : AbLegacyStatusMapper.MapStatus(status));
|
||||
// A no-reply status evicts the stale handle so the next call re-creates it (mirrors AbCip).
|
||||
if (IsNoReply((AbStatus)status) && status != 0) EvictRuntime(device, def.Name);
|
||||
RecordWriteStatus(device, def, status, ref outcome);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (ConnectionThrottledException)
|
||||
catch (ConnectionThrottledException tex)
|
||||
{
|
||||
// The device is in its reconnect backoff window: fail fast, no wire, no new evidence.
|
||||
// The device is in its reconnect backoff window: fail fast, no wire, no new evidence for the backoff.
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
|
||||
outcome.FaultMessage ??= tex.Message;
|
||||
}
|
||||
catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status))
|
||||
{
|
||||
device.RecordAnswered();
|
||||
WarnRefusalOnce(device, def, "write", ex.Message);
|
||||
outcome.AnyAnswered = true;
|
||||
WarnRefusalOnce(device, def, "write", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status));
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(ex.Status));
|
||||
}
|
||||
catch (NotSupportedException nse)
|
||||
@@ -953,15 +979,38 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
{
|
||||
// Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip).
|
||||
EvictRuntime(device, def.Name);
|
||||
device.RecordCommsFault(DateTime.UtcNow);
|
||||
device.RecordCommsFault(Clock());
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
|
||||
SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message));
|
||||
outcome.FaultMessage ??= ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
ApplyHealth(epoch, outcome.FaultMessage, anyGood: outcome.AnyAnswered, anyAnswered: outcome.AnyAnswered, DateTime.UtcNow);
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a completed write's raw status into the call's health evidence and the device's backoff:
|
||||
/// Good or a device refusal = answered; a no-reply status = comms fault (it used to leave health untouched on
|
||||
/// this path while the exception path degraded — review 07 r0 M10); Unsupported = neutral.
|
||||
/// </summary>
|
||||
private void RecordWriteStatus(DeviceState device, AbLegacyTagDefinition def, int status, ref DeviceReadOutcome outcome)
|
||||
{
|
||||
var s = (AbStatus)status;
|
||||
if (status == 0 || IsDeviceRefusal(s))
|
||||
{
|
||||
device.RecordAnswered();
|
||||
outcome.AnyAnswered = true;
|
||||
if (status != 0)
|
||||
WarnRefusalOnce(device, def, "write", $"device status {s}", AbLegacyStatusMapper.MapStatus(status));
|
||||
}
|
||||
else if (IsNoReply(s))
|
||||
{
|
||||
device.RecordCommsFault(Clock());
|
||||
outcome.FaultMessage ??= $"transport status {status} writing {def.Name}";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -1052,7 +1101,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
// The probe's verdict feeds the data path's backoff (review 07 P7): an answer resets the window so a
|
||||
// recovered device is read again at once; a comms fault counts toward marking it down.
|
||||
if (success || IsDeviceRefusal((AbStatus)status)) state.RecordAnswered();
|
||||
else if ((AbStatus)status != AbStatus.Unsupported) state.RecordCommsFault(DateTime.UtcNow);
|
||||
else if ((AbStatus)status != AbStatus.Unsupported) state.RecordCommsFault(Clock());
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch (Exception ex)
|
||||
@@ -1061,7 +1110,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
probeRuntime = null;
|
||||
state.ProbeInitialized = false;
|
||||
if (ex is AbTransportException ate && IsDeviceRefusal(ate.Status)) state.RecordAnswered();
|
||||
else if (!IsLocalRefusal(ex)) state.RecordCommsFault(DateTime.UtcNow);
|
||||
else if (!IsLocalRefusal(ex)) state.RecordCommsFault(Clock());
|
||||
}
|
||||
|
||||
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
|
||||
@@ -1155,8 +1204,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
/// <see cref="SemaphoreSlim"/>. The parent word is masked to its native width before the
|
||||
/// bit operation so a sign-extended decode never corrupts the high bits.
|
||||
/// </summary>
|
||||
private async Task<uint> WriteBitInWordAsync(
|
||||
AbLegacyDriver.DeviceState device, AbLegacyAddress bitAddress, int bit, object? value, CancellationToken ct)
|
||||
/// <returns>The OPC UA status the write is answered with, and the raw transport status (0 = Good) for health.</returns>
|
||||
private async Task<(uint Code, int Raw)> WriteBitInWordAsync(
|
||||
AbLegacyDriver.DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress bitAddress, int bit, object? value,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// The parent word width follows the file letter: an L-file element is 32-bit, every
|
||||
// other bit-addressable PCCC file (N/I/O/S/A) is a 16-bit word. bit is already
|
||||
@@ -1169,6 +1220,23 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
var parentAddress = bitAddress with { BitIndex = null };
|
||||
var parentName = parentAddress.ToLibplctagName();
|
||||
|
||||
// An indirect bit write (N7:[N7:0]/3) resolves to a different parent word per index value: release the
|
||||
// previous resolution's parent handle so a sweeping index does not keep one per word (review 07 r0 M4). The
|
||||
// per-word RMW gate is kept — removing a gate another writer may hold would let two RMWs of one word race.
|
||||
var arrow = def.Name.IndexOf("=>", StringComparison.Ordinal);
|
||||
if (arrow > 0)
|
||||
{
|
||||
var indirectName = def.Name[..arrow];
|
||||
var previous = device.IndirectLastParent.TryGetValue(indirectName, out var p) ? p : null;
|
||||
device.IndirectLastParent[indirectName] = parentName;
|
||||
if (previous is not null && !string.Equals(previous, parentName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var oldGate = device.GetRmwLock(previous);
|
||||
await oldGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try { EvictParentRuntime(device, previous); } finally { oldGate.Release(); }
|
||||
}
|
||||
}
|
||||
|
||||
var rmwLock = device.GetRmwLock(parentName);
|
||||
await rmwLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
@@ -1176,7 +1244,11 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
|
||||
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
|
||||
var readStatus = parentRuntime.GetStatus();
|
||||
if (readStatus != 0) return AbLegacyStatusMapper.MapStatus(readStatus);
|
||||
if (readStatus != 0)
|
||||
{
|
||||
if (IsNoReply((AbStatus)readStatus)) EvictParentRuntime(device, parentName);
|
||||
return (AbLegacyStatusMapper.MapStatus(readStatus), readStatus);
|
||||
}
|
||||
|
||||
// Mask the decoded word to its native width: DecodeValue for a 16-bit Int returns a
|
||||
// sign-extended int, so a word with bit 15 set decodes negative. Masking pins the
|
||||
@@ -1193,9 +1265,8 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
parentRuntime.EncodeValue(AbLegacyDataType.Int, bitIndex: null, (short)updated);
|
||||
await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
|
||||
var writeStatus = parentRuntime.GetStatus();
|
||||
return writeStatus == 0
|
||||
? AbLegacyStatusMapper.Good
|
||||
: AbLegacyStatusMapper.MapStatus(writeStatus);
|
||||
if (writeStatus != 0 && IsNoReply((AbStatus)writeStatus)) EvictParentRuntime(device, parentName);
|
||||
return (writeStatus == 0 ? AbLegacyStatusMapper.Good : AbLegacyStatusMapper.MapStatus(writeStatus), writeStatus);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1381,7 +1452,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
/// (<see cref="ConnectionThrottledException"/>). The HOT path (cached handle) never touches the gate. The
|
||||
/// caller records the outcome on the device's backoff (once — recording here as well would double-count).
|
||||
/// </summary>
|
||||
private static async Task<IAbLegacyTagRuntime> CreateSingleFlightAsync(
|
||||
private async Task<IAbLegacyTagRuntime> CreateSingleFlightAsync(
|
||||
DeviceState device,
|
||||
System.Collections.Concurrent.ConcurrentDictionary<string, IAbLegacyTagRuntime> table,
|
||||
string key,
|
||||
@@ -1395,7 +1466,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
{
|
||||
// Re-check under the gate: a sibling caller may have connected this very key while we waited.
|
||||
if (table.TryGetValue(key, out existing)) return existing;
|
||||
if (device.IsThrottled(DateTime.UtcNow))
|
||||
if (device.IsThrottled(Clock()))
|
||||
throw new ConnectionThrottledException(
|
||||
$"AbLegacy connect for '{key}' on {device.Options.HostAddress} throttled: the device is in its reconnect backoff window.");
|
||||
|
||||
@@ -1544,7 +1615,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
var retired = RetireTables(bumpEpoch: true);
|
||||
var (retired, teardownEpoch) = RetireTables(bumpEpoch: true);
|
||||
// ValueTask.ConfigureAwait(false).GetAwaiter().GetResult() — drains the poll engine
|
||||
// shutdown on the current thread without capturing the SynchronizationContext. The
|
||||
// engine cancels every loop CTS up-front then either completes immediately (no
|
||||
@@ -1557,8 +1628,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
JoinProbeLoopsSync(retired);
|
||||
foreach (var state in retired) state.Retire();
|
||||
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
|
||||
// A newer InitializeAsync that ran while this teardown was still joining owns health now.
|
||||
lock (_lifecycleLock)
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
{
|
||||
if (teardownEpoch == _lifecycleEpoch)
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1620,6 +1695,11 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, string> IndirectLastName { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Indirect bit-write tag name to the parent word it last read-modify-wrote, so a moved index releases
|
||||
/// the previous parent handle.</summary>
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, string> IndirectLastParent { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Per-device connect gate (review 07 P7): every COLD runtime create + initialise on this device runs under
|
||||
/// it, so connects are single-flight per device — a down device sees one attempt at a time, not one per
|
||||
|
||||
Reference in New Issue
Block a user