fix(twincat): replay failure marks the registration dead, publishes Bad quality, degrades health (05/STAB-16)

This commit is contained in:
Joseph Doherty
2026-07-13 12:34:43 -04:00
parent 4f16620948
commit a06515ae91
2 changed files with 45 additions and 4 deletions
@@ -192,7 +192,7 @@
{
"id": "B6.2",
"subject": "B6: NativeRegistration HasLiveHandle/MarkHandleDead/OnUnavailable + replay-catch Bad-quality emit + health degrade \u2014 STAB-16",
"status": "pending",
"status": "completed",
"blockedBy": [
"B6.1"
]
@@ -493,9 +493,14 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
var reg = new NativeRegistration(
Interlocked.Increment(ref _nextRegId), device, reference, symbolName,
def.DataType, bitIndex, publishingInterval,
(_, value) => OnDataChange?.Invoke(this,
onChange: (_, value) => OnDataChange?.Invoke(this,
new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
value, TwinCATStatusMapper.Good, DateTime.UtcNow, DateTime.UtcNow))));
value, TwinCATStatusMapper.Good, DateTime.UtcNow, DateTime.UtcNow))),
// 05/STAB-16: when the handle goes dead (a failed replay) push a Bad snapshot so
// subscribers see the tag go Bad instead of a frozen-Good-stale value.
onUnavailable: () => OnDataChange?.Invoke(this,
new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
null, TwinCATStatusMapper.BadCommunicationError, null, DateTime.UtcNow))));
await RegisterNotificationAsync(device, reg, cancellationToken).ConfigureAwait(false);
registrations.Add(reg);
@@ -593,8 +598,22 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// <summary>Value-change callback (closes over the owning subscription handle + reference).</summary>
public Action<string, object?> OnChange { get; }
/// <summary>
/// Bad-quality callback (05/STAB-16). Invoked when this registration's handle goes dead
/// (a failed replay) so subscribers observe the tag go Bad instead of frozen-Good-stale —
/// the <see cref="OnChange"/> shape has no quality channel. Closes over the same
/// (handle, reference) pair as <see cref="OnChange"/>.
/// </summary>
public Action OnUnavailable { get; }
private ITwinCATNotificationHandle? _handle;
/// <summary>
/// True while this registration holds a live notification handle. False after a failed
/// replay marks it dead (05/STAB-16) — the probe-tick retry re-registers such handles.
/// </summary>
public bool HasLiveHandle => Volatile.Read(ref _handle) is not null;
/// <summary>Initializes a new instance of the <see cref="NativeRegistration"/> class.</summary>
/// <param name="id">Globally-unique registration id (registry key on the device).</param>
/// <param name="device">The device this notification is registered against.</param>
@@ -604,8 +623,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// <param name="bitIndex">Bit index for a BOOL-within-word symbol; null otherwise.</param>
/// <param name="interval">Requested notification interval.</param>
/// <param name="onChange">Value-change callback (closes over the owning subscription handle + reference).</param>
/// <param name="onUnavailable">Bad-quality callback fired when the handle goes dead (05/STAB-16).</param>
public NativeRegistration(long id, DeviceState device, string reference, string symbolName,
TwinCATDataType dataType, int? bitIndex, TimeSpan interval, Action<string, object?> onChange)
TwinCATDataType dataType, int? bitIndex, TimeSpan interval, Action<string, object?> onChange,
Action onUnavailable)
{
Id = id;
Device = device;
@@ -615,6 +636,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
BitIndex = bitIndex;
Interval = interval;
OnChange = onChange;
OnUnavailable = onUnavailable;
}
/// <summary>Installs a freshly-registered handle and returns the previous one (null on first set).</summary>
@@ -623,6 +645,15 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
public ITwinCATNotificationHandle? SwapHandle(ITwinCATNotificationHandle newHandle) =>
Interlocked.Exchange(ref _handle, newHandle);
/// <summary>
/// Atomically clears the live handle and returns the previous one (05/STAB-16). The caller
/// disposes the returned (already-dead) handle best-effort. After this
/// <see cref="HasLiveHandle"/> is false until a retry re-registers.
/// </summary>
/// <returns>The previous handle, or null.</returns>
public ITwinCATNotificationHandle? MarkHandleDead() =>
Interlocked.Exchange(ref _handle, null);
/// <summary>Removes this registration from its device and disposes its live handle.</summary>
public void Dispose()
{
@@ -851,6 +882,16 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
"TwinCAT driver '{DriverInstanceId}' failed to replay native notification " +
"'{Symbol}' on device '{HostAddress}' after reconnect",
_driverInstanceId, reg.SymbolName, device.Options.HostAddress);
// 05/STAB-16: the intent has no live handle now — mark it dead so the probe-tick
// retry re-registers it, surface Bad quality to subscribers (the OnChange shape has
// no quality channel), and degrade health preserving the last successful read.
var dead = reg.MarkHandleDead();
try { dead?.Dispose(); } catch { /* already-dead handle from the disposed client */ }
try { reg.OnUnavailable(); } catch { /* never let a subscriber callback abort recovery of the rest */ }
if (_health.State != DriverState.Faulted)
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"native-notification replay failed for '{reg.SymbolName}' on {device.Options.HostAddress}");
}
}