fix(client): failover runs under the subscription gate; ConnectAsync refuses when connected
R08 4.5: every subscription operation that awaits I/O and the failover's teardown + reconnect + replay share one async gate, and every read/write of the subscription fields (failover's null-outs included) is under _subscriptionLock. A subscribe in flight when a failover starts is recorded first and then replayed onto the new session instead of landing on the dying session's subscription. The dead session is disposed before the gate is taken so a blocked call fails fast. A DisconnectAsync during a failover is no longer undone by it. ConnectAsync on a connected or reconnecting client throws instead of overwriting (and leaking) the live session. Refs #747 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -15,16 +15,17 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<OpcUaClientService>();
|
||||
|
||||
// Guards all access to the subscription-bookkeeping state below
|
||||
// (_activeDataSubscriptions and _activeAlarmSubscription). The dictionary
|
||||
// and tuple are mutated from the caller thread, the keep-alive failover
|
||||
// path, and DisconnectAsync, so every read/write must be inside this lock.
|
||||
// Guards all access to the subscription state below (_activeDataSubscriptions, _activeAlarmSubscription,
|
||||
// _dataSubscription, _alarmSubscription). They are touched from the caller thread, the keep-alive failover path
|
||||
// and DisconnectAsync, so every read/write is inside this lock — including failover's null-outs.
|
||||
private readonly object _subscriptionLock = new();
|
||||
|
||||
// Serialises SubscribeAlarmsAsync / UnsubscribeAlarmsAsync so concurrent callers
|
||||
// cannot both pass the _alarmSubscription == null check and create duplicate event
|
||||
// subscriptions. Capacity 1 = at most one waiter; async-friendly (no thread-block).
|
||||
private readonly SemaphoreSlim _alarmSubscribeSemaphore = new(1, 1);
|
||||
// Serialises every subscription operation that awaits I/O (Subscribe/Unsubscribe, SubscribeAlarms/
|
||||
// UnsubscribeAlarms) AND the failover's teardown + reconnect + replay. A subscribe is therefore either
|
||||
// wholly before a failover (recorded, then replayed onto the new session) or wholly after it (on the new
|
||||
// session) — never half on the dying session. Async-friendly (no thread block). Deliberately never disposed:
|
||||
// async-only waits hold no kernel handle, and a failover still running at Dispose releases it afterwards.
|
||||
private readonly SemaphoreSlim _subscriptionGate = new(1, 1);
|
||||
|
||||
// Track active data subscriptions for replay after failover
|
||||
private readonly Dictionary<string, (NodeId NodeId, int IntervalMs, uint Handle)> _activeDataSubscriptions = new();
|
||||
@@ -34,6 +35,9 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
// run at a time. 0 = idle, 1 = failover in progress (Interlocked-managed).
|
||||
private int _failoverInProgress;
|
||||
|
||||
/// <summary>The most recent keep-alive-triggered failover task (test seam: lets a test await it).</summary>
|
||||
internal Task? LastFailover { get; private set; }
|
||||
|
||||
private readonly IApplicationConfigurationFactory _configFactory;
|
||||
private readonly IEndpointDiscovery _endpointDiscovery;
|
||||
|
||||
@@ -99,6 +103,12 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
ThrowIfDisposed();
|
||||
settings.Validate();
|
||||
|
||||
// Overwriting a live (or reconnecting) session would leak it and orphan its subscriptions and keep-alive
|
||||
// handler. The caller must disconnect first.
|
||||
if (_state != ConnectionState.Disconnected || _session != null)
|
||||
throw new InvalidOperationException(
|
||||
$"Already {_state.ToString().ToLowerInvariant()}; call DisconnectAsync before connecting again.");
|
||||
|
||||
_settings = settings;
|
||||
_allEndpointUrls = FailoverUrlParser.Parse(settings.EndpointUrl, settings.FailoverUrls);
|
||||
_currentEndpointIndex = 0;
|
||||
@@ -112,7 +122,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
|
||||
session.RegisterKeepAliveHandler(isGood =>
|
||||
{
|
||||
if (!isGood) _ = HandleKeepAliveFailureAsync();
|
||||
if (!isGood) LastFailover = HandleKeepAliveFailureAsync();
|
||||
});
|
||||
|
||||
CurrentConnectionInfo = BuildConnectionInfo(session);
|
||||
@@ -248,20 +258,39 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
ThrowIfNotConnected();
|
||||
|
||||
var nodeIdStr = nodeId.ToString();
|
||||
lock (_subscriptionLock)
|
||||
await _subscriptionGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_activeDataSubscriptions.ContainsKey(nodeIdStr))
|
||||
return; // Already subscribed
|
||||
// A failover may have run (and failed) while this call waited for the gate.
|
||||
ThrowIfNotConnected();
|
||||
|
||||
ISubscriptionAdapter? subscription;
|
||||
ISessionAdapter session;
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
if (_activeDataSubscriptions.ContainsKey(nodeIdStr))
|
||||
return; // Already subscribed
|
||||
subscription = _dataSubscription;
|
||||
session = _session!;
|
||||
}
|
||||
|
||||
if (subscription == null)
|
||||
{
|
||||
subscription = await session.CreateSubscriptionAsync(intervalMs, ct);
|
||||
lock (_subscriptionLock) _dataSubscription = subscription;
|
||||
}
|
||||
|
||||
var handle = await subscription.AddDataChangeMonitoredItemAsync(
|
||||
nodeId, intervalMs, OnDataChangeNotification, ct);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
_activeDataSubscriptions[nodeIdStr] = (nodeId, intervalMs, handle);
|
||||
}
|
||||
}
|
||||
|
||||
if (_dataSubscription == null) _dataSubscription = await _session!.CreateSubscriptionAsync(intervalMs, ct);
|
||||
|
||||
var handle = await _dataSubscription.AddDataChangeMonitoredItemAsync(
|
||||
nodeId, intervalMs, OnDataChangeNotification, ct);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
finally
|
||||
{
|
||||
_activeDataSubscriptions[nodeIdStr] = (nodeId, intervalMs, handle);
|
||||
_subscriptionGate.Release();
|
||||
}
|
||||
|
||||
Logger.Debug("Subscribed to data changes on {NodeId}", nodeId);
|
||||
@@ -273,18 +302,28 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
ThrowIfDisposed();
|
||||
|
||||
var nodeIdStr = nodeId.ToString();
|
||||
(NodeId NodeId, int IntervalMs, uint Handle) sub;
|
||||
lock (_subscriptionLock)
|
||||
await _subscriptionGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (!_activeDataSubscriptions.TryGetValue(nodeIdStr, out sub))
|
||||
return; // Not subscribed, safe to ignore
|
||||
(NodeId NodeId, int IntervalMs, uint Handle) sub;
|
||||
ISubscriptionAdapter? subscription;
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
if (!_activeDataSubscriptions.TryGetValue(nodeIdStr, out sub))
|
||||
return; // Not subscribed, safe to ignore
|
||||
subscription = _dataSubscription;
|
||||
}
|
||||
|
||||
if (subscription != null) await subscription.RemoveMonitoredItemAsync(sub.Handle, ct);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
_activeDataSubscriptions.Remove(nodeIdStr);
|
||||
}
|
||||
}
|
||||
|
||||
if (_dataSubscription != null) await _dataSubscription.RemoveMonitoredItemAsync(sub.Handle, ct);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
finally
|
||||
{
|
||||
_activeDataSubscriptions.Remove(nodeIdStr);
|
||||
_subscriptionGate.Release();
|
||||
}
|
||||
|
||||
Logger.Debug("Unsubscribed from data changes on {NodeId}", nodeId);
|
||||
@@ -300,17 +339,25 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
// Serialise check-and-create under the semaphore: concurrent
|
||||
// callers cannot both pass the null-check and create duplicate alarm subscriptions.
|
||||
// The semaphore is async-friendly; the subscription I/O runs inside the critical section.
|
||||
await _alarmSubscribeSemaphore.WaitAsync(ct);
|
||||
await _subscriptionGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_alarmSubscription != null)
|
||||
return; // Already subscribed to alarms
|
||||
ThrowIfNotConnected();
|
||||
|
||||
ISessionAdapter session;
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
if (_alarmSubscription != null)
|
||||
return; // Already subscribed to alarms
|
||||
session = _session!;
|
||||
}
|
||||
|
||||
var monitorNode = sourceNodeId ?? ObjectIds.Server;
|
||||
_alarmSubscription = await _session!.CreateSubscriptionAsync(intervalMs, ct);
|
||||
var subscription = await session.CreateSubscriptionAsync(intervalMs, ct);
|
||||
lock (_subscriptionLock) _alarmSubscription = subscription;
|
||||
|
||||
var filter = CreateAlarmEventFilter();
|
||||
await _alarmSubscription.AddEventMonitoredItemAsync(
|
||||
await subscription.AddEventMonitoredItemAsync(
|
||||
monitorNode, intervalMs, filter, OnAlarmEventNotification, ct);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
@@ -322,7 +369,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
}
|
||||
finally
|
||||
{
|
||||
_alarmSubscribeSemaphore.Release();
|
||||
_subscriptionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,15 +378,27 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_alarmSubscription == null)
|
||||
return;
|
||||
|
||||
await _alarmSubscription.DeleteAsync(ct);
|
||||
_alarmSubscription = null;
|
||||
|
||||
lock (_subscriptionLock)
|
||||
await _subscriptionGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
_activeAlarmSubscription = null;
|
||||
ISubscriptionAdapter? subscription;
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
subscription = _alarmSubscription;
|
||||
if (subscription == null) return;
|
||||
}
|
||||
|
||||
await subscription.DeleteAsync(ct);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
_alarmSubscription = null;
|
||||
_activeAlarmSubscription = null;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_subscriptionGate.Release();
|
||||
}
|
||||
|
||||
Logger.Debug("Unsubscribed from alarm events");
|
||||
@@ -351,10 +410,12 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotConnected();
|
||||
|
||||
if (_alarmSubscription == null)
|
||||
ISubscriptionAdapter? subscription;
|
||||
lock (_subscriptionLock) subscription = _alarmSubscription;
|
||||
if (subscription == null)
|
||||
throw new InvalidOperationException("No alarm subscription is active.");
|
||||
|
||||
await _alarmSubscription.ConditionRefreshAsync(ct);
|
||||
await subscription.ConditionRefreshAsync(ct);
|
||||
Logger.Debug("Condition refresh requested");
|
||||
}
|
||||
|
||||
@@ -643,7 +704,6 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
_dataSubscription?.Dispose();
|
||||
_alarmSubscription?.Dispose();
|
||||
_session?.Dispose();
|
||||
_alarmSubscribeSemaphore.Dispose();
|
||||
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
@@ -710,69 +770,93 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
if (_state == ConnectionState.Reconnecting || _state == ConnectionState.Disconnected)
|
||||
return;
|
||||
|
||||
var oldEndpoint = _session?.EndpointUrl ?? string.Empty;
|
||||
var oldSession = _session;
|
||||
var oldEndpoint = oldSession?.EndpointUrl ?? string.Empty;
|
||||
TransitionState(ConnectionState.Reconnecting, oldEndpoint);
|
||||
Logger.Warning("Session lost on {EndpointUrl}. Attempting failover...", oldEndpoint);
|
||||
|
||||
// Close old session
|
||||
if (_session != null)
|
||||
// Dispose the dead session BEFORE taking the gate: an in-flight subscribe holding the gate is blocked on
|
||||
// that session, and disposing it is what makes the call fail fast and release the gate.
|
||||
if (oldSession != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_session.Dispose();
|
||||
oldSession.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
_session = null;
|
||||
}
|
||||
|
||||
_dataSubscription = null;
|
||||
_alarmSubscription = null;
|
||||
|
||||
if (_settings == null || _allEndpointUrls == null)
|
||||
// The rest runs under the subscription gate (and its null-outs under _subscriptionLock): no subscribe can
|
||||
// interleave with the teardown, the reconnect or the replay.
|
||||
await _subscriptionGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
TransitionState(ConnectionState.Disconnected, oldEndpoint);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try each endpoint
|
||||
for (var attempt = 0; attempt < _allEndpointUrls.Length; attempt++)
|
||||
{
|
||||
_currentEndpointIndex = (_currentEndpointIndex + 1) % _allEndpointUrls.Length;
|
||||
var url = _allEndpointUrls[_currentEndpointIndex];
|
||||
|
||||
try
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
Logger.Information("Failover attempt to {EndpointUrl}", url);
|
||||
var session = await ConnectToEndpointAsync(_settings, url, CancellationToken.None);
|
||||
_session = session;
|
||||
if (ReferenceEquals(_session, oldSession)) _session = null;
|
||||
_dataSubscription = null;
|
||||
_alarmSubscription = null;
|
||||
}
|
||||
|
||||
session.RegisterKeepAliveHandler(isGood =>
|
||||
{
|
||||
if (!isGood) _ = HandleKeepAliveFailureAsync();
|
||||
});
|
||||
|
||||
CurrentConnectionInfo = BuildConnectionInfo(session);
|
||||
TransitionState(ConnectionState.Connected, url);
|
||||
Logger.Information("Failover succeeded to {EndpointUrl}", url);
|
||||
|
||||
// Replay subscriptions
|
||||
await ReplaySubscriptionsAsync();
|
||||
if (_settings == null || _allEndpointUrls == null)
|
||||
{
|
||||
TransitionState(ConnectionState.Disconnected, oldEndpoint);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning(ex, "Failover to {EndpointUrl} failed", url);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.Error("All failover endpoints unreachable");
|
||||
TransitionState(ConnectionState.Disconnected, oldEndpoint);
|
||||
// Try each endpoint
|
||||
for (var attempt = 0; attempt < _allEndpointUrls.Length; attempt++)
|
||||
{
|
||||
_currentEndpointIndex = (_currentEndpointIndex + 1) % _allEndpointUrls.Length;
|
||||
var url = _allEndpointUrls[_currentEndpointIndex];
|
||||
|
||||
try
|
||||
{
|
||||
Logger.Information("Failover attempt to {EndpointUrl}", url);
|
||||
var session = await ConnectToEndpointAsync(_settings, url, CancellationToken.None);
|
||||
|
||||
// A DisconnectAsync / Dispose that ran while this attempt was connecting wins: do not resurrect
|
||||
// the connection behind the caller's back.
|
||||
if (_disposed || _state == ConnectionState.Disconnected)
|
||||
{
|
||||
session.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
_session = session;
|
||||
|
||||
session.RegisterKeepAliveHandler(isGood =>
|
||||
{
|
||||
if (!isGood) LastFailover = HandleKeepAliveFailureAsync();
|
||||
});
|
||||
|
||||
CurrentConnectionInfo = BuildConnectionInfo(session);
|
||||
TransitionState(ConnectionState.Connected, url);
|
||||
Logger.Information("Failover succeeded to {EndpointUrl}", url);
|
||||
|
||||
// Replay subscriptions
|
||||
await ReplaySubscriptionsAsync(session);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning(ex, "Failover to {EndpointUrl} failed", url);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.Error("All failover endpoints unreachable");
|
||||
TransitionState(ConnectionState.Disconnected, oldEndpoint);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_subscriptionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReplaySubscriptionsAsync()
|
||||
/// <summary>Re-creates the recorded subscriptions on <paramref name="session"/>. Runs under the subscription gate.</summary>
|
||||
private async Task ReplaySubscriptionsAsync(ISessionAdapter session)
|
||||
{
|
||||
// Snapshot the bookkeeping state under the lock, then clear it so the
|
||||
// replayed handles can be recorded fresh as each monitored item is
|
||||
@@ -788,13 +872,17 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
}
|
||||
|
||||
// Replay data subscriptions
|
||||
ISubscriptionAdapter? dataSubscription = null;
|
||||
foreach (var (nodeIdStr, (nodeId, intervalMs, _)) in subscriptions)
|
||||
try
|
||||
{
|
||||
if (_dataSubscription == null)
|
||||
_dataSubscription = await _session!.CreateSubscriptionAsync(intervalMs, CancellationToken.None);
|
||||
if (dataSubscription == null)
|
||||
{
|
||||
dataSubscription = await session.CreateSubscriptionAsync(intervalMs, CancellationToken.None);
|
||||
lock (_subscriptionLock) _dataSubscription = dataSubscription;
|
||||
}
|
||||
|
||||
var handle = await _dataSubscription.AddDataChangeMonitoredItemAsync(
|
||||
var handle = await dataSubscription.AddDataChangeMonitoredItemAsync(
|
||||
nodeId, intervalMs, OnDataChangeNotification, CancellationToken.None);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
@@ -814,9 +902,10 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
try
|
||||
{
|
||||
var monitorNode = sourceNodeId ?? ObjectIds.Server;
|
||||
_alarmSubscription = await _session!.CreateSubscriptionAsync(intervalMs, CancellationToken.None);
|
||||
var replayedAlarms = await session.CreateSubscriptionAsync(intervalMs, CancellationToken.None);
|
||||
lock (_subscriptionLock) _alarmSubscription = replayedAlarms;
|
||||
var filter = CreateAlarmEventFilter();
|
||||
await _alarmSubscription.AddEventMonitoredItemAsync(
|
||||
await replayedAlarms.AddEventMonitoredItemAsync(
|
||||
monitorNode, intervalMs, filter, OnAlarmEventNotification, CancellationToken.None);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
|
||||
Reference in New Issue
Block a user