fix(twincat): retry failed replay intents on the next successful probe tick (05/STAB-16)

This commit is contained in:
Joseph Doherty
2026-07-13 12:37:20 -04:00
parent a06515ae91
commit de29759643
3 changed files with 121 additions and 1 deletions
@@ -690,6 +690,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
// (the compounding bug — native pushes would never recover).
if (!success)
await RecycleClientAsync(state).ConfigureAwait(false);
else
// 05/STAB-16: on a healthy tick, re-register any notifications whose replay
// failed (a transient AddNotification blip) so they recover without a client swap.
await RetryDeadRegistrationsAsync(state, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch
@@ -901,6 +905,65 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
_driverInstanceId, replayed, intents.Length, device.Options.HostAddress);
}
/// <summary>
/// 05/STAB-16: re-registers every dead (failed-replay) <see cref="NativeRegistration"/> onto
/// the device's currently-connected client. Runs under <see cref="DeviceState.ConnectGate"/>
/// (shared with the replay so it can't race a register/unsubscribe). Called from the probe
/// loop after a successful probe — a bounded retry cadence (the probe interval) with no extra
/// timers. A registration that fails again just stays dead for the next tick. Recovery of the
/// value itself arrives via ADS's initial-notification push on register, exactly as the
/// initial subscribe relies on.
/// </summary>
private async Task RetryDeadRegistrationsAsync(DeviceState device, CancellationToken ct)
{
if (device.NativeRegistrations.IsEmpty) return;
await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false);
try
{
if (device.Client is not { IsConnected: true } client) return;
var recovered = 0;
foreach (var reg in device.NativeRegistrations.Values)
{
if (reg.HasLiveHandle) continue;
try
{
var newHandle = await client.AddNotificationAsync(
reg.SymbolName, reg.DataType, reg.BitIndex, reg.Interval,
_options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false);
reg.SwapHandle(newHandle);
// STAB-17 ownership re-check — see ReplayNativeRegistrationsAsync.
if (!device.NativeRegistrations.ContainsKey(reg.Id))
{
var orphan = reg.MarkHandleDead();
try { orphan?.Dispose(); } catch { /* best-effort */ }
continue;
}
recovered++;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
{
_logger.LogDebug(ex,
"TwinCAT driver '{DriverInstanceId}' probe-tick retry of dead notification " +
"'{Symbol}' on device '{HostAddress}' failed; will retry next tick",
_driverInstanceId, reg.SymbolName, device.Options.HostAddress);
}
}
if (recovered > 0)
_logger.LogInformation(
"TwinCAT driver '{DriverInstanceId}' recovered {Recovered} dead native notification(s) " +
"on device '{HostAddress}' via probe-tick retry",
_driverInstanceId, recovered, device.Options.HostAddress);
}
finally
{
device.ConnectGate.Release();
}
}
/// <summary>
/// Disposes + nulls a device's client under <see cref="DeviceState.ConnectGate"/> so the
/// next <see cref="EnsureConnectedAsync"/> rebuilds it (and replays notifications). Called