feat: add JoeAppEngine OPC UA nodes, fix DCL auto-reconnect and quality push

- Add JoeAppEngine folder to OPC UA nodes.json (BTCS, AlarmCntsBySeverity, Scheduler/ScanTime)
- Fix DataConnectionActor: capture Self in PreStart for use from non-actor threads,
  preventing Self.Tell failure in Disconnected event handler
- Implement InstanceActor.HandleConnectionQualityChanged to mark attributes Bad on disconnect
- Fix LmxFakeProxy TagMapper to serialize arrays as JSON instead of "System.Int32[]"
- Allow DataType and DataSourceReference updates in TemplateService.UpdateAttributeAsync
- Update test_infra_opcua.md with JoeAppEngine documentation
This commit is contained in:
Joseph Doherty
2026-03-19 13:27:54 -04:00
parent ffdda51990
commit 7740a3bcf9
70 changed files with 2684 additions and 541 deletions

View File

@@ -62,6 +62,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
private readonly IDictionary<string, string> _connectionDetails;
/// <summary>
/// Captured Self reference for use from non-actor threads (event handlers, callbacks).
/// Akka.NET's Self property is only valid inside the actor's message loop.
/// </summary>
private IActorRef _self = null!;
public DataConnectionActor(
string connectionName,
IDataConnection adapter,
@@ -79,13 +85,28 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
protected override void PreStart()
{
_log.Info("DataConnectionActor [{0}] starting in Connecting state", _connectionName);
// Capture Self for use from non-actor threads (event handlers, callbacks).
// Akka.NET's Self property is only valid inside the actor's message loop.
_self = Self;
// Listen for unexpected adapter disconnections
_adapter.Disconnected += OnAdapterDisconnected;
BecomeConnecting();
}
private void OnAdapterDisconnected()
{
// Marshal the event onto the actor's message loop using captured _self reference.
// This runs on a background thread (gRPC stream reader), so Self would throw.
_self.Tell(new AdapterDisconnected());
}
protected override void PostStop()
{
_log.Info("DataConnectionActor [{0}] stopping — disposing adapter", _connectionName);
// Clean up the adapter asynchronously
_adapter.Disconnected -= OnAdapterDisconnected;
_ = _adapter.DisposeAsync().AsTask();
}
@@ -276,7 +297,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
private void HandleDisconnect()
{
_log.Warning("[{0}] Adapter reported disconnect", _connectionName);
_log.Warning("[{0}] AdapterDisconnected message received — transitioning to Reconnecting", _connectionName);
BecomeReconnecting();
}

View File

@@ -39,6 +39,7 @@ public interface ILmxProxyClient : IAsyncDisposable
Task<ILmxSubscription> SubscribeAsync(
IEnumerable<string> addresses,
Action<string, LmxVtq> onUpdate,
Action? onStreamError = null,
CancellationToken cancellationToken = default);
}
@@ -48,7 +49,7 @@ public interface ILmxProxyClient : IAsyncDisposable
/// </summary>
public interface ILmxProxyClientFactory
{
ILmxProxyClient Create(string host, int port, string? apiKey);
ILmxProxyClient Create(string host, int port, string? apiKey, int samplingIntervalMs = 0, bool useTls = false);
}
/// <summary>
@@ -56,7 +57,7 @@ public interface ILmxProxyClientFactory
/// </summary>
public class DefaultLmxProxyClientFactory : ILmxProxyClientFactory
{
public ILmxProxyClient Create(string host, int port, string? apiKey) => new StubLmxProxyClient();
public ILmxProxyClient Create(string host, int port, string? apiKey, int samplingIntervalMs = 0, bool useTls = false) => new StubLmxProxyClient();
}
/// <summary>
@@ -93,7 +94,7 @@ internal class StubLmxProxyClient : ILmxProxyClient
public Task WriteBatchAsync(IDictionary<string, object> values, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task<ILmxSubscription> SubscribeAsync(IEnumerable<string> addresses, Action<string, LmxVtq> onUpdate, CancellationToken cancellationToken = default)
public Task<ILmxSubscription> SubscribeAsync(IEnumerable<string> addresses, Action<string, LmxVtq> onUpdate, Action? onStreamError = null, CancellationToken cancellationToken = default)
=> Task.FromResult<ILmxSubscription>(new StubLmxSubscription());
public ValueTask DisposeAsync()

View File

@@ -1,12 +1,28 @@
namespace ScadaLink.DataConnectionLayer.Adapters;
/// <summary>
/// Configuration options for OPC UA connections, parsed from connection details JSON.
/// All values have defaults matching the OPC Foundation SDK's typical settings.
/// </summary>
public record OpcUaConnectionOptions(
int SessionTimeoutMs = 60000,
int OperationTimeoutMs = 15000,
int PublishingIntervalMs = 1000,
int KeepAliveCount = 10,
int LifetimeCount = 30,
int MaxNotificationsPerPublish = 100,
int SamplingIntervalMs = 1000,
int QueueSize = 10,
string SecurityMode = "None",
bool AutoAcceptUntrustedCerts = true);
/// <summary>
/// WP-7: Abstraction over OPC UA client library for testability.
/// The real implementation would wrap an OPC UA SDK (e.g., OPC Foundation .NET Standard Library).
/// </summary>
public interface IOpcUaClient : IAsyncDisposable
{
Task ConnectAsync(string endpointUrl, CancellationToken cancellationToken = default);
Task ConnectAsync(string endpointUrl, OpcUaConnectionOptions? options = null, CancellationToken cancellationToken = default);
Task DisconnectAsync(CancellationToken cancellationToken = default);
bool IsConnected { get; }
@@ -24,6 +40,12 @@ public interface IOpcUaClient : IAsyncDisposable
string nodeId, CancellationToken cancellationToken = default);
Task<uint> WriteValueAsync(string nodeId, object? value, CancellationToken cancellationToken = default);
/// <summary>
/// Raised when the OPC UA session detects a keep-alive failure or the server
/// becomes unreachable. The adapter layer uses this to trigger reconnection.
/// </summary>
event Action? ConnectionLost;
}
/// <summary>
@@ -50,8 +72,11 @@ public class DefaultOpcUaClientFactory : IOpcUaClientFactory
internal class StubOpcUaClient : IOpcUaClient
{
public bool IsConnected { get; private set; }
#pragma warning disable CS0067
public event Action? ConnectionLost;
#pragma warning restore CS0067
public Task ConnectAsync(string endpointUrl, CancellationToken cancellationToken = default)
public Task ConnectAsync(string endpointUrl, OpcUaConnectionOptions? options = null, CancellationToken cancellationToken = default)
{
IsConnected = true;
return Task.CompletedTask;

View File

@@ -23,6 +23,7 @@ public class LmxProxyDataConnection : IDataConnection
private ConnectionHealth _status = ConnectionHealth.Disconnected;
private readonly Dictionary<string, ILmxSubscription> _subscriptions = new();
private volatile bool _disconnectFired;
public LmxProxyDataConnection(ILmxProxyClientFactory clientFactory, ILogger<LmxProxyDataConnection> logger)
{
@@ -31,6 +32,7 @@ public class LmxProxyDataConnection : IDataConnection
}
public ConnectionHealth Status => _status;
public event Action? Disconnected;
public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
{
@@ -39,11 +41,15 @@ public class LmxProxyDataConnection : IDataConnection
_port = port;
connectionDetails.TryGetValue("ApiKey", out var apiKey);
var samplingIntervalMs = connectionDetails.TryGetValue("SamplingIntervalMs", out var sampStr) && int.TryParse(sampStr, out var samp) ? samp : 0;
var useTls = connectionDetails.TryGetValue("UseTls", out var tlsStr) && bool.TryParse(tlsStr, out var tls) && tls;
_status = ConnectionHealth.Connecting;
_client = _clientFactory.Create(_host, _port, apiKey);
_client = _clientFactory.Create(_host, _port, apiKey, samplingIntervalMs, useTls);
await _client.ConnectAsync(cancellationToken);
_status = ConnectionHealth.Connected;
_disconnectFired = false;
_logger.LogInformation("LmxProxy connected to {Host}:{Port}", _host, _port);
}
@@ -62,13 +68,22 @@ public class LmxProxyDataConnection : IDataConnection
{
EnsureConnected();
var vtq = await _client!.ReadAsync(tagPath, cancellationToken);
var quality = MapQuality(vtq.Quality);
var tagValue = new TagValue(vtq.Value, quality, new DateTimeOffset(vtq.TimestampUtc, TimeSpan.Zero));
try
{
var vtq = await _client!.ReadAsync(tagPath, cancellationToken);
var quality = MapQuality(vtq.Quality);
var tagValue = new TagValue(vtq.Value, quality, new DateTimeOffset(vtq.TimestampUtc, TimeSpan.Zero));
return vtq.Quality == LmxQuality.Bad
? new ReadResult(false, tagValue, "LmxProxy read returned bad quality")
: new ReadResult(true, tagValue, null);
return vtq.Quality == LmxQuality.Bad
? new ReadResult(false, tagValue, "LmxProxy read returned bad quality")
: new ReadResult(true, tagValue, null);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "LmxProxy read failed for {TagPath} — connection may be lost", tagPath);
RaiseDisconnected();
throw;
}
}
public async Task<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync(IEnumerable<string> tagPaths, CancellationToken cancellationToken = default)
@@ -161,6 +176,11 @@ public class LmxProxyDataConnection : IDataConnection
var quality = MapQuality(vtq.Quality);
callback(path, new TagValue(vtq.Value, quality, new DateTimeOffset(vtq.TimestampUtc, TimeSpan.Zero)));
},
onStreamError: () =>
{
_logger.LogWarning("LmxProxy subscription stream ended unexpectedly for {TagPath}", tagPath);
RaiseDisconnected();
},
cancellationToken);
var subscriptionId = Guid.NewGuid().ToString("N");
@@ -199,6 +219,19 @@ public class LmxProxyDataConnection : IDataConnection
throw new InvalidOperationException("LmxProxy client is not connected.");
}
/// <summary>
/// Marks the connection as disconnected and fires the Disconnected event once.
/// Thread-safe: only the first caller triggers the event.
/// </summary>
private void RaiseDisconnected()
{
if (_disconnectFired) return;
_disconnectFired = true;
_status = ConnectionHealth.Disconnected;
_logger.LogWarning("LmxProxy connection to {Host}:{Port} lost", _host, _port);
Disconnected?.Invoke();
}
private static QualityCode MapQuality(LmxQuality quality) => quality switch
{
LmxQuality.Good => QualityCode.Good,

View File

@@ -33,29 +33,62 @@ public class OpcUaDataConnection : IDataConnection
_logger = logger;
}
private volatile bool _disconnectFired;
public ConnectionHealth Status => _status;
public event Action? Disconnected;
public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
{
// Support both "endpoint" (from JSON config) and "EndpointUrl" (programmatic)
_endpointUrl = connectionDetails.TryGetValue("endpoint", out var url)
? url
: connectionDetails.TryGetValue("EndpointUrl", out var url2)
? url2
: "opc.tcp://localhost:4840";
var options = new OpcUaConnectionOptions(
SessionTimeoutMs: ParseInt(connectionDetails, "SessionTimeoutMs", 60000),
OperationTimeoutMs: ParseInt(connectionDetails, "OperationTimeoutMs", 15000),
PublishingIntervalMs: ParseInt(connectionDetails, "PublishingIntervalMs", 1000),
KeepAliveCount: ParseInt(connectionDetails, "KeepAliveCount", 10),
LifetimeCount: ParseInt(connectionDetails, "LifetimeCount", 30),
MaxNotificationsPerPublish: ParseInt(connectionDetails, "MaxNotificationsPerPublish", 100),
SamplingIntervalMs: ParseInt(connectionDetails, "SamplingIntervalMs", 1000),
QueueSize: ParseInt(connectionDetails, "QueueSize", 10),
SecurityMode: connectionDetails.TryGetValue("SecurityMode", out var secMode) ? secMode : "None",
AutoAcceptUntrustedCerts: ParseBool(connectionDetails, "AutoAcceptUntrustedCerts", true));
_status = ConnectionHealth.Connecting;
_client = _clientFactory.Create();
await _client.ConnectAsync(_endpointUrl, cancellationToken);
_client.ConnectionLost += OnClientConnectionLost;
await _client.ConnectAsync(_endpointUrl, options, cancellationToken);
_status = ConnectionHealth.Connected;
_disconnectFired = false;
_logger.LogInformation("OPC UA connected to {Endpoint}", _endpointUrl);
}
internal static int ParseInt(IDictionary<string, string> d, string key, int defaultValue)
{
return d.TryGetValue(key, out var str) && int.TryParse(str, out var val) ? val : defaultValue;
}
internal static bool ParseBool(IDictionary<string, string> d, string key, bool defaultValue)
{
return d.TryGetValue(key, out var str) && bool.TryParse(str, out var val) ? val : defaultValue;
}
private void OnClientConnectionLost()
{
RaiseDisconnected();
}
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
{
if (_client != null)
{
_client.ConnectionLost -= OnClientConnectionLost;
await _client.DisconnectAsync(cancellationToken);
_status = ConnectionHealth.Disconnected;
_logger.LogInformation("OPC UA disconnected from {Endpoint}", _endpointUrl);
@@ -92,13 +125,22 @@ public class OpcUaDataConnection : IDataConnection
{
EnsureConnected();
var (value, timestamp, statusCode) = await _client!.ReadValueAsync(tagPath, cancellationToken);
var quality = MapStatusCode(statusCode);
try
{
var (value, timestamp, statusCode) = await _client!.ReadValueAsync(tagPath, cancellationToken);
var quality = MapStatusCode(statusCode);
if (quality == QualityCode.Bad)
return new ReadResult(false, null, $"OPC UA read returned bad status: 0x{statusCode:X8}");
if (quality == QualityCode.Bad)
return new ReadResult(false, null, $"OPC UA read returned bad status: 0x{statusCode:X8}");
return new ReadResult(true, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)), null);
return new ReadResult(true, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)), null);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "OPC UA read failed for {TagPath} — connection may be lost", tagPath);
RaiseDisconnected();
throw;
}
}
public async Task<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync(IEnumerable<string> tagPaths, CancellationToken cancellationToken = default)
@@ -163,6 +205,7 @@ public class OpcUaDataConnection : IDataConnection
{
if (_client != null)
{
_client.ConnectionLost -= OnClientConnectionLost;
await _client.DisposeAsync();
_client = null;
}
@@ -175,6 +218,19 @@ public class OpcUaDataConnection : IDataConnection
throw new InvalidOperationException("OPC UA client is not connected.");
}
/// <summary>
/// Marks the connection as disconnected and fires the Disconnected event once.
/// Thread-safe: only the first caller triggers the event.
/// </summary>
private void RaiseDisconnected()
{
if (_disconnectFired) return;
_disconnectFired = true;
_status = ConnectionHealth.Disconnected;
_logger.LogWarning("OPC UA connection to {Endpoint} lost", _endpointUrl);
Disconnected?.Invoke();
}
/// <summary>
/// Maps OPC UA StatusCode to QualityCode.
/// StatusCode 0 = Good, high bit set = Bad, otherwise Uncertain.

View File

@@ -14,25 +14,31 @@ internal class RealLmxProxyClient : ILmxProxyClient
private readonly string _host;
private readonly int _port;
private readonly string? _apiKey;
private readonly int _samplingIntervalMs;
private readonly bool _useTls;
private GrpcChannel? _channel;
private ScadaService.ScadaServiceClient? _client;
private string? _sessionId;
private Metadata? _headers;
public RealLmxProxyClient(string host, int port, string? apiKey)
public RealLmxProxyClient(string host, int port, string? apiKey, int samplingIntervalMs = 0, bool useTls = false)
{
_host = host;
_port = port;
_apiKey = apiKey;
_samplingIntervalMs = samplingIntervalMs;
_useTls = useTls;
}
public bool IsConnected => _client != null && !string.IsNullOrEmpty(_sessionId);
public async Task ConnectAsync(CancellationToken cancellationToken = default)
{
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
if (!_useTls)
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
_channel = GrpcChannel.ForAddress($"http://{_host}:{_port}");
var scheme = _useTls ? "https" : "http";
_channel = GrpcChannel.ForAddress($"{scheme}://{_host}:{_port}");
_client = new ScadaService.ScadaServiceClient(_channel);
_headers = new Metadata();
@@ -111,13 +117,13 @@ internal class RealLmxProxyClient : ILmxProxyClient
throw new InvalidOperationException($"WriteBatch failed: {response.Message}");
}
public Task<ILmxSubscription> SubscribeAsync(IEnumerable<string> addresses, Action<string, LmxVtq> onUpdate, CancellationToken cancellationToken = default)
public Task<ILmxSubscription> SubscribeAsync(IEnumerable<string> addresses, Action<string, LmxVtq> onUpdate, Action? onStreamError = null, CancellationToken cancellationToken = default)
{
EnsureConnected();
var tags = addresses.ToList();
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var request = new SubscribeRequest { SessionId = _sessionId!, SamplingMs = 0 };
var request = new SubscribeRequest { SessionId = _sessionId!, SamplingMs = _samplingIntervalMs };
request.Tags.AddRange(tags);
var call = _client!.Subscribe(request, _headers, cancellationToken: cts.Token);
@@ -131,9 +137,18 @@ internal class RealLmxProxyClient : ILmxProxyClient
var msg = call.ResponseStream.Current;
onUpdate(msg.Tag, ConvertVtq(msg));
}
// Stream ended normally (server closed) — treat as disconnect
_sessionId = null;
onStreamError?.Invoke();
}
catch (OperationCanceledException) { }
catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled) { }
catch (RpcException)
{
// gRPC error (server offline, network failure) — signal disconnect
_sessionId = null;
onStreamError?.Invoke();
}
}, cts.Token);
return Task.FromResult<ILmxSubscription>(new CtsSubscription(cts));
@@ -191,6 +206,6 @@ internal class RealLmxProxyClient : ILmxProxyClient
/// </summary>
public class RealLmxProxyClientFactory : ILmxProxyClientFactory
{
public ILmxProxyClient Create(string host, int port, string? apiKey)
=> new RealLmxProxyClient(host, port, apiKey);
public ILmxProxyClient Create(string host, int port, string? apiKey, int samplingIntervalMs = 0, bool useTls = false)
=> new RealLmxProxyClient(host, port, apiKey, samplingIntervalMs, useTls);
}

View File

@@ -14,31 +14,44 @@ public class RealOpcUaClient : IOpcUaClient
private Subscription? _subscription;
private readonly Dictionary<string, MonitoredItem> _monitoredItems = new();
private readonly Dictionary<string, Action<string, object?, DateTime, uint>> _callbacks = new();
private volatile bool _connectionLostFired;
private OpcUaConnectionOptions _options = new();
public bool IsConnected => _session?.Connected ?? false;
public event Action? ConnectionLost;
public async Task ConnectAsync(string endpointUrl, CancellationToken cancellationToken = default)
public async Task ConnectAsync(string endpointUrl, OpcUaConnectionOptions? options = null, CancellationToken cancellationToken = default)
{
var opts = options ?? new OpcUaConnectionOptions();
var preferredSecurityMode = opts.SecurityMode?.ToUpperInvariant() switch
{
"SIGN" => MessageSecurityMode.Sign,
"SIGNANDENCRYPT" => MessageSecurityMode.SignAndEncrypt,
_ => MessageSecurityMode.None
};
var appConfig = new ApplicationConfiguration
{
ApplicationName = "ScadaLink-DCL",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
AutoAcceptUntrustedCertificates = true,
AutoAcceptUntrustedCertificates = opts.AutoAcceptUntrustedCerts,
ApplicationCertificate = new CertificateIdentifier(),
TrustedIssuerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "issuers") },
TrustedPeerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "trusted") },
RejectedCertificateStore = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "rejected") }
},
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = opts.SessionTimeoutMs },
TransportQuotas = new TransportQuotas { OperationTimeout = opts.OperationTimeoutMs }
};
await appConfig.ValidateAsync(ApplicationType.Client);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
if (opts.AutoAcceptUntrustedCerts)
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
// Discover endpoints from the server, pick the no-security one
// Discover endpoints from the server, pick the preferred security mode
EndpointDescription? endpoint;
try
{
@@ -49,7 +62,7 @@ public class RealOpcUaClient : IOpcUaClient
var endpoints = discoveryClient.GetEndpoints(null);
#pragma warning restore CS0618
endpoint = endpoints
.Where(e => e.SecurityMode == MessageSecurityMode.None)
.Where(e => e.SecurityMode == preferredSecurityMode)
.FirstOrDefault() ?? endpoints.FirstOrDefault();
}
catch
@@ -66,17 +79,24 @@ public class RealOpcUaClient : IOpcUaClient
#pragma warning restore CS0618
_session = await sessionFactory.CreateAsync(
appConfig, configuredEndpoint, false,
"ScadaLink-DCL-Session", 60000, null, null, cancellationToken);
"ScadaLink-DCL-Session", (uint)opts.SessionTimeoutMs, null, null, cancellationToken);
// Detect server going offline via keep-alive failures
_connectionLostFired = false;
_session.KeepAlive += OnSessionKeepAlive;
// Store options for monitored item creation
_options = opts;
// Create a default subscription for all monitored items
_subscription = new Subscription(_session.DefaultSubscription)
{
DisplayName = "ScadaLink",
PublishingEnabled = true,
PublishingInterval = 1000,
KeepAliveCount = 10,
LifetimeCount = 30,
MaxNotificationsPerPublish = 100
PublishingInterval = opts.PublishingIntervalMs,
KeepAliveCount = (uint)opts.KeepAliveCount,
LifetimeCount = (uint)opts.LifetimeCount,
MaxNotificationsPerPublish = (uint)opts.MaxNotificationsPerPublish
};
_session.AddSubscription(_subscription);
@@ -92,6 +112,7 @@ public class RealOpcUaClient : IOpcUaClient
}
if (_session != null)
{
_session.KeepAlive -= OnSessionKeepAlive;
await _session.CloseAsync(cancellationToken);
_session = null;
}
@@ -112,8 +133,8 @@ public class RealOpcUaClient : IOpcUaClient
DisplayName = nodeId,
StartNodeId = nodeId,
AttributeId = Attributes.Value,
SamplingInterval = 1000,
QueueSize = 10,
SamplingInterval = _options.SamplingIntervalMs,
QueueSize = (uint)_options.QueueSize,
DiscardOldest = true
};
@@ -188,6 +209,20 @@ public class RealOpcUaClient : IOpcUaClient
return response.Results[0].Code;
}
/// <summary>
/// Called by the OPC UA SDK when a keep-alive response arrives (or fails).
/// When CurrentState is bad, the server is unreachable.
/// </summary>
private void OnSessionKeepAlive(ISession session, KeepAliveEventArgs e)
{
if (ServiceResult.IsBad(e.Status))
{
if (_connectionLostFired) return;
_connectionLostFired = true;
ConnectionLost?.Invoke();
}
}
public async ValueTask DisposeAsync()
{
await DisconnectAsync();