feat(opcuaclient): #676 follow-ups — endpoint rewrite, stall wire test, fast definitive-loss recovery

- Endpoint rewrite: the driver now connects to the configured host AND port (scheme
  match, advertised path kept) instead of the server-advertised URL — fixes NAT/port-map
  upstreams and lets an in-test proxy carry the session (SDK SelectEndpoint rule).
- TcpStallProxy + OpcUaClientDriverStallWireTests: the in-place blip (#670) is now
  wire-tested on loopback — one Bad, frozen read stamp, same session, identical value
  republished Good, a post-recovery change arrives, no source-time regression after
  the SDK's late backlog flush.
- Defect found by that test, fixed: a change published right after a stall rode a
  Publish response the SDK timed out and never republished, leaving the node Good on
  the pre-change value indefinitely. New trailing reconcile read (Timeout +
  KeepAliveInterval after recovery) publishes any Good value newer than the last
  accepted one, through the replay guard's mark.
- IDriverConnectionLossSource (Core.Abstractions): opt-in definitive-loss signal.
  DriverInstanceActor attaches in PreStart / detaches in PostStop and turns a raise
  into a disconnect only while Connected. OpcUaClient raises once per session on
  keep-alive BadSessionIdInvalid/BadSessionClosed/BadSessionNotActivated/
  BadServerHalted (measured: the SDK never re-opens a halted channel) or a dropped
  subscription; ambiguous codes stay with the #527 watchdog.
- Replay guard: one Information summary per recovery when the window closes
  ("dropped N late notifications across M refs"), with a timer so quiet
  subscriptions log it too.
- ReconnectPeriod deleted (options, AdminUI form, docs, tests); stored configs that
  carry reconnectPeriod still parse in the factory, probe and browser.
- Docs: OpcUaClient.md, DriverLifecycle.md, driver-specs.md, CLAUDE.md.
This commit is contained in:
Joseph Doherty
2026-10-11 11:17:19 -04:00
parent e59ab69398
commit fccffd0a0f
22 changed files with 1390 additions and 82 deletions
@@ -53,11 +53,6 @@
<InputNumber @bind-Value="_form.OpcUa.KeepAliveIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 5 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Reconnect period (s)</label>
<InputNumber @bind-Value="_form.OpcUa.ReconnectPeriodSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Initial delay after session drop. Default 5 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max discovered nodes</label>
<InputNumber @bind-Value="_form.OpcUa.MaxDiscoveredNodes" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
@@ -330,7 +325,6 @@
public int TimeoutSeconds { get; set; } = 10;
public int SessionTimeoutSeconds { get; set; } = 120;
public int KeepAliveIntervalSeconds { get; set; } = 5;
public int ReconnectPeriodSeconds { get; set; } = 5;
public int MaxDiscoveredNodes { get; set; } = 10_000;
public int MaxBrowseDepth { get; set; } = 10;
@@ -370,7 +364,6 @@
TimeoutSeconds = (int)r.Timeout.TotalSeconds,
SessionTimeoutSeconds = (int)r.SessionTimeout.TotalSeconds,
KeepAliveIntervalSeconds = (int)r.KeepAliveInterval.TotalSeconds,
ReconnectPeriodSeconds = (int)r.ReconnectPeriod.TotalSeconds,
MaxDiscoveredNodes = r.MaxDiscoveredNodes,
MaxBrowseDepth = r.MaxBrowseDepth,
SecurityMode = r.SecurityMode,
@@ -404,7 +397,6 @@
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
SessionTimeout = TimeSpan.FromSeconds(SessionTimeoutSeconds),
KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveIntervalSeconds),
ReconnectPeriod = TimeSpan.FromSeconds(ReconnectPeriodSeconds),
MaxDiscoveredNodes = MaxDiscoveredNodes,
MaxBrowseDepth = MaxBrowseDepth,
SecurityMode = SecurityMode,
@@ -153,6 +153,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// behaviour for the same reason as <see cref="RediscoveryRaised"/> — a probe tick can land while the
/// driver is between connects.</summary>
private sealed record HostStatusRaised;
/// <summary>Self-sent when the wrapped driver raises <see cref="IDriverConnectionLossSource.ConnectionLost"/> (#676)
/// — it KNOWS its connection is gone for good. Marshals the event off the driver's thread. Acted on only in
/// Connected (it becomes a disconnect at once, instead of after the #527 watchdog's silence window); every other
/// behaviour is already reconnecting or connecting and ignores it.</summary>
private sealed record ConnectionLossRaised(DriverConnectionLossEventArgs Args);
public sealed class RetryConnect
{
@@ -233,6 +238,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private EventHandler<AlarmEventArgs>? _alarmEventHandler;
private EventHandler<RediscoveryEventArgs>? _rediscoveryHandler;
private EventHandler<HostStatusChangedEventArgs>? _hostStatusHandler;
private EventHandler<DriverConnectionLossEventArgs>? _connectionLossHandler;
/// <summary>When the driver last raised <see cref="IRediscoverable.OnRediscoveryNeeded"/>, and the reason
/// it gave. Null until the first raise. Carried on every subsequent health snapshot so the AdminUI can
@@ -400,6 +406,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// has no connection affinity, and a driver can observe a remote change while disconnected.
AttachRediscoverySource();
AttachHostStatusSource();
AttachConnectionLossSource();
PublishHealthSnapshot();
Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, _healthPollInterval);
}
@@ -427,6 +434,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// Stubbed drivers have no upstream alarm system — swallow the ack (it's fire-and-forget, no reply).
Receive<RouteAlarmAck>(_ => { /* stubbed drivers have no alarm backend */ });
Receive<DisconnectObserved>(_ => { /* stubbed drivers don't disconnect */ });
Receive<ConnectionLossRaised>(IgnoreConnectionLoss);
Receive<ForceReconnect>(_ => { /* stubbed drivers don't reconnect */ });
Receive<SetDesiredSubscriptions>(StoreDesiredSubscriptions);
// Stubbed drivers never enter Connected, so they never kick discovery; swallow defensively in case a
@@ -468,6 +476,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
});
Receive<SetDesiredSubscriptions>(StoreDesiredSubscriptions);
Receive<ForceReconnect>(_ => { /* already connecting — no-op */ });
Receive<ConnectionLossRaised>(IgnoreConnectionLoss);
// ResubscribeDesired self-Tells Subscribe; HandleSubscribeAsync replies SubscriptionEstablished to the
// sender, which on the self-resubscribe path is Self. Swallow it (trace only) so it doesn't dead-letter.
Receive<SubscriptionEstablished>(msg =>
@@ -500,15 +509,12 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// reconnects but never manages a read is measured from THIS connect, not from a stale earlier one.
_connectedSinceUtc = DateTime.UtcNow;
Receive<DisconnectObserved>(msg =>
{
_log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting",
_driverInstanceId, msg.Reason);
DetachSubscription();
RecordFault();
Become(Reconnecting);
PublishHealthSnapshot();
});
Receive<DisconnectObserved>(msg => HandleDisconnectObserved(msg.Reason));
// #676 — the driver KNOWS its connection is gone (e.g. OpcUaClient: the upstream server halted or forgot the
// session). Same handling as the watchdog's disconnect, minus the ~90 s of silence it would need to prove it.
// Handled inline rather than re-told as DisconnectObserved, so it cannot land in Reconnecting as an unhandled
// message.
Receive<ConnectionLossRaised>(msg => HandleDisconnectObserved($"driver reported connection lost: {msg.Args.Reason}"));
Receive<ForceReconnect>(_ =>
{
_log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId);
@@ -764,6 +770,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
Receive<InitializeFailed>(_ => { /* keep retrying via timer */ });
Receive<SetDesiredSubscriptions>(StoreDesiredSubscriptions);
Receive<ForceReconnect>(_ => { /* already reconnecting — no-op */ });
Receive<ConnectionLossRaised>(IgnoreConnectionLoss);
// ResubscribeDesired self-Tells Subscribe; HandleSubscribeAsync replies SubscriptionEstablished to the
// sender, which on the self-resubscribe path is Self. Swallow it (trace only) so it doesn't dead-letter.
Receive<SubscriptionEstablished>(msg =>
@@ -1105,6 +1112,50 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// one), marshaling each transition to the actor thread. Idempotent; mirrors
/// <see cref="AttachRediscoverySource"/>, including the PreStart-not-per-connect placement — a probe
/// loop can report a host down while the driver itself is between connects.</summary>
/// <summary>
/// Subscribe the driver's <see cref="IDriverConnectionLossSource.ConnectionLost"/> (#676), if it is one,
/// marshalling each raise to the actor thread — actor state is never touched on the driver's thread. Attached
/// once in <c>PreStart</c> and detached in <c>PostStop</c>, like <see cref="AttachRediscoverySource"/>: the
/// <see cref="IDriver"/> instance can outlive this actor, so a per-connect attach would leak a handler (holding
/// a dead <c>Self</c>) per respawn.
/// </summary>
private void AttachConnectionLossSource()
{
if (_driver is not IDriverConnectionLossSource src || _connectionLossHandler is not null) return;
var self = Self;
_connectionLossHandler = (_, e) => self.Tell(new ConnectionLossRaised(e));
src.ConnectionLost += _connectionLossHandler;
}
/// <summary>Symmetric teardown, called from PostStop — same leak argument as <see cref="DetachRediscoverySource"/>.</summary>
private void DetachConnectionLossSource()
{
if (_driver is IDriverConnectionLossSource src && _connectionLossHandler is not null)
src.ConnectionLost -= _connectionLossHandler;
_connectionLossHandler = null;
}
/// <summary>
/// A connection loss reported while NOT Connected (#676): a (re)initialise is already pending or running, so
/// there is nothing to tear down — and acting on it could tear down the connection that attempt is building.
/// </summary>
private void IgnoreConnectionLoss(ConnectionLossRaised msg) =>
_log.Debug("DriverInstance {Id}: driver reported connection lost ({Reason}) while not connected — ignored",
_driverInstanceId, msg.Args.Reason);
/// <summary>
/// Connected → Reconnecting on an observed disconnect — from the #527 watchdog or a driver's definitive
/// <see cref="IDriverConnectionLossSource.ConnectionLost"/> (#676).
/// </summary>
private void HandleDisconnectObserved(string reason)
{
_log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting", _driverInstanceId, reason);
DetachSubscription();
RecordFault();
Become(Reconnecting);
PublishHealthSnapshot();
}
private void AttachHostStatusSource()
{
if (_driver is not IHostConnectivityProbe src || _hostStatusHandler is not null) return;
@@ -1497,6 +1548,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// same driver object), so a missing unsubscribe accumulates a handler per respawn holding a dead Self.
DetachRediscoverySource();
DetachHostStatusSource();
DetachConnectionLossSource();
try { _driver.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult(); }
catch (Exception ex) { _log.Warning(ex, "DriverInstance {Id}: ShutdownAsync threw on PostStop", _driverInstanceId); }
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,