diff --git a/src/NATS.Client.Core/Commands/CommandWriter.cs b/src/NATS.Client.Core/Commands/CommandWriter.cs index 5ec709a41..536775705 100644 --- a/src/NATS.Client.Core/Commands/CommandWriter.cs +++ b/src/NATS.Client.Core/Commands/CommandWriter.cs @@ -49,7 +49,7 @@ internal sealed class CommandWriter : IAsyncDisposable private readonly PipeWriter _pipeWriter; private readonly SemaphoreSlim _semLock = new(1); private readonly PartialSendFailureCounter _partialSendFailureCounter = new(); - private ISocketConnection? _socketConnection; + private SocketConnectionWrapper? _socketConnection; private Task? _flushTask; private Task? _readerLoopTask; private CancellationTokenSource? _ctsReader; @@ -87,7 +87,7 @@ public CommandWriter(string name, NatsConnection connection, ObjectPool pool, Na _logger.LogDebug(NatsLogEvents.Buffer, "Created {Name}", _name); } - public void Reset(ISocketConnection socketConnection) + public void Reset(SocketConnectionWrapper socketConnection) { _logger.LogDebug(NatsLogEvents.Buffer, "Resetting {Name}", _name); @@ -481,7 +481,7 @@ internal async Task TestStallFlushAsync(TimeSpan timeSpan, CancellationToken can private static async Task ReaderLoopAsync( ILogger logger, - ISocketConnection connection, + SocketConnectionWrapper socketConnection, PipeReader pipeReader, Channel channelSize, Memory consolidateMem, @@ -535,7 +535,7 @@ private static async Task ReaderLoopAsync( stopwatch.Restart(); } - sent = await connection.SendAsync(sendMem).ConfigureAwait(false); + sent = await socketConnection.SendAsync(sendMem).ConfigureAwait(false); if (trace) { @@ -664,7 +664,7 @@ private static async Task ReaderLoopAsync( // We signal the connection to disconnect, which will trigger a reconnect // in the connection loop. This is necessary because the connection may // be half-open, and we can't rely on the reader loop to detect that. - connection.SignalDisconnected(e); + socketConnection.SignalDisconnected(e); } catch (Exception e1) { diff --git a/src/NATS.Client.Core/Commands/PriorityCommandWriter.cs b/src/NATS.Client.Core/Commands/PriorityCommandWriter.cs index 627c9afc9..1faf9aa64 100644 --- a/src/NATS.Client.Core/Commands/PriorityCommandWriter.cs +++ b/src/NATS.Client.Core/Commands/PriorityCommandWriter.cs @@ -6,7 +6,7 @@ internal sealed class PriorityCommandWriter : IAsyncDisposable { private int _disposed; - public PriorityCommandWriter(NatsConnection connection, ObjectPool pool, ISocketConnection socketConnection, NatsOpts opts, ConnectionStatsCounter counter, Action enqueuePing) + public PriorityCommandWriter(NatsConnection connection, ObjectPool pool, SocketConnectionWrapper socketConnection, NatsOpts opts, ConnectionStatsCounter counter, Action enqueuePing) { CommandWriter = new CommandWriter("init", connection, pool, opts, counter, enqueuePing); CommandWriter.Reset(socketConnection); diff --git a/src/NATS.Client.Core/INatsConnection.cs b/src/NATS.Client.Core/INatsConnection.cs index a2b75f4ed..a075fa7d3 100644 --- a/src/NATS.Client.Core/INatsConnection.cs +++ b/src/NATS.Client.Core/INatsConnection.cs @@ -64,7 +64,7 @@ public interface INatsConnection : INatsClient /// /// Hook when socket is available. /// - Func>? OnSocketAvailableAsync { get; set; } + Func>? OnSocketAvailableAsync { get; set; } /// /// Publishes a serializable message payload to the given subject name, optionally supplying a reply subject. diff --git a/src/NATS.Client.Core/INatsSocketConnection.cs b/src/NATS.Client.Core/INatsSocketConnection.cs new file mode 100644 index 000000000..87386dc76 --- /dev/null +++ b/src/NATS.Client.Core/INatsSocketConnection.cs @@ -0,0 +1,56 @@ +using System.Net.Sockets; + +namespace NATS.Client.Core; + +/// +/// Represents a socket connection to a NATS server. +/// +/// +/// This interface defines the contract for low-level socket connections to NATS servers, +/// providing methods for sending and receiving data and disposing the socket. +/// Disposing should attempt to perform graceful shutdown of the socket. +/// +public interface INatsSocketConnection : IAsyncDisposable +{ + /// + /// Sends data asynchronously over the connection. + /// + /// The buffer containing the data to send. + /// A task representing the asynchronous send operation with the number of bytes sent. + ValueTask SendAsync(ReadOnlyMemory buffer); + + /// + /// Receives data asynchronously from the connection. + /// + /// The buffer to store the received data. + /// A task representing the asynchronous receive operation with the number of bytes received. + ValueTask ReceiveAsync(Memory buffer); +} + +/// +/// Obsolete, use instead +/// +[Obsolete("This interface is obsolete, use INatsSocketConnection instead.", error: false)] +public interface ISocketConnection : INatsSocketConnection +{ + Task WaitForClosed { get; } + + void SignalDisconnected(Exception exception); + + ValueTask AbortConnectionAsync(CancellationToken cancellationToken); +} + +/// +/// Represents a NATS socket connection that can be upgraded to use TLS. +/// +/// +/// This interface extends to provide access to the underlying +/// socket that is used to create a SslStream. +/// +public interface INatsTlsUpgradeableSocketConnection : INatsSocketConnection +{ + /// + /// Gets the underlying Socket instance for the connection. + /// + Socket Socket { get; } +} diff --git a/src/NATS.Client.Core/INatsSocketConnectionFactory.cs b/src/NATS.Client.Core/INatsSocketConnectionFactory.cs new file mode 100644 index 000000000..903b33453 --- /dev/null +++ b/src/NATS.Client.Core/INatsSocketConnectionFactory.cs @@ -0,0 +1,23 @@ +namespace NATS.Client.Core; + +/// +/// Factory interface for creating socket connections to NATS servers. +/// +/// +/// This interface abstracts the creation of socket connections to NATS servers, +/// allowing for custom implementations of connection logic. +/// +public interface INatsSocketConnectionFactory +{ + /// + /// Establishes a connection to a NATS server at the specified URI. + /// + /// The URI of the NATS server to connect to. + /// The Options associated with the NATS Client. + /// A token to cancel the asynchronous operation. + /// + /// A task that represents the asynchronous connect operation. The task result contains + /// the socket connection. + /// + ValueTask ConnectAsync(Uri uri, NatsOpts opts, CancellationToken cancellationToken); +} diff --git a/src/NATS.Client.Core/ISocketConnection.cs b/src/NATS.Client.Core/ISocketConnection.cs deleted file mode 100644 index 855b6502c..000000000 --- a/src/NATS.Client.Core/ISocketConnection.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace NATS.Client.Core; - -public interface ISocketConnection : IAsyncDisposable -{ - public Task WaitForClosed { get; } - - public ValueTask SendAsync(ReadOnlyMemory buffer); - - public ValueTask ReceiveAsync(Memory buffer); - - public ValueTask AbortConnectionAsync(CancellationToken cancellationToken); - - public void SignalDisconnected(Exception exception); -} diff --git a/src/NATS.Client.Core/Internal/NatsReadProtocolProcessor.cs b/src/NATS.Client.Core/Internal/NatsReadProtocolProcessor.cs index 813096a1e..469b05193 100644 --- a/src/NATS.Client.Core/Internal/NatsReadProtocolProcessor.cs +++ b/src/NATS.Client.Core/Internal/NatsReadProtocolProcessor.cs @@ -24,7 +24,7 @@ internal sealed class NatsReadProtocolProcessor : IAsyncDisposable private readonly bool _trace; private int _disposed; - public NatsReadProtocolProcessor(ISocketConnection socketConnection, NatsConnection connection, TaskCompletionSource waitForInfoSignal, TaskCompletionSource waitForPongOrErrorSignal, Task infoParsed) + public NatsReadProtocolProcessor(SocketConnectionWrapper socketConnection, NatsConnection connection, TaskCompletionSource waitForInfoSignal, TaskCompletionSource waitForPongOrErrorSignal, Task infoParsed) { _connection = connection; _logger = connection.Opts.LoggerFactory.CreateLogger(); diff --git a/src/NATS.Client.Core/Internal/NatsUri.cs b/src/NATS.Client.Core/Internal/NatsUri.cs index 02144d50c..c3b033750 100644 --- a/src/NATS.Client.Core/Internal/NatsUri.cs +++ b/src/NATS.Client.Core/Internal/NatsUri.cs @@ -1,6 +1,6 @@ namespace NATS.Client.Core.Internal; -internal sealed class NatsUri : IEquatable +internal sealed record NatsUri { public const string DefaultScheme = "nats"; @@ -57,7 +57,7 @@ public NatsUri(string urlString, bool isSeed, string defaultScheme = DefaultSche _redacted = IsWebSocket && Uri.AbsolutePath != "/" ? uriBuilder.Uri.ToString() : uriBuilder.Uri.ToString().Trim('/'); } - public Uri Uri { get; } + public Uri Uri { get; init; } public bool IsSeed { get; } @@ -69,28 +69,5 @@ public NatsUri(string urlString, bool isSeed, string defaultScheme = DefaultSche public int Port => Uri.Port; - public NatsUri CloneWith(string host, int? port = default) - { - var newUri = new UriBuilder(Uri) - { - Host = host, - Port = port ?? Port, - }.Uri.ToString(); - - return new NatsUri(newUri, IsSeed); - } - public override string ToString() => _redacted; - - public override int GetHashCode() => Uri.GetHashCode(); - - public bool Equals(NatsUri? other) - { - if (other == null) - return false; - if (ReferenceEquals(this, other)) - return true; - - return Uri.Equals(other.Uri); - } } diff --git a/src/NATS.Client.Core/Internal/ServerInfo.cs b/src/NATS.Client.Core/Internal/ServerInfo.cs index ce66aa3f1..3fb243e7f 100644 --- a/src/NATS.Client.Core/Internal/ServerInfo.cs +++ b/src/NATS.Client.Core/Internal/ServerInfo.cs @@ -7,71 +7,71 @@ namespace NATS.Client.Core.Internal; internal sealed record ServerInfo : INatsServerInfo { [JsonPropertyName("server_id")] - public string Id { get; set; } = string.Empty; + public string Id { get; init; } = string.Empty; [JsonPropertyName("server_name")] - public string Name { get; set; } = string.Empty; + public string Name { get; init; } = string.Empty; [JsonPropertyName("version")] - public string Version { get; set; } = string.Empty; + public string Version { get; init; } = string.Empty; [JsonPropertyName("proto")] - public long ProtocolVersion { get; set; } + public long ProtocolVersion { get; init; } [JsonPropertyName("git_commit")] - public string GitCommit { get; set; } = string.Empty; + public string GitCommit { get; init; } = string.Empty; [JsonPropertyName("go")] - public string GoVersion { get; set; } = string.Empty; + public string GoVersion { get; init; } = string.Empty; [JsonPropertyName("host")] - public string Host { get; set; } = string.Empty; + public string Host { get; init; } = string.Empty; [JsonPropertyName("port")] - public int Port { get; set; } + public int Port { get; init; } [JsonPropertyName("headers")] - public bool HeadersSupported { get; set; } + public bool HeadersSupported { get; init; } [JsonPropertyName("auth_required")] - public bool AuthRequired { get; set; } + public bool AuthRequired { get; init; } [JsonPropertyName("tls_required")] - public bool TlsRequired { get; set; } + public bool TlsRequired { get; init; } [JsonPropertyName("tls_verify")] - public bool TlsVerify { get; set; } + public bool TlsVerify { get; init; } [JsonPropertyName("tls_available")] - public bool TlsAvailable { get; set; } + public bool TlsAvailable { get; init; } [JsonPropertyName("max_payload")] - public int MaxPayload { get; set; } + public int MaxPayload { get; init; } [JsonPropertyName("jetstream")] - public bool JetStreamAvailable { get; set; } + public bool JetStreamAvailable { get; init; } [JsonPropertyName("client_id")] - public ulong ClientId { get; set; } + public ulong ClientId { get; init; } [JsonPropertyName("client_ip")] - public string ClientIp { get; set; } = string.Empty; + public string ClientIp { get; init; } = string.Empty; [JsonPropertyName("nonce")] - public string? Nonce { get; set; } + public string? Nonce { get; init; } [JsonPropertyName("cluster")] - public string? Cluster { get; set; } + public string? Cluster { get; init; } [JsonPropertyName("cluster_dynamic")] - public bool ClusterDynamic { get; set; } + public bool ClusterDynamic { get; init; } [JsonPropertyName("connect_urls")] - public string[]? ClientConnectUrls { get; set; } + public string[]? ClientConnectUrls { get; init; } [JsonPropertyName("ws_connect_urls")] - public string[]? WebSocketConnectUrls { get; set; } + public string[]? WebSocketConnectUrls { get; init; } [JsonPropertyName("ldm")] - public bool LameDuckMode { get; set; } + public bool LameDuckMode { get; init; } } diff --git a/src/NATS.Client.Core/Internal/SocketConnectionWrapper.cs b/src/NATS.Client.Core/Internal/SocketConnectionWrapper.cs new file mode 100644 index 000000000..14f642cd5 --- /dev/null +++ b/src/NATS.Client.Core/Internal/SocketConnectionWrapper.cs @@ -0,0 +1,56 @@ +namespace NATS.Client.Core.Internal; + +// wraps INatsSocketConnection and signals on disconnect +internal record SocketConnectionWrapper(INatsSocketConnection InnerSocket) : INatsSocketConnection +{ + private readonly TaskCompletionSource _waitForClosedSource = +#if NETSTANDARD + new(TaskCreationOptions.None); +#else + new(); +#endif + + private readonly SemaphoreSlim _sem = new(1); + + public Task WaitForClosed => _waitForClosedSource.Task; + + public void SignalDisconnected(Exception exception) + { + // guard with semaphore so this doesn't race DisposeAsync + _sem.Wait(); + try + { + _waitForClosedSource.TrySetException(exception); + } + finally + { + _sem.Release(1); + } + } + + public ValueTask SendAsync(ReadOnlyMemory buffer) => InnerSocket.SendAsync(buffer); + + public ValueTask ReceiveAsync(Memory buffer) => InnerSocket.ReceiveAsync(buffer); + + public async ValueTask DisposeAsync() + { + // guard with semaphore in case SignalDisconnected races + await _sem.WaitAsync().ConfigureAwait(false); + try + { + // dispose first, then signal + try + { + await InnerSocket.DisposeAsync().ConfigureAwait(false); + } + finally + { + _waitForClosedSource.TrySetResult(); + } + } + finally + { + _sem.Release(1); + } + } +} diff --git a/src/NATS.Client.Core/Internal/SocketReader.cs b/src/NATS.Client.Core/Internal/SocketReader.cs index 2aa1feb76..93f41b8ca 100644 --- a/src/NATS.Client.Core/Internal/SocketReader.cs +++ b/src/NATS.Client.Core/Internal/SocketReader.cs @@ -14,11 +14,11 @@ internal sealed class SocketReader private readonly Stopwatch _stopwatch = new Stopwatch(); private readonly ILogger _logger; private readonly bool _isTraceLogging; - private ISocketConnection _socketConnection; + private readonly SocketConnectionWrapper _socketConnection; private Memory _availableMemory; - public SocketReader(ISocketConnection socketConnection, int minimumBufferSize, ConnectionStatsCounter counter, ILoggerFactory loggerFactory) + public SocketReader(SocketConnectionWrapper socketConnection, int minimumBufferSize, ConnectionStatsCounter counter, ILoggerFactory loggerFactory) { _socketConnection = socketConnection; _minimumBufferSize = minimumBufferSize; diff --git a/src/NATS.Client.Core/Internal/SslStreamConnection.cs b/src/NATS.Client.Core/Internal/SslStreamConnection.cs index adeda8871..2cf4049b0 100644 --- a/src/NATS.Client.Core/Internal/SslStreamConnection.cs +++ b/src/NATS.Client.Core/Internal/SslStreamConnection.cs @@ -12,26 +12,20 @@ namespace NATS.Client.Core.Internal; -internal sealed class SslStreamConnection : ISocketConnection +internal sealed class SslStreamConnection : INatsSocketConnection { - private readonly ILogger _logger; - private readonly Socket _socket; - private readonly TaskCompletionSource _waitForClosedSource; + private readonly INatsTlsUpgradeableSocketConnection _socketConnection; private readonly NatsTlsOpts _tlsOpts; private readonly CancellationTokenSource _closeCts = new(); private int _disposed; private SslStream? _sslStream; - public SslStreamConnection(ILogger logger, Socket socket, NatsTlsOpts tlsOpts, TaskCompletionSource waitForClosedSource) + public SslStreamConnection(INatsTlsUpgradeableSocketConnection socketConnection, NatsTlsOpts tlsOpts) { - _logger = logger; - _socket = socket; + _socketConnection = socketConnection; _tlsOpts = tlsOpts; - _waitForClosedSource = waitForClosedSource; } - public Task WaitForClosed => _waitForClosedSource.Task; - public async ValueTask DisposeAsync() { if (Interlocked.Increment(ref _disposed) == 1) @@ -43,7 +37,6 @@ public async ValueTask DisposeAsync() #else _closeCts.Cancel(); #endif - _waitForClosedSource.TrySetCanceled(); } catch { @@ -52,12 +45,27 @@ public async ValueTask DisposeAsync() if (_sslStream != null) { + try + { +#if NETSTANDARD2_0 + _sslStream.Close(); +#else + await _sslStream.ShutdownAsync().ConfigureAwait(false); +#endif + } + catch + { + // ignored + } + #if NETSTANDARD2_0 _sslStream.Dispose(); #else await _sslStream.DisposeAsync().ConfigureAwait(false); #endif } + + await _socketConnection.DisposeAsync().ConfigureAwait(false); } } @@ -106,22 +114,16 @@ public async ValueTask AbortConnectionAsync(CancellationToken cancellationToken) } } - // when catch SocketClosedException, call this method. - public void SignalDisconnected(Exception exception) - { - _waitForClosedSource.TrySetResult(exception); - } - public async Task AuthenticateAsClientAsync(NatsUri uri, TimeSpan timeout) { - var options = await _tlsOpts.AuthenticateAsClientOptionsAsync(uri).ConfigureAwait(true); + var options = await _tlsOpts.AuthenticateAsClientOptionsAsync(uri.Uri).ConfigureAwait(true); #if NETSTANDARD2_0 if (_sslStream != null) _sslStream.Dispose(); _sslStream = new SslStream( - innerStream: new NetworkStream(_socket, true), + innerStream: new NetworkStream(_socketConnection.Socket, true), leaveInnerStreamOpen: false, userCertificateSelectionCallback: options.LocalCertificateSelectionCallback, userCertificateValidationCallback: options.RemoteCertificateValidationCallback); @@ -135,13 +137,13 @@ await _sslStream.AuthenticateAsClientAsync( } catch (AuthenticationException ex) { - throw new NatsException($"TLS authentication failed", ex); + throw new NatsException("TLS authentication failed", ex); } #else if (_sslStream != null) await _sslStream.DisposeAsync().ConfigureAwait(false); - _sslStream = new SslStream(innerStream: new NetworkStream(_socket, true)); + _sslStream = new SslStream(innerStream: new NetworkStream(_socketConnection.Socket, true)); try { using var cts = new CancellationTokenSource(timeout); @@ -153,7 +155,7 @@ await _sslStream.AuthenticateAsClientAsync( } catch (AuthenticationException ex) { - throw new NatsException($"TLS authentication failed", ex); + throw new NatsException("TLS authentication failed", ex); } #endif } diff --git a/src/NATS.Client.Core/Internal/TcpConnection.cs b/src/NATS.Client.Core/Internal/TcpConnection.cs index c955e0b3c..479e35da2 100644 --- a/src/NATS.Client.Core/Internal/TcpConnection.cs +++ b/src/NATS.Client.Core/Internal/TcpConnection.cs @@ -1,8 +1,6 @@ -using System.Net.Security; using System.Net.Sockets; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using Microsoft.Extensions.Logging; namespace NATS.Client.Core.Internal; @@ -14,71 +12,39 @@ public SocketClosedException(Exception? innerException) } } -internal sealed class TcpConnection : ISocketConnection +internal sealed class TcpConnection : INatsTlsUpgradeableSocketConnection { - private readonly ILogger _logger; - private readonly Socket _socket; - private readonly TaskCompletionSource _waitForClosedSource = new(); + private readonly NatsOpts _natsOpts; private int _disposed; - public TcpConnection(ILogger logger) + public TcpConnection(NatsOpts natsOpts) { - _logger = logger; - _socket = new Socket(Socket.OSSupportsIPv6 ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + _natsOpts = natsOpts; + Socket = new Socket(Socket.OSSupportsIPv6 ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); if (Socket.OSSupportsIPv6) { - _socket.DualMode = true; + Socket.DualMode = true; } - _socket.NoDelay = true; + Socket.NoDelay = true; } - public Task WaitForClosed => _waitForClosedSource.Task; + public Socket Socket { get; } - // CancellationToken is not used, operation lifetime is completely same as socket. - - // socket is closed: - // receiving task returns 0 read - // throws SocketException when call method - // socket is disposed: - // throws DisposedException - - // return ValueTask directly for performance, not care exception and signal-disconnected. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken) - { -#if NETSTANDARD - return new ValueTask(_socket.ConnectAsync(host, port).WaitAsync(Timeout.InfiniteTimeSpan, cancellationToken)); -#else - return _socket.ConnectAsync(host, port, cancellationToken); -#endif - } - - /// - /// Connect with Timeout. When failed, Dispose this connection. - /// - public async ValueTask ConnectAsync(string host, int port, TimeSpan timeout) + public async ValueTask ConnectAsync(Uri uri, CancellationToken cancellationToken) { - using var cts = new CancellationTokenSource(timeout); try { #if NETSTANDARD - await _socket.ConnectAsync(host, port).WaitAsync(timeout, cts.Token).ConfigureAwait(false); + await Socket.ConnectAsync(uri.Host, uri.Port).WaitAsync(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); #else - await _socket.ConnectAsync(host, port, cts.Token).ConfigureAwait(false); + await Socket.ConnectAsync(uri.Host, uri.Port, cancellationToken).ConfigureAwait(false); #endif } - catch (Exception ex) + catch (Exception) { await DisposeAsync().ConfigureAwait(false); - if (ex is OperationCanceledException) - { - throw new SocketException(10060); // 10060 = connection timeout. - } - else - { - throw; - } + throw; } } @@ -91,9 +57,9 @@ public ValueTask SendAsync(ReadOnlyMemory buffer) segment = new ArraySegment(buffer.ToArray()); } - return new ValueTask(_socket.SendAsync(segment, SocketFlags.None)); + return new ValueTask(Socket.SendAsync(segment, SocketFlags.None)); #else - return _socket.SendAsync(buffer, SocketFlags.None, CancellationToken.None); + return Socket.SendAsync(buffer, SocketFlags.None, CancellationToken.None); #endif } @@ -106,67 +72,49 @@ public ValueTask ReceiveAsync(Memory buffer) ThrowHelper.ThrowInvalidOperationException("Can't get underlying array"); } - return new ValueTask(_socket.ReceiveAsync(segment, SocketFlags.None)); + return new ValueTask(Socket.ReceiveAsync(segment, SocketFlags.None)); #else - return _socket.ReceiveAsync(buffer, SocketFlags.None, CancellationToken.None); + return Socket.ReceiveAsync(buffer, SocketFlags.None, CancellationToken.None); #endif } public ValueTask AbortConnectionAsync(CancellationToken cancellationToken) { #if NETSTANDARD - _socket.Disconnect(false); + Socket.Disconnect(false); return default; #else - return _socket.DisconnectAsync(false, cancellationToken); + return Socket.DisconnectAsync(false, cancellationToken); #endif } - public ValueTask DisposeAsync() + public +#if !NETSTANDARD + async +#endif + ValueTask DisposeAsync() { if (Interlocked.Increment(ref _disposed) == 1) { try { - _waitForClosedSource.TrySetCanceled(); - } - catch - { - } - - try - { - _socket.Shutdown(SocketShutdown.Both); +#if NETSTANDARD + Socket.Disconnect(false); +#else + using var cts = new CancellationTokenSource(_natsOpts.ConnectTimeout); + await Socket.DisconnectAsync(false, cts.Token).ConfigureAwait(false); +#endif } catch { + // ignored } - _socket.Dispose(); + Socket.Dispose(); } +#if NETSTANDARD return default; - } - - // when catch SocketClosedException, call this method. - public void SignalDisconnected(Exception exception) - { - _waitForClosedSource.TrySetResult(exception); - } - - // NetworkStream will own the Socket, so mark as disposed - // in order to skip socket.Dispose() in DisposeAsync - public SslStreamConnection UpgradeToSslStreamConnection(NatsTlsOpts tlsOpts) - { - if (Interlocked.Increment(ref _disposed) == 1) - { - return new SslStreamConnection( - _logger, - _socket, - tlsOpts, - _waitForClosedSource); - } - - throw new ObjectDisposedException(nameof(TcpConnection)); +#endif } } diff --git a/src/NATS.Client.Core/Internal/TcpFactory.cs b/src/NATS.Client.Core/Internal/TcpFactory.cs new file mode 100644 index 000000000..e27f514f1 --- /dev/null +++ b/src/NATS.Client.Core/Internal/TcpFactory.cs @@ -0,0 +1,15 @@ +namespace NATS.Client.Core.Internal +{ + internal sealed class TcpFactory : INatsSocketConnectionFactory + { + public static INatsSocketConnectionFactory Default { get; } = new TcpFactory(); + + public async ValueTask ConnectAsync(Uri uri, NatsOpts opts, CancellationToken cancellationToken) + { + var conn = new TcpConnection(opts); + await conn.ConnectAsync(uri, cancellationToken).ConfigureAwait(false); + + return conn; + } + } +} diff --git a/src/NATS.Client.Core/Internal/WebSocketConnection.cs b/src/NATS.Client.Core/Internal/WebSocketConnection.cs index 60c4e4649..636f72272 100644 --- a/src/NATS.Client.Core/Internal/WebSocketConnection.cs +++ b/src/NATS.Client.Core/Internal/WebSocketConnection.cs @@ -1,5 +1,3 @@ -using System.Net.Security; -using System.Net.Sockets; using System.Net.WebSockets; using System.Runtime.CompilerServices; #if NETSTANDARD @@ -8,57 +6,22 @@ namespace NATS.Client.Core.Internal; -internal sealed class WebSocketConnection : ISocketConnection +internal sealed class WebSocketConnection(NatsOpts natsOpts) : INatsSocketConnection { - private readonly ClientWebSocket _socket; - private readonly TaskCompletionSource _waitForClosedSource = new(); - private readonly TimeSpan _socketCloseTimeout = TimeSpan.FromSeconds(5); // matches _socketComponentDisposeTimeout in NatsConnection.cs + private readonly ClientWebSocket _socket = new(); private int _disposed; - public WebSocketConnection() + public async ValueTask ConnectAsync(Uri uri, CancellationToken cancellationToken) { - _socket = new ClientWebSocket(); - } - - public Task WaitForClosed => _waitForClosedSource.Task; - - // CancellationToken is not used, operation lifetime is completely same as socket. - - // socket is closed: - // receiving task returns 0 read - // throws SocketException when call method - // socket is disposed: - // throws DisposedException - - // return ValueTask directly for performance, not care exception and signal-disconnected. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public Task ConnectAsync(Uri uri, CancellationToken cancellationToken) - { - return _socket.ConnectAsync(uri, cancellationToken); - } - - /// - /// Connect with Timeout. When failed, Dispose this connection. - /// - public async ValueTask ConnectAsync(NatsUri uri, NatsOpts opts) - { - using var cts = new CancellationTokenSource(opts.ConnectTimeout); try { - await opts.WebSocketOpts.ApplyClientWebSocketOptionsAsync(_socket.Options, uri, opts.TlsOpts, cts.Token).ConfigureAwait(false); - await _socket.ConnectAsync(uri.Uri, cts.Token).ConfigureAwait(false); + await natsOpts.WebSocketOpts.ApplyClientWebSocketOptionsAsync(_socket.Options, uri, natsOpts.TlsOpts, cancellationToken).ConfigureAwait(false); + await _socket.ConnectAsync(uri, cancellationToken).ConfigureAwait(false); } - catch (Exception ex) + catch (Exception) { await DisposeAsync().ConfigureAwait(false); - if (ex is OperationCanceledException) - { - throw new SocketException(10060); // 10060 = connection timeout. - } - else - { - throw; - } + throw; } } @@ -94,42 +57,21 @@ public async ValueTask ReceiveAsync(Memory buffer) return wsRead.Count; } - public ValueTask AbortConnectionAsync(CancellationToken cancellationToken) - { - // ClientWebSocket.Abort() doesn't accept a cancellation token, so check at the beginning of this method - cancellationToken.ThrowIfCancellationRequested(); - _socket.Abort(); - return default; - } - public async ValueTask DisposeAsync() { if (Interlocked.Increment(ref _disposed) == 1) { try { - _waitForClosedSource.TrySetCanceled(); - } - catch - { - } - - try - { - var cts = new CancellationTokenSource(_socketCloseTimeout); - await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, default, cts.Token).ConfigureAwait(false); + using var cts = new CancellationTokenSource(natsOpts.ConnectTimeout); + await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, null, cts.Token).ConfigureAwait(false); } catch { + // ignored } _socket.Dispose(); } } - - // when catch SocketClosedException, call this method. - public void SignalDisconnected(Exception exception) - { - _waitForClosedSource.TrySetResult(exception); - } } diff --git a/src/NATS.Client.Core/Internal/WebSocketFactory.cs b/src/NATS.Client.Core/Internal/WebSocketFactory.cs new file mode 100644 index 000000000..03118f62b --- /dev/null +++ b/src/NATS.Client.Core/Internal/WebSocketFactory.cs @@ -0,0 +1,15 @@ +namespace NATS.Client.Core.Internal +{ + internal sealed class WebSocketFactory : INatsSocketConnectionFactory + { + public static INatsSocketConnectionFactory Default { get; } = new WebSocketFactory(); + + public async ValueTask ConnectAsync(Uri uri, NatsOpts opts, CancellationToken cancellationToken) + { + var conn = new WebSocketConnection(opts); + await conn.ConnectAsync(uri, cancellationToken).ConfigureAwait(false); + + return conn; + } + } +} diff --git a/src/NATS.Client.Core/NatsConnection.Reconnect.cs b/src/NATS.Client.Core/NatsConnection.Reconnect.cs index fafe0d687..00fa4e84d 100644 --- a/src/NATS.Client.Core/NatsConnection.Reconnect.cs +++ b/src/NATS.Client.Core/NatsConnection.Reconnect.cs @@ -13,6 +13,6 @@ public async ValueTask ReconnectAsync() } _logger.LogInformation(NatsLogEvents.Connection, "Forcing reconnection to NATS server"); - await _socket!.AbortConnectionAsync(CancellationToken.None).ConfigureAwait(false); + await _socketConnection!.DisposeAsync().ConfigureAwait(false); } } diff --git a/src/NATS.Client.Core/NatsConnection.cs b/src/NATS.Client.Core/NatsConnection.cs index 9e4b99537..6c2f14209 100644 --- a/src/NATS.Client.Core/NatsConnection.cs +++ b/src/NATS.Client.Core/NatsConnection.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Diagnostics; +using System.Net.Sockets; using System.Threading.Channels; using Microsoft.Extensions.Logging; using NATS.Client.Core.Commands; @@ -35,7 +36,7 @@ public partial class NatsConnection : INatsConnection private readonly object _gate = new object(); private readonly ILogger _logger; private readonly ObjectPool _pool; - private readonly CancellationTokenSource _disposedCancellationTokenSource; + private readonly CancellationTokenSource _disposedCts; private readonly string _name; private readonly TimeSpan _socketComponentDisposeTimeout = TimeSpan.FromSeconds(5); private readonly BoundedChannelOptions _defaultSubscriptionChannelOpts; @@ -50,7 +51,7 @@ public partial class NatsConnection : INatsConnection private int _reconnectCount; // when reconnected, make new instance. - private ISocketConnection? _socket; + private SocketConnectionWrapper? _socketConnection; private CancellationTokenSource? _pingTimerCancellationTokenSource; private volatile NatsUri? _currentConnectUri; private volatile NatsUri? _lastSeedConnectUri; @@ -75,7 +76,7 @@ public NatsConnection(NatsOpts opts) Opts = opts.ReadUserInfoFromConnectionString(); ConnectionState = NatsConnectionState.Closed; _waitForOpenConnection = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _disposedCancellationTokenSource = new CancellationTokenSource(); + _disposedCts = new CancellationTokenSource(); _pool = new ObjectPool(opts.ObjectPoolSize); _name = opts.Name; Counter = new ConnectionStatsCounter(); @@ -93,13 +94,8 @@ public NatsConnection(NatsOpts opts) }; // push consumer events to a channel so handlers can be awaited (also prevents user code from blocking us) - _eventChannel = Channel.CreateUnbounded<(NatsEvent, NatsEventArgs)>(new UnboundedChannelOptions - { - AllowSynchronousContinuations = false, - SingleWriter = false, - SingleReader = true, - }); - _publishEventsTask = Task.Run(PublishEventsAsync, _disposedCancellationTokenSource.Token); + _eventChannel = Channel.CreateUnbounded<(NatsEvent, NatsEventArgs)>(new UnboundedChannelOptions { AllowSynchronousContinuations = false, SingleWriter = false, SingleReader = true, }); + _publishEventsTask = Task.Run(PublishEventsAsync, _disposedCts.Token); } // events @@ -136,7 +132,7 @@ private set // Hooks public Func<(string Host, int Port), ValueTask<(string Host, int Port)>>? OnConnectingAsync { get; set; } - public Func>? OnSocketAvailableAsync { get; set; } + public Func>? OnSocketAvailableAsync { get; set; } internal ServerInfo? WritableServerInfo { @@ -165,7 +161,7 @@ internal bool IsDisposed internal ObjectPool ObjectPool => _pool; // only used for internal testing - internal ISocketConnection? TestSocket => _socket; + internal SocketConnectionWrapper? TestSocketConnection => _socketConnection; /// /// Connect socket and write CONNECT command to nats server. @@ -251,9 +247,9 @@ public virtual async ValueTask DisposeAsync() await CommandWriter.DisposeAsync().ConfigureAwait(false); _waitForOpenConnection.TrySetCanceled(); #if NET8_0_OR_GREATER - await _disposedCancellationTokenSource.CancelAsync().ConfigureAwait(false); + await _disposedCts.CancelAsync().ConfigureAwait(false); #else - _disposedCancellationTokenSource.Cancel(); + _disposedCts.Cancel(); #endif } } @@ -314,7 +310,7 @@ private async ValueTask InitialConnectAsync() foreach (var uri in uris) { - if (Opts.TlsOpts.EffectiveMode(uri) == TlsMode.Disable && uri.IsTls) + if (Opts.TlsOpts.EffectiveMode(uri.Uri) == TlsMode.Disable && uri.IsTls) throw new NatsException($"URI {uri} requires TLS but TlsMode is set to Disable"); } @@ -327,42 +323,7 @@ private async ValueTask InitialConnectAsync() { try { - var target = (uri.Host, uri.Port); - if (OnConnectingAsync != null) - { - _logger.LogInformation(NatsLogEvents.Connection, "Try to invoke OnConnectingAsync before connect to NATS"); - target = await OnConnectingAsync(target).ConfigureAwait(false); - } - - _logger.LogInformation(NatsLogEvents.Connection, "Try to connect NATS {0}", uri); - if (uri.IsWebSocket) - { - var conn = new WebSocketConnection(); - await conn.ConnectAsync(uri, Opts).ConfigureAwait(false); - _socket = conn; - } - else - { - var conn = new TcpConnection(_logger); - await conn.ConnectAsync(target.Host, target.Port, Opts.ConnectTimeout).ConfigureAwait(false); - _socket = conn; - - if (Opts.TlsOpts.EffectiveMode(uri) == TlsMode.Implicit) - { - // upgrade TcpConnection to SslConnection - var sslConnection = conn.UpgradeToSslStreamConnection(Opts.TlsOpts); - await sslConnection.AuthenticateAsClientAsync(uri, Opts.ConnectTimeout).ConfigureAwait(false); - _socket = sslConnection; - } - } - - if (OnSocketAvailableAsync != null) - { - _logger.LogInformation(NatsLogEvents.Connection, "Try to invoke OnSocketAvailable"); - _socket = await OnSocketAvailableAsync(_socket).ConfigureAwait(false); - } - - _currentConnectUri = uri; + await ConnectSocketAsync(uri).ConfigureAwait(false); break; } catch (Exception ex) @@ -371,7 +332,7 @@ private async ValueTask InitialConnectAsync() } } - if (_socket == null) + if (_socketConnection == null) { var exception = new NatsException("can not connect uris: " + string.Join(",", uris.Select(x => x.ToString()))); lock (_gate) @@ -417,6 +378,51 @@ private async ValueTask InitialConnectAsync() } } + private async Task ConnectSocketAsync(NatsUri uri) + { + var target = (uri.Host, uri.Port); + if (OnConnectingAsync != null) + { + _logger.LogInformation(NatsLogEvents.Connection, "Invoke OnConnectingAsync before connecting to NATS {Uri}", uri); + target = await OnConnectingAsync(target).ConfigureAwait(false); + if (target.Host != uri.Host || target.Port != uri.Port) + { + uri = uri with { Uri = new UriBuilder(uri.Uri) { Host = target.Host, Port = target.Port, }.Uri }; + } + } + + var connectionFactory = Opts.SocketConnectionFactory ?? (uri.IsWebSocket ? WebSocketFactory.Default : TcpFactory.Default); + _logger.LogInformation(NatsLogEvents.Connection, "Connect to NATS using {FactoryType} {Uri}", connectionFactory.GetType().Name, uri); + using var timeoutCts = new CancellationTokenSource(Opts.ConnectTimeout); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(_disposedCts.Token, timeoutCts.Token); + try + { + var socketConnection = await connectionFactory.ConnectAsync(uri.Uri, Opts, cts.Token).ConfigureAwait(false); + _socketConnection = new SocketConnectionWrapper(socketConnection); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) + { + throw new SocketException(10060); // 10060 = connection timeout. + } + + if (Opts.TlsOpts.EffectiveMode(uri.Uri) == TlsMode.Implicit + && _socketConnection.InnerSocket is INatsTlsUpgradeableSocketConnection tlsUpgradeableSocketConnection) + { + _logger.LogDebug(NatsLogEvents.Security, "Perform implicit TLS Upgrade to {Uri}", uri); + var sslConnection = new SslStreamConnection(tlsUpgradeableSocketConnection, Opts.TlsOpts); + await sslConnection.AuthenticateAsClientAsync(uri, Opts.ConnectTimeout).ConfigureAwait(false); + _socketConnection = _socketConnection with { InnerSocket = sslConnection }; + } + + if (OnSocketAvailableAsync != null) + { + _logger.LogInformation(NatsLogEvents.Connection, "Invoke OnSocketAvailable after connecting to NATS {Uri}", uri); + _socketConnection = _socketConnection with { InnerSocket = await OnSocketAvailableAsync(_socketConnection.InnerSocket).ConfigureAwait(false) }; + } + + _currentConnectUri = uri; + } + private async ValueTask SetupReaderWriterAsync(bool reconnect) { _logger.LogDebug(NatsLogEvents.Connection, "Setup reader and writer"); @@ -428,7 +434,7 @@ private async ValueTask SetupReaderWriterAsync(bool reconnect) var waitForInfoSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var waitForPongOrErrorSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var infoParsedSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _socketReader = new NatsReadProtocolProcessor(_socket!, this, waitForInfoSignal, waitForPongOrErrorSignal, infoParsedSignal.Task); + _socketReader = new NatsReadProtocolProcessor(_socketConnection!, this, waitForInfoSignal, waitForPongOrErrorSignal, infoParsedSignal.Task); try { @@ -437,21 +443,21 @@ private async ValueTask SetupReaderWriterAsync(bool reconnect) await waitForInfoSignal.Task.WaitAsync(Opts.RequestTimeout).ConfigureAwait(false); // check to see if we should upgrade to TLS - if (_socket is TcpConnection tcpConnection) + if (_socketConnection!.InnerSocket is INatsTlsUpgradeableSocketConnection tlsUpgradeableSocket) { - if (Opts.TlsOpts.EffectiveMode(_currentConnectUri) == TlsMode.Disable && WritableServerInfo!.TlsRequired) + if (Opts.TlsOpts.EffectiveMode(_currentConnectUri.Uri) == TlsMode.Disable && WritableServerInfo!.TlsRequired) { throw new NatsException( $"Server {_currentConnectUri} requires TLS but TlsMode is set to Disable"); } - if (Opts.TlsOpts.EffectiveMode(_currentConnectUri) == TlsMode.Require && !WritableServerInfo!.TlsRequired && !WritableServerInfo.TlsAvailable) + if (Opts.TlsOpts.EffectiveMode(_currentConnectUri.Uri) == TlsMode.Require && !WritableServerInfo!.TlsRequired && !WritableServerInfo.TlsAvailable) { throw new NatsException( $"Server {_currentConnectUri} does not support TLS but TlsMode is set to Require"); } - if (Opts.TlsOpts.TryTls(_currentConnectUri) && (WritableServerInfo!.TlsRequired || WritableServerInfo.TlsAvailable)) + if (Opts.TlsOpts.TryTls(_currentConnectUri.Uri) && (WritableServerInfo!.TlsRequired || WritableServerInfo.TlsAvailable)) { // do TLS upgrade var targetUri = FixTlsHost(_currentConnectUri); @@ -464,14 +470,14 @@ private async ValueTask SetupReaderWriterAsync(bool reconnect) _socketReader = null; // upgrade TcpConnection to SslConnection - var sslConnection = tcpConnection.UpgradeToSslStreamConnection(Opts.TlsOpts); + var sslConnection = new SslStreamConnection(tlsUpgradeableSocket, Opts.TlsOpts); await sslConnection.AuthenticateAsClientAsync(targetUri, Opts.ConnectTimeout).ConfigureAwait(false); - _socket = sslConnection; + _socketConnection = _socketConnection with { InnerSocket = sslConnection }; // create new socket reader waitForPongOrErrorSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); infoParsedSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _socketReader = new NatsReadProtocolProcessor(_socket, this, waitForInfoSignal, waitForPongOrErrorSignal, infoParsedSignal.Task); + _socketReader = new NatsReadProtocolProcessor(_socketConnection, this, waitForInfoSignal, waitForPongOrErrorSignal, infoParsedSignal.Task); } } @@ -481,10 +487,10 @@ private async ValueTask SetupReaderWriterAsync(bool reconnect) // Authentication if (_userCredentials != null) { - await _userCredentials.AuthenticateAsync(_clientOpts, WritableServerInfo, _currentConnectUri, Opts.ConnectTimeout, _disposedCancellationTokenSource.Token).ConfigureAwait(false); + await _userCredentials.AuthenticateAsync(_clientOpts, WritableServerInfo, _currentConnectUri, Opts.ConnectTimeout, _disposedCts.Token).ConfigureAwait(false); } - await using (var priorityCommandWriter = new PriorityCommandWriter(this, _pool, _socket!, Opts, Counter, EnqueuePing)) + await using (var priorityCommandWriter = new PriorityCommandWriter(this, _pool, _socketConnection!, Opts, Counter, EnqueuePing)) { // add CONNECT and PING command to priority lane await priorityCommandWriter.CommandWriter.ConnectAsync(_clientOpts, CancellationToken.None).ConfigureAwait(false); @@ -518,7 +524,7 @@ await waitForPongOrErrorSignal.Task } // create the socket writer - CommandWriter.Reset(_socket!); + CommandWriter.Reset(_socketConnection!); lock (_gate) { @@ -567,8 +573,21 @@ private async void ReconnectLoop() try { - // If dispose this client, WaitForClosed throws OperationCanceledException so stop reconnect-loop correctly. - await _socket!.WaitForClosed.ConfigureAwait(false); + try + { + // wait for the current socket to complete or throw + await _socketConnection!.WaitForClosed.ConfigureAwait(false); + } + catch (Exception) + { + // If the NatsConnection is disposed, WaitForClosed throws so stop reconnect-loop correctly + if (CheckDisposed()) + { + return; + } + + // otherwise ignore the exception and reconnect + } _logger.LogDebug(NatsLogEvents.Connection, "Reconnect loop connection closed [{ReconnectCount}]", reconnectCount); @@ -613,12 +632,11 @@ private async void ReconnectLoop() _logger.LogDebug(NatsLogEvents.Connection, "Trying to reconnect [{ReconnectCount}]", reconnectCount); - if (IsDisposed) + if (CheckDisposed()) { // No point in trying to reconnect. // This can happen if we're disposed while we're waiting for the next reconnect // and potentially gets us stuck in a reconnect loop. - _logger.LogDebug(NatsLogEvents.Connection, "Disposed, no point in trying to reconnect [{ReconnectCount}]", reconnectCount); return; } @@ -627,51 +645,8 @@ private async void ReconnectLoop() if (urlEnumerator.MoveNext()) { url = urlEnumerator.Current; - - if (OnConnectingAsync != null) - { - var target = (url.Host, url.Port); - _logger.LogInformation(NatsLogEvents.Connection, "Try to invoke OnConnectingAsync before connect to NATS [{ReconnectCount}]", reconnectCount); - var newTarget = await OnConnectingAsync(target).ConfigureAwait(false); - - if (newTarget.Host != target.Host || newTarget.Port != target.Port) - { - url = url.CloneWith(newTarget.Host, newTarget.Port); - } - } - - _logger.LogInformation(NatsLogEvents.Connection, "Tried to connect NATS {Url} [{ReconnectCount}]", url, reconnectCount); - if (url.IsWebSocket) - { - _logger.LogDebug(NatsLogEvents.Connection, "Trying to reconnect using WebSocket {Url} [{ReconnectCount}]", url, reconnectCount); - var conn = new WebSocketConnection(); - await conn.ConnectAsync(url, Opts).ConfigureAwait(false); - _socket = conn; - } - else - { - _logger.LogDebug(NatsLogEvents.Connection, "Trying to reconnect using TCP {Url} [{ReconnectCount}]", url, reconnectCount); - var conn = new TcpConnection(_logger); - await conn.ConnectAsync(url.Host, url.Port, Opts.ConnectTimeout).ConfigureAwait(false); - _socket = conn; - - if (Opts.TlsOpts.EffectiveMode(url) == TlsMode.Implicit) - { - // upgrade TcpConnection to SslConnection - _logger.LogDebug(NatsLogEvents.Connection, "Trying to reconnect and upgrading to TLS {Url} [{ReconnectCount}]", url, reconnectCount); - var sslConnection = conn.UpgradeToSslStreamConnection(Opts.TlsOpts); - await sslConnection.AuthenticateAsClientAsync(FixTlsHost(url), Opts.ConnectTimeout).ConfigureAwait(false); - _socket = sslConnection; - } - } - - if (OnSocketAvailableAsync != null) - { - _logger.LogInformation(NatsLogEvents.Connection, "Try to invoke OnSocketAvailable"); - _socket = await OnSocketAvailableAsync(_socket).ConfigureAwait(false); - } - - _currentConnectUri = url; + _logger.LogInformation(NatsLogEvents.Connection, "Reconnecting to NATS {Url} [{ReconnectCount}]", url, reconnectCount); + await ConnectSocketAsync(url).ConfigureAwait(false); } else { @@ -685,7 +660,7 @@ private async void ReconnectLoop() } catch (Exception ex) { - if (IsDisposed) + if (CheckDisposed()) { // No point in trying to reconnect. return; @@ -727,20 +702,6 @@ private async void ReconnectLoop() } catch (Exception ex) { - if (ex is OperationCanceledException) - { - try - { - _logger.LogDebug(NatsLogEvents.Connection, "Operation cancelled. Retry loop stopped because the connection was disposed [{ReconnectCount}]", reconnectCount); - } - catch - { - // ignore logging exceptions in case our host might be disposed or shutting down - } - - return; - } - _waitForOpenConnection.TrySetException(ex); try { @@ -757,15 +718,40 @@ private async void ReconnectLoop() // which in turn would crash the application. // (e.g. we've seen this with EventLog provider on Windows) } + + return; } + // the reconnect loop should never get here, since everything + // is either an early return or stays in the loop try { - _logger.LogDebug(NatsLogEvents.Connection, "Reconnect loop stopped [{ReconnectCount}]", reconnectCount); + _logger.LogError(NatsLogEvents.Connection, "Retry loop stopped and connection state is invalid [{ReconnectCount}]", reconnectCount); } catch { - // ignore logging exceptions in case our host might be disposed or shutting down + // ignore logging exceptions since our host might be disposed or shutting down + } + + return; + + bool CheckDisposed() + { + if (IsDisposed) + { + try + { + _logger.LogDebug(NatsLogEvents.Connection, "Retry loop stopped because the connection was disposed [{ReconnectCount}]", reconnectCount); + } + catch + { + // ignore logging exceptions since our host might be disposed or shutting down + } + + return true; + } + + return false; } } @@ -773,9 +759,9 @@ private async Task PublishEventsAsync() { try { - while (!_disposedCancellationTokenSource.IsCancellationRequested) + while (!_disposedCts.IsCancellationRequested) { - var hasData = await _eventChannel.Reader.WaitToReadAsync(_disposedCancellationTokenSource.Token).ConfigureAwait(false); + var hasData = await _eventChannel.Reader.WaitToReadAsync(_disposedCts.Token).ConfigureAwait(false); while (hasData && _eventChannel.Reader.TryRead(out var eventArgs)) { var (natsEvent, args) = eventArgs; @@ -800,15 +786,15 @@ private async Task PublishEventsAsync() } } } - catch (OperationCanceledException) + catch (OperationCanceledException) when (_disposedCts.IsCancellationRequested) { // Ignore, we're disposing the connection } catch (Exception ex) { _logger.LogError(NatsLogEvents.Connection, ex, "Error occured when publishing events"); - if (!_disposedCancellationTokenSource.IsCancellationRequested) - _publishEventsTask = Task.Run(PublishEventsAsync, _disposedCancellationTokenSource.Token); + if (!_disposedCts.IsCancellationRequested) + _publishEventsTask = Task.Run(PublishEventsAsync, _disposedCts.Token); } } @@ -826,11 +812,17 @@ private NatsUri FixTlsHost(NatsUri uri) && Uri.CheckHostName(uri.Host) != UriHostNameType.Dns && Uri.CheckHostName(lastSeedHost) == UriHostNameType.Dns) { + return uri with + { + Uri = new UriBuilder(uri.Uri) + { #if NETSTANDARD2_0 - return uri.CloneWith(lastSeedHost!); + Host = lastSeedHost!, #else - return uri.CloneWith(lastSeedHost); + Host = lastSeedHost, #endif + }.Uri, + }; } return uri; @@ -905,9 +897,9 @@ private async void StartPingTimer(CancellationToken cancellationToken) if (Interlocked.Increment(ref _pongCount) > Opts.MaxPingOut) { _logger.LogInformation(NatsLogEvents.Connection, "Server didn't respond to our ping requests. Aborting connection"); - if (_socket != null) + if (_socketConnection != null) { - await _socket.AbortConnectionAsync(cancellationToken).ConfigureAwait(false); + await _socketConnection.DisposeAsync().ConfigureAwait(false); return; } } @@ -1002,10 +994,10 @@ private async ValueTask DisposeSocketAsync(bool asyncReaderDispose) // ignore logging exceptions in case our host might be disposed or shutting down } - if (_socket != null) + if (_socketConnection != null) { - await DisposeSocketComponentAsync(_socket, "socket").ConfigureAwait(false); - _socket = null; + await DisposeSocketComponentAsync(_socketConnection, "socket").ConfigureAwait(false); + _socketConnection = null; } if (_socketReader != null) diff --git a/src/NATS.Client.Core/NatsOpts.cs b/src/NATS.Client.Core/NatsOpts.cs index e99530c80..ab29300b6 100644 --- a/src/NATS.Client.Core/NatsOpts.cs +++ b/src/NATS.Client.Core/NatsOpts.cs @@ -1,4 +1,3 @@ -using System.Net.WebSockets; using System.Text; using System.Threading.Channels; using Microsoft.Extensions.Logging; @@ -142,6 +141,14 @@ public sealed record NatsOpts /// public BoundedChannelFullMode SubPendingChannelFullMode { get; init; } = BoundedChannelFullMode.DropNewest; + /// + /// Factory for creating socket connections to the NATS server. + /// When set, this factory will be used instead of the default connection implementation. + /// For the library to handle TLS upgrade automatically, implement the interface. + /// + /// + public INatsSocketConnectionFactory? SocketConnectionFactory { get; init; } + internal NatsUri[] GetSeedUris(bool suppressRandomization = false) { var urls = Url.Split(','); diff --git a/src/NATS.Client.Core/NatsTlsOpts.cs b/src/NATS.Client.Core/NatsTlsOpts.cs index 760e6fca0..220acd672 100644 --- a/src/NATS.Client.Core/NatsTlsOpts.cs +++ b/src/NATS.Client.Core/NatsTlsOpts.cs @@ -115,19 +115,19 @@ internal bool HasTlsCerts } } - internal TlsMode EffectiveMode(NatsUri uri) => Mode switch + internal TlsMode EffectiveMode(Uri uri) => Mode switch { - TlsMode.Auto => HasTlsCerts || uri.Uri.Scheme.ToLower() == "tls" ? TlsMode.Require : TlsMode.Prefer, + TlsMode.Auto => HasTlsCerts || uri.Scheme.ToLower() == "tls" ? TlsMode.Require : TlsMode.Prefer, _ => Mode, }; - internal bool TryTls(NatsUri uri) + internal bool TryTls(Uri uri) { var effectiveMode = EffectiveMode(uri); return effectiveMode is TlsMode.Require or TlsMode.Prefer; } - internal async ValueTask AuthenticateAsClientOptionsAsync(NatsUri uri) + internal async ValueTask AuthenticateAsClientOptionsAsync(Uri uri) { if (EffectiveMode(uri) == TlsMode.Disable) { diff --git a/src/NATS.Client.Core/NatsWebSocketOpts.cs b/src/NATS.Client.Core/NatsWebSocketOpts.cs index 3ed2a1db9..6c0e699b5 100644 --- a/src/NATS.Client.Core/NatsWebSocketOpts.cs +++ b/src/NATS.Client.Core/NatsWebSocketOpts.cs @@ -29,7 +29,7 @@ public sealed record NatsWebSocketOpts internal async ValueTask ApplyClientWebSocketOptionsAsync( ClientWebSocketOptions clientWebSocketOptions, - NatsUri uri, + Uri uri, NatsTlsOpts tlsOpts, CancellationToken cancellationToken) { @@ -73,7 +73,7 @@ internal async ValueTask ApplyClientWebSocketOptionsAsync( if (ConfigureClientWebSocketOptions != null) { - await ConfigureClientWebSocketOptions(uri.Uri, clientWebSocketOptions, cancellationToken).ConfigureAwait(false); + await ConfigureClientWebSocketOptions(uri, clientWebSocketOptions, cancellationToken).ConfigureAwait(false); } } } diff --git a/tests/NATS.Client.Core.Tests/ConnectionRetryTest.cs b/tests/NATS.Client.Core.Tests/ConnectionRetryTest.cs index 79ab020d6..3c1742169 100644 --- a/tests/NATS.Client.Core.Tests/ConnectionRetryTest.cs +++ b/tests/NATS.Client.Core.Tests/ConnectionRetryTest.cs @@ -131,9 +131,9 @@ public async Task Reconnect_doesnt_drop_partially_sent_msgs() { while (!stopCts.IsCancellationRequested) { - if (pubConn is { ConnectionState: NatsConnectionState.Open, TestSocket.WaitForClosed.IsCanceled: false }) + if (pubConn is { ConnectionState: NatsConnectionState.Open, TestSocketConnection.WaitForClosed.IsCanceled: false }) { - await pubConn.TestSocket.AbortConnectionAsync(timeoutCts.Token); + await pubConn.TestSocketConnection.DisposeAsync(); Interlocked.Increment(ref reconnects); } diff --git a/tests/NATS.Client.JetStream.Tests/NatsJsContextFactoryTest.cs b/tests/NATS.Client.JetStream.Tests/NatsJsContextFactoryTest.cs index ac1e6f68a..a9331f19f 100644 --- a/tests/NATS.Client.JetStream.Tests/NatsJsContextFactoryTest.cs +++ b/tests/NATS.Client.JetStream.Tests/NatsJsContextFactoryTest.cs @@ -100,7 +100,7 @@ public class MockConnection : INatsConnection public Func<(string Host, int Port), ValueTask<(string Host, int Port)>>? OnConnectingAsync { get; set; } - public Func>? OnSocketAvailableAsync { get; set; } + public Func>? OnSocketAvailableAsync { get; set; } public ValueTask PingAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); diff --git a/tests/NATS.Slow.Tests/NatsConnectionTest.cs b/tests/NATS.Slow.Tests/NatsConnectionTest.cs index b7f672728..888c9c731 100644 --- a/tests/NATS.Slow.Tests/NatsConnectionTest.cs +++ b/tests/NATS.Slow.Tests/NatsConnectionTest.cs @@ -427,7 +427,7 @@ public async Task OnSocketAvailableAsync_ShouldBeInvokedOnInitialConnection() nats.OnSocketAvailableAsync = socket => { wasInvoked.Pulse(); - return new ValueTask(socket); + return new ValueTask(socket); }; // Act @@ -449,7 +449,7 @@ public async Task OnSocketAvailableAsync_ShouldBeInvokedOnReconnection() nats.OnSocketAvailableAsync = socket => { Interlocked.Increment(ref invocationCount); - return new ValueTask(socket); + return new ValueTask(socket); }; // Simulate initial connection diff --git a/tests/NATS.Slow.Tests/TlsOptsTest.cs b/tests/NATS.Slow.Tests/TlsOptsTest.cs index beeb49067..f0a5d9f59 100644 --- a/tests/NATS.Slow.Tests/TlsOptsTest.cs +++ b/tests/NATS.Slow.Tests/TlsOptsTest.cs @@ -34,7 +34,7 @@ await ValidateAsync(new NatsTlsOpts static async ValueTask ValidateAsync(NatsTlsOpts opts) { - var clientOpts = await opts.AuthenticateAsClientOptionsAsync(new NatsUri("demo.nats.io", true)); + var clientOpts = await opts.AuthenticateAsClientOptionsAsync(new NatsUri("demo.nats.io", true).Uri); Assert.NotNull(clientOpts.RemoteCertificateValidationCallback); } } @@ -74,7 +74,7 @@ await ValidateAsync(new NatsTlsOpts static async Task ValidateAsync(NatsTlsOpts opts) { - var clientOpts = await opts.AuthenticateAsClientOptionsAsync(new NatsUri("demo.nats.io", true)); + var clientOpts = await opts.AuthenticateAsClientOptionsAsync(new NatsUri("demo.nats.io", true).Uri); #if NET8_0_OR_GREATER Assert.NotNull(clientOpts.ClientCertificateContext); @@ -122,7 +122,7 @@ await ValidateAsync(new NatsTlsOpts static async Task ValidateAsync(NatsTlsOpts opts) { #if NET8_0_OR_GREATER - var clientOpts = await opts.AuthenticateAsClientOptionsAsync(new NatsUri("demo.nats.io", true)); + var clientOpts = await opts.AuthenticateAsClientOptionsAsync(new NatsUri("demo.nats.io", true).Uri); var ctx = clientOpts.ClientCertificateContext; Assert.NotNull(ctx);