From 3b7645b5ff5ccfafbd7743f447912ed615d06063 Mon Sep 17 00:00:00 2001 From: Shane32 Date: Mon, 7 Oct 2024 00:12:59 -0400 Subject: [PATCH 1/8] Add KeepAliveMode option --- .../GraphQLHttpMiddleware.cs | 2 +- .../WebSockets/BaseSubscriptionServer.cs | 72 +++++++++++- .../WebSockets/GraphQLWebSocketOptions.cs | 19 +++ .../WebSockets/GraphQLWs/PingPayload.cs | 12 ++ .../GraphQLWs/SubscriptionServer.cs | 111 +++++++++++++++++- .../WebSockets/KeepAliveMode.cs | 36 ++++++ .../SubscriptionServer.cs | 22 ++++ ....Server.Transports.AspNetCore.approved.txt | 27 +++++ ....Server.Transports.AspNetCore.approved.txt | 27 +++++ ....Server.Transports.AspNetCore.approved.txt | 27 +++++ .../WebSockets/TestBaseSubscriptionServer.cs | 2 + 11 files changed, 348 insertions(+), 9 deletions(-) create mode 100644 src/Transports.AspNetCore/WebSockets/GraphQLWs/PingPayload.cs create mode 100644 src/Transports.AspNetCore/WebSockets/KeepAliveMode.cs diff --git a/src/Transports.AspNetCore/GraphQLHttpMiddleware.cs b/src/Transports.AspNetCore/GraphQLHttpMiddleware.cs index ebfa343e..bf61490a 100644 --- a/src/Transports.AspNetCore/GraphQLHttpMiddleware.cs +++ b/src/Transports.AspNetCore/GraphQLHttpMiddleware.cs @@ -1007,7 +1007,7 @@ protected virtual Task WriteJsonResponseAsync(HttpContext context, Http /// /// Gets a list of WebSocket sub-protocols supported. /// - protected virtual IEnumerable SupportedWebSocketSubProtocols => _supportedSubProtocols; + protected virtual IEnumerable SupportedWebSocketSubProtocols => _options.WebSockets.SupportedWebSocketSubProtocols; /// /// Creates an , a WebSocket message pump. diff --git a/src/Transports.AspNetCore/WebSockets/BaseSubscriptionServer.cs b/src/Transports.AspNetCore/WebSockets/BaseSubscriptionServer.cs index 7ba030bd..ca0186e7 100644 --- a/src/Transports.AspNetCore/WebSockets/BaseSubscriptionServer.cs +++ b/src/Transports.AspNetCore/WebSockets/BaseSubscriptionServer.cs @@ -259,10 +259,33 @@ protected virtual Task OnNotAuthorizedPolicyAsync(OperationMessage message, Auth ///

/// Otherwise, the connection is acknowledged via , /// is called to indicate that this WebSocket connection is ready to accept requests, - /// and keep-alive messages are sent via if configured to do so. - /// Keep-alive messages are only sent if no messages have been sent over the WebSockets connection for the - /// length of time configured in . + /// and is called to start sending keep-alive messages if configured to do so. ///
+ protected virtual async Task OnConnectionInitAsync(OperationMessage message) + { + if (!await AuthorizeAsync(message)) + { + return; + } + await OnConnectionAcknowledgeAsync(message); + if (TryInitialize() == false) + return; + + _ = OnKeepAliveLoopAsync(); + } + + /// + /// Executes when the client is attempting to initialize the connection. + ///

+ /// By default, this first checks to validate that the + /// request has passed authentication. If validation fails, the connection is closed with an Access + /// Denied message. + ///

+ /// Otherwise, the connection is acknowledged via , + /// is called to indicate that this WebSocket connection is ready to accept requests, + /// and is called to start sending keep-alive messages if configured to do so. + ///
+ [Obsolete($"Please use the {nameof(OnConnectionInitAsync)}(message) and {nameof(OnKeepAliveLoopAsync)} methods instead. This method will be removed in a future version of this library.")] protected virtual async Task OnConnectionInitAsync(OperationMessage message, bool smartKeepAlive) { if (!await AuthorizeAsync(message)) @@ -277,12 +300,49 @@ protected virtual async Task OnConnectionInitAsync(OperationMessage message, boo if (keepAliveTimeout > TimeSpan.Zero) { if (smartKeepAlive) - _ = StartSmartKeepAliveLoopAsync(); + _ = OnKeepAliveLoopAsync(keepAliveTimeout, KeepAliveMode.Timeout); else - _ = StartKeepAliveLoopAsync(); + _ = OnKeepAliveLoopAsync(keepAliveTimeout, KeepAliveMode.Interval); + } + } + + /// + /// Starts sending keep-alive messages if configured to do so. Inspects the configured + /// and passes control to + /// if keep-alive messages are enabled. + /// + protected virtual Task OnKeepAliveLoopAsync() + { + return OnKeepAliveLoopAsync( + _options.KeepAliveTimeout ?? DefaultKeepAliveTimeout, + _options.KeepAliveMode); + } + + /// + /// Sends keep-alive messages according to the specified timeout period and method. + /// See for implementation details for each supported mode. + /// + protected virtual async Task OnKeepAliveLoopAsync(TimeSpan keepAliveTimeout, KeepAliveMode keepAliveMode) + { + if (keepAliveTimeout <= TimeSpan.Zero) + return; + + switch (keepAliveMode) + { + case KeepAliveMode.Default: + case KeepAliveMode.Timeout: + await StartSmartKeepAliveLoopAsync(); + break; + case KeepAliveMode.Interval: + await StartDumbKeepAliveLoopAsync(); + break; + case KeepAliveMode.TimeoutWithPayload: + throw new NotImplementedException($"{nameof(KeepAliveMode.TimeoutWithPayload)} is not implemented within the {nameof(BaseSubscriptionServer)} class."); + default: + throw new ArgumentOutOfRangeException(nameof(keepAliveMode)); } - async Task StartKeepAliveLoopAsync() + async Task StartDumbKeepAliveLoopAsync() { while (!CancellationToken.IsCancellationRequested) { diff --git a/src/Transports.AspNetCore/WebSockets/GraphQLWebSocketOptions.cs b/src/Transports.AspNetCore/WebSockets/GraphQLWebSocketOptions.cs index f6444d97..858d26bf 100644 --- a/src/Transports.AspNetCore/WebSockets/GraphQLWebSocketOptions.cs +++ b/src/Transports.AspNetCore/WebSockets/GraphQLWebSocketOptions.cs @@ -25,6 +25,12 @@ public class GraphQLWebSocketOptions /// public TimeSpan? KeepAliveTimeout { get; set; } + /// + /// Gets or sets the keep-alive mode used for websocket subscriptions. + /// This property is only applicable when using the GraphQLWs protocol. + /// + public KeepAliveMode KeepAliveMode { get; set; } = KeepAliveMode.Default; + /// /// The amount of time to wait to attempt a graceful teardown of the WebSockets protocol. /// A value of indicates the default value defined by the implementation. @@ -42,4 +48,17 @@ public class GraphQLWebSocketOptions /// Disconnects a subscription from the client in the event of any GraphQL errors during a subscription. The default value is . /// public bool DisconnectAfterAnyError { get; set; } + + /// + /// The list of supported WebSocket sub-protocols. + /// Defaults to and . + /// Adding other sub-protocols require the method + /// to be overridden to handle the new sub-protocol. + /// + /// + /// When the is set to , you may wish to remove + /// from this list to prevent clients from using + /// protocols which do not support the keep-alive mode. + /// + public List SupportedWebSocketSubProtocols { get; set; } = [GraphQLWs.SubscriptionServer.SubProtocol, SubscriptionsTransportWs.SubscriptionServer.SubProtocol]; } diff --git a/src/Transports.AspNetCore/WebSockets/GraphQLWs/PingPayload.cs b/src/Transports.AspNetCore/WebSockets/GraphQLWs/PingPayload.cs new file mode 100644 index 00000000..a673cf8e --- /dev/null +++ b/src/Transports.AspNetCore/WebSockets/GraphQLWs/PingPayload.cs @@ -0,0 +1,12 @@ +namespace GraphQL.Server.Transports.AspNetCore.WebSockets.GraphQLWs; + +/// +/// The payload of the ping message. +/// +public class PingPayload +{ + /// + /// The unique identifier of the ping message. + /// + public string? id { get; set; } +} diff --git a/src/Transports.AspNetCore/WebSockets/GraphQLWs/SubscriptionServer.cs b/src/Transports.AspNetCore/WebSockets/GraphQLWs/SubscriptionServer.cs index 3235d16c..728a3391 100644 --- a/src/Transports.AspNetCore/WebSockets/GraphQLWs/SubscriptionServer.cs +++ b/src/Transports.AspNetCore/WebSockets/GraphQLWs/SubscriptionServer.cs @@ -4,6 +4,11 @@ namespace GraphQL.Server.Transports.AspNetCore.WebSockets.GraphQLWs; public class SubscriptionServer : BaseSubscriptionServer { private readonly IWebSocketAuthenticationService? _authenticationService; + private readonly IGraphQLSerializer _serializer; + private readonly GraphQLWebSocketOptions _options; + private DateTime _lastPongReceivedUtc; + private string? _lastPingId; + private readonly object _lastPingLock = new(); /// /// The WebSocket sub-protocol used for this protocol. @@ -67,6 +72,8 @@ public SubscriptionServer( UserContextBuilder = userContextBuilder ?? throw new ArgumentNullException(nameof(userContextBuilder)); Serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); _authenticationService = authenticationService; + _serializer = serializer; + _options = options; } /// @@ -90,7 +97,9 @@ public override async Task OnMessageReceivedAsync(OperationMessage message) } else { +#pragma warning disable CS0618 // Type or member is obsolete await OnConnectionInitAsync(message, true); +#pragma warning restore CS0618 // Type or member is obsolete } return; } @@ -113,6 +122,69 @@ public override async Task OnMessageReceivedAsync(OperationMessage message) } } + /// + [Obsolete($"Please use the {nameof(OnConnectionInitAsync)} and {nameof(OnKeepAliveLoopAsync)} methods instead. This method will be removed in a future version of this library.")] + protected override Task OnConnectionInitAsync(OperationMessage message, bool smartKeepAlive) + { + if (smartKeepAlive) + return base.OnConnectionInitAsync(message); + else + return base.OnConnectionInitAsync(message, smartKeepAlive); + } + + /// + protected override Task OnKeepAliveLoopAsync(TimeSpan keepAliveTimeout, KeepAliveMode keepAliveMode) + { + if (keepAliveMode == KeepAliveMode.TimeoutWithPayload) + { + if (keepAliveTimeout <= TimeSpan.Zero) + return Task.CompletedTask; + return SecureKeepAliveLoopAsync(keepAliveTimeout, keepAliveTimeout); + } + return base.OnKeepAliveLoopAsync(keepAliveTimeout, keepAliveMode); + + // pingInterval is the time since the last pong was received before sending a new ping + // pongInterval is the time to wait for a pong after a ping was sent before forcibly closing the connection + async Task SecureKeepAliveLoopAsync(TimeSpan pingInterval, TimeSpan pongInterval) + { + lock (_lastPingLock) + _lastPongReceivedUtc = DateTime.UtcNow; + while (!CancellationToken.IsCancellationRequested) + { + // Wait for the next ping interval + TimeSpan interval; + var now = DateTime.UtcNow; + DateTime lastPongReceivedUtc; + lock (_lastPingLock) + { + lastPongReceivedUtc = _lastPongReceivedUtc; + } + var nextPing = _lastPongReceivedUtc.Add(pingInterval); + interval = nextPing.Subtract(now); + if (interval > TimeSpan.Zero) // could easily be zero or less, if pongInterval is equal or greater than pingInterval + await Task.Delay(interval, CancellationToken); + + // Send a new ping message + await OnSendKeepAliveAsync(); + + // Wait for the pong response + await Task.Delay(pongInterval, CancellationToken); + bool abort; + lock (_lastPingLock) + { + abort = _lastPongReceivedUtc == lastPongReceivedUtc; + } + if (abort) + { + // Forcibly close the connection if the client has not responded to the keep-alive message. + // Do not send a close message to the client or wait for a response. + Connection.HttpContext.Abort(); + return; + } + } + } + } + /// /// Pong is a required response to a ping, and also a unidirectional keep-alive packet, /// whereas ping is a bidirectional keep-alive packet. @@ -129,11 +201,46 @@ protected virtual Task OnPingAsync(OperationMessage message) /// Executes when a pong message is received. /// protected virtual Task OnPongAsync(OperationMessage message) - => Task.CompletedTask; + { + if (_options.KeepAliveMode == KeepAliveMode.TimeoutWithPayload) + { + try + { + var pingId = _serializer.ReadNode(message.Payload)?.id; + lock (_lastPingLock) + { + if (_lastPingId == pingId) + _lastPongReceivedUtc = DateTime.UtcNow; + } + } + catch { } // ignore deserialization errors in case the pong message does not match the expected format + } + return Task.CompletedTask; + } /// protected override Task OnSendKeepAliveAsync() - => Connection.SendMessageAsync(_pongMessage); + { + if (_options.KeepAliveMode == KeepAliveMode.TimeoutWithPayload) + { + var lastPingId = Guid.NewGuid().ToString("N"); + lock (_lastPingLock) + { + _lastPingId = lastPingId; + } + return Connection.SendMessageAsync( + new() + { + Type = MessageType.Ping, + Payload = new PingPayload { id = lastPingId } + } + ); + } + else + { + return Connection.SendMessageAsync(_pongMessage); + } + } private static readonly OperationMessage _connectionAckMessage = new() { Type = MessageType.ConnectionAck }; /// diff --git a/src/Transports.AspNetCore/WebSockets/KeepAliveMode.cs b/src/Transports.AspNetCore/WebSockets/KeepAliveMode.cs new file mode 100644 index 00000000..2b02463e --- /dev/null +++ b/src/Transports.AspNetCore/WebSockets/KeepAliveMode.cs @@ -0,0 +1,36 @@ +namespace GraphQL.Server.Transports.AspNetCore.WebSockets; + +/// +/// Specifies the mode of keep-alive behavior. +/// +public enum KeepAliveMode +{ + /// + /// Same as : Sends a unidirectional keep-alive message when no message has been received within the specified timeout period. + /// + Default = 0, + + /// + /// Sends a unidirectional keep-alive message when no message has been received within the specified timeout period. + /// + Timeout = 1, + + /// + /// Sends a unidirectional keep-alive message at a fixed interval, regardless of message activity. + /// + Interval = 2, + + /// + /// Sends a Ping message with a payload after the specified timeout from the last received Pong, + /// and waits for a corresponding Pong response. Requires that the client reflects the payload + /// in the response. Forcibly disconnects the client if the client does not respond with a Pong + /// message within the specified timeout. This means that a dead connection will be closed after + /// a maximum of double the period. + /// + /// + /// This mode is particularly useful when backpressure causes subscription messages to be delayed + /// due to a slow or unresponsive client connection. The server can detect that the client is not + /// processing messages in a timely manner and disconnect the client to free up resources. + /// + TimeoutWithPayload = 3, +} diff --git a/src/Transports.AspNetCore/WebSockets/SubscriptionsTransportWs/SubscriptionServer.cs b/src/Transports.AspNetCore/WebSockets/SubscriptionsTransportWs/SubscriptionServer.cs index b0cf7c53..ccdebe6b 100644 --- a/src/Transports.AspNetCore/WebSockets/SubscriptionsTransportWs/SubscriptionServer.cs +++ b/src/Transports.AspNetCore/WebSockets/SubscriptionsTransportWs/SubscriptionServer.cs @@ -85,7 +85,9 @@ public override async Task OnMessageReceivedAsync(OperationMessage message) } else { +#pragma warning disable CS0618 // Type or member is obsolete await OnConnectionInitAsync(message, false); +#pragma warning restore CS0618 // Type or member is obsolete } return; } @@ -108,6 +110,26 @@ public override async Task OnMessageReceivedAsync(OperationMessage message) } } + /// + [Obsolete($"Please use the {nameof(OnConnectionInitAsync)} and {nameof(OnKeepAliveLoopAsync)} methods instead. This method will be removed in a future version of this library.")] + protected override Task OnConnectionInitAsync(OperationMessage message, bool smartKeepAlive) + { + if (!smartKeepAlive) + return base.OnConnectionInitAsync(message); + else + return base.OnConnectionInitAsync(message, smartKeepAlive); + } + + /// + /// + /// This implementation overrides to + /// as this protocol does not support the other modes. Override this method to support your own implementation. + /// + protected override Task OnKeepAliveLoopAsync(TimeSpan keepAliveTimeout, KeepAliveMode keepAliveMode) + => base.OnKeepAliveLoopAsync( + keepAliveTimeout, + KeepAliveMode.Interval); + private static readonly OperationMessage _keepAliveMessage = new() { Type = MessageType.GQL_CONNECTION_KEEP_ALIVE }; /// protected override Task OnSendKeepAliveAsync() diff --git a/tests/ApiApprovalTests/net50+net60+net80/GraphQL.Server.Transports.AspNetCore.approved.txt b/tests/ApiApprovalTests/net50+net60+net80/GraphQL.Server.Transports.AspNetCore.approved.txt index 066e8a44..4039cd6e 100644 --- a/tests/ApiApprovalTests/net50+net60+net80/GraphQL.Server.Transports.AspNetCore.approved.txt +++ b/tests/ApiApprovalTests/net50+net60+net80/GraphQL.Server.Transports.AspNetCore.approved.txt @@ -274,8 +274,13 @@ namespace GraphQL.Server.Transports.AspNetCore.WebSockets public virtual System.Threading.Tasks.Task InitializeConnectionAsync() { } protected virtual System.Threading.Tasks.Task OnCloseConnectionAsync() { } protected abstract System.Threading.Tasks.Task OnConnectionAcknowledgeAsync(GraphQL.Transport.OperationMessage message); + protected virtual System.Threading.Tasks.Task OnConnectionInitAsync(GraphQL.Transport.OperationMessage message) { } + [System.Obsolete("Please use the OnConnectionInitAsync(message) and OnKeepAliveLoopAsync methods in" + + "stead. This method will be removed in a future version of this library.")] protected virtual System.Threading.Tasks.Task OnConnectionInitAsync(GraphQL.Transport.OperationMessage message, bool smartKeepAlive) { } protected virtual System.Threading.Tasks.Task OnConnectionInitWaitTimeoutAsync() { } + protected virtual System.Threading.Tasks.Task OnKeepAliveLoopAsync() { } + protected virtual System.Threading.Tasks.Task OnKeepAliveLoopAsync(System.TimeSpan keepAliveTimeout, GraphQL.Server.Transports.AspNetCore.WebSockets.KeepAliveMode keepAliveMode) { } public abstract System.Threading.Tasks.Task OnMessageReceivedAsync(GraphQL.Transport.OperationMessage message); protected virtual System.Threading.Tasks.Task OnNotAuthenticatedAsync(GraphQL.Transport.OperationMessage message) { } protected virtual System.Threading.Tasks.Task OnNotAuthorizedPolicyAsync(GraphQL.Transport.OperationMessage message, Microsoft.AspNetCore.Authorization.AuthorizationResult result) { } @@ -300,7 +305,9 @@ namespace GraphQL.Server.Transports.AspNetCore.WebSockets public bool DisconnectAfterAnyError { get; set; } public bool DisconnectAfterErrorEvent { get; set; } public System.TimeSpan? DisconnectionTimeout { get; set; } + public GraphQL.Server.Transports.AspNetCore.WebSockets.KeepAliveMode KeepAliveMode { get; set; } public System.TimeSpan? KeepAliveTimeout { get; set; } + public System.Collections.Generic.List SupportedWebSocketSubProtocols { get; set; } } public interface IOperationMessageProcessor : System.IDisposable { @@ -321,6 +328,13 @@ namespace GraphQL.Server.Transports.AspNetCore.WebSockets System.Threading.Tasks.Task ExecuteAsync(GraphQL.Server.Transports.AspNetCore.WebSockets.IOperationMessageProcessor operationMessageProcessor); System.Threading.Tasks.Task SendMessageAsync(GraphQL.Transport.OperationMessage message); } + public enum KeepAliveMode + { + Default = 0, + Timeout = 1, + Interval = 2, + TimeoutWithPayload = 3, + } public sealed class SubscriptionList : System.IDisposable { public SubscriptionList() { } @@ -364,6 +378,11 @@ namespace GraphQL.Server.Transports.AspNetCore.WebSockets.GraphQLWs public const string Pong = "pong"; public const string Subscribe = "subscribe"; } + public class PingPayload + { + public PingPayload() { } + public string? id { get; set; } + } public class SubscriptionServer : GraphQL.Server.Transports.AspNetCore.WebSockets.BaseSubscriptionServer { public SubscriptionServer(GraphQL.Server.Transports.AspNetCore.WebSockets.IWebSocketConnection connection, GraphQL.Server.Transports.AspNetCore.WebSockets.GraphQLWebSocketOptions options, GraphQL.Server.Transports.AspNetCore.IAuthorizationOptions authorizationOptions, GraphQL.IDocumentExecuter executer, GraphQL.IGraphQLSerializer serializer, Microsoft.Extensions.DependencyInjection.IServiceScopeFactory serviceScopeFactory, GraphQL.Server.Transports.AspNetCore.IUserContextBuilder userContextBuilder, GraphQL.Server.Transports.AspNetCore.WebSockets.IWebSocketAuthenticationService? authenticationService = null) { } @@ -377,6 +396,10 @@ namespace GraphQL.Server.Transports.AspNetCore.WebSockets.GraphQLWs protected override System.Threading.Tasks.Task ExecuteRequestAsync(GraphQL.Transport.OperationMessage message) { } protected virtual System.Threading.Tasks.Task OnCompleteAsync(GraphQL.Transport.OperationMessage message) { } protected override System.Threading.Tasks.Task OnConnectionAcknowledgeAsync(GraphQL.Transport.OperationMessage message) { } + [System.Obsolete("Please use the OnConnectionInitAsync and OnKeepAliveLoopAsync methods instead. Th" + + "is method will be removed in a future version of this library.")] + protected override System.Threading.Tasks.Task OnConnectionInitAsync(GraphQL.Transport.OperationMessage message, bool smartKeepAlive) { } + protected override System.Threading.Tasks.Task OnKeepAliveLoopAsync(System.TimeSpan keepAliveTimeout, GraphQL.Server.Transports.AspNetCore.WebSockets.KeepAliveMode keepAliveMode) { } public override System.Threading.Tasks.Task OnMessageReceivedAsync(GraphQL.Transport.OperationMessage message) { } protected virtual System.Threading.Tasks.Task OnPingAsync(GraphQL.Transport.OperationMessage message) { } protected virtual System.Threading.Tasks.Task OnPongAsync(GraphQL.Transport.OperationMessage message) { } @@ -415,6 +438,10 @@ namespace GraphQL.Server.Transports.AspNetCore.WebSockets.SubscriptionsTransport protected override System.Threading.Tasks.Task ErrorAccessDeniedAsync() { } protected override System.Threading.Tasks.Task ExecuteRequestAsync(GraphQL.Transport.OperationMessage message) { } protected override System.Threading.Tasks.Task OnConnectionAcknowledgeAsync(GraphQL.Transport.OperationMessage message) { } + [System.Obsolete("Please use the OnConnectionInitAsync and OnKeepAliveLoopAsync methods instead. Th" + + "is method will be removed in a future version of this library.")] + protected override System.Threading.Tasks.Task OnConnectionInitAsync(GraphQL.Transport.OperationMessage message, bool smartKeepAlive) { } + protected override System.Threading.Tasks.Task OnKeepAliveLoopAsync(System.TimeSpan keepAliveTimeout, GraphQL.Server.Transports.AspNetCore.WebSockets.KeepAliveMode keepAliveMode) { } public override System.Threading.Tasks.Task OnMessageReceivedAsync(GraphQL.Transport.OperationMessage message) { } protected override System.Threading.Tasks.Task OnSendKeepAliveAsync() { } protected virtual System.Threading.Tasks.Task OnStartAsync(GraphQL.Transport.OperationMessage message) { } diff --git a/tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt b/tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt index 1216fed0..a3dc75d6 100644 --- a/tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt +++ b/tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt @@ -292,8 +292,13 @@ namespace GraphQL.Server.Transports.AspNetCore.WebSockets public virtual System.Threading.Tasks.Task InitializeConnectionAsync() { } protected virtual System.Threading.Tasks.Task OnCloseConnectionAsync() { } protected abstract System.Threading.Tasks.Task OnConnectionAcknowledgeAsync(GraphQL.Transport.OperationMessage message); + protected virtual System.Threading.Tasks.Task OnConnectionInitAsync(GraphQL.Transport.OperationMessage message) { } + [System.Obsolete("Please use the OnConnectionInitAsync(message) and OnKeepAliveLoopAsync methods in" + + "stead. This method will be removed in a future version of this library.")] protected virtual System.Threading.Tasks.Task OnConnectionInitAsync(GraphQL.Transport.OperationMessage message, bool smartKeepAlive) { } protected virtual System.Threading.Tasks.Task OnConnectionInitWaitTimeoutAsync() { } + protected virtual System.Threading.Tasks.Task OnKeepAliveLoopAsync() { } + protected virtual System.Threading.Tasks.Task OnKeepAliveLoopAsync(System.TimeSpan keepAliveTimeout, GraphQL.Server.Transports.AspNetCore.WebSockets.KeepAliveMode keepAliveMode) { } public abstract System.Threading.Tasks.Task OnMessageReceivedAsync(GraphQL.Transport.OperationMessage message); protected virtual System.Threading.Tasks.Task OnNotAuthenticatedAsync(GraphQL.Transport.OperationMessage message) { } protected virtual System.Threading.Tasks.Task OnNotAuthorizedPolicyAsync(GraphQL.Transport.OperationMessage message, Microsoft.AspNetCore.Authorization.AuthorizationResult result) { } @@ -318,7 +323,9 @@ namespace GraphQL.Server.Transports.AspNetCore.WebSockets public bool DisconnectAfterAnyError { get; set; } public bool DisconnectAfterErrorEvent { get; set; } public System.TimeSpan? DisconnectionTimeout { get; set; } + public GraphQL.Server.Transports.AspNetCore.WebSockets.KeepAliveMode KeepAliveMode { get; set; } public System.TimeSpan? KeepAliveTimeout { get; set; } + public System.Collections.Generic.List SupportedWebSocketSubProtocols { get; set; } } public interface IOperationMessageProcessor : System.IDisposable { @@ -339,6 +346,13 @@ namespace GraphQL.Server.Transports.AspNetCore.WebSockets System.Threading.Tasks.Task ExecuteAsync(GraphQL.Server.Transports.AspNetCore.WebSockets.IOperationMessageProcessor operationMessageProcessor); System.Threading.Tasks.Task SendMessageAsync(GraphQL.Transport.OperationMessage message); } + public enum KeepAliveMode + { + Default = 0, + Timeout = 1, + Interval = 2, + TimeoutWithPayload = 3, + } public sealed class SubscriptionList : System.IDisposable { public SubscriptionList() { } @@ -382,6 +396,11 @@ namespace GraphQL.Server.Transports.AspNetCore.WebSockets.GraphQLWs public const string Pong = "pong"; public const string Subscribe = "subscribe"; } + public class PingPayload + { + public PingPayload() { } + public string? id { get; set; } + } public class SubscriptionServer : GraphQL.Server.Transports.AspNetCore.WebSockets.BaseSubscriptionServer { public SubscriptionServer(GraphQL.Server.Transports.AspNetCore.WebSockets.IWebSocketConnection connection, GraphQL.Server.Transports.AspNetCore.WebSockets.GraphQLWebSocketOptions options, GraphQL.Server.Transports.AspNetCore.IAuthorizationOptions authorizationOptions, GraphQL.IDocumentExecuter executer, GraphQL.IGraphQLSerializer serializer, Microsoft.Extensions.DependencyInjection.IServiceScopeFactory serviceScopeFactory, GraphQL.Server.Transports.AspNetCore.IUserContextBuilder userContextBuilder, GraphQL.Server.Transports.AspNetCore.WebSockets.IWebSocketAuthenticationService? authenticationService = null) { } @@ -395,6 +414,10 @@ namespace GraphQL.Server.Transports.AspNetCore.WebSockets.GraphQLWs protected override System.Threading.Tasks.Task ExecuteRequestAsync(GraphQL.Transport.OperationMessage message) { } protected virtual System.Threading.Tasks.Task OnCompleteAsync(GraphQL.Transport.OperationMessage message) { } protected override System.Threading.Tasks.Task OnConnectionAcknowledgeAsync(GraphQL.Transport.OperationMessage message) { } + [System.Obsolete("Please use the OnConnectionInitAsync and OnKeepAliveLoopAsync methods instead. Th" + + "is method will be removed in a future version of this library.")] + protected override System.Threading.Tasks.Task OnConnectionInitAsync(GraphQL.Transport.OperationMessage message, bool smartKeepAlive) { } + protected override System.Threading.Tasks.Task OnKeepAliveLoopAsync(System.TimeSpan keepAliveTimeout, GraphQL.Server.Transports.AspNetCore.WebSockets.KeepAliveMode keepAliveMode) { } public override System.Threading.Tasks.Task OnMessageReceivedAsync(GraphQL.Transport.OperationMessage message) { } protected virtual System.Threading.Tasks.Task OnPingAsync(GraphQL.Transport.OperationMessage message) { } protected virtual System.Threading.Tasks.Task OnPongAsync(GraphQL.Transport.OperationMessage message) { } @@ -433,6 +456,10 @@ namespace GraphQL.Server.Transports.AspNetCore.WebSockets.SubscriptionsTransport protected override System.Threading.Tasks.Task ErrorAccessDeniedAsync() { } protected override System.Threading.Tasks.Task ExecuteRequestAsync(GraphQL.Transport.OperationMessage message) { } protected override System.Threading.Tasks.Task OnConnectionAcknowledgeAsync(GraphQL.Transport.OperationMessage message) { } + [System.Obsolete("Please use the OnConnectionInitAsync and OnKeepAliveLoopAsync methods instead. Th" + + "is method will be removed in a future version of this library.")] + protected override System.Threading.Tasks.Task OnConnectionInitAsync(GraphQL.Transport.OperationMessage message, bool smartKeepAlive) { } + protected override System.Threading.Tasks.Task OnKeepAliveLoopAsync(System.TimeSpan keepAliveTimeout, GraphQL.Server.Transports.AspNetCore.WebSockets.KeepAliveMode keepAliveMode) { } public override System.Threading.Tasks.Task OnMessageReceivedAsync(GraphQL.Transport.OperationMessage message) { } protected override System.Threading.Tasks.Task OnSendKeepAliveAsync() { } protected virtual System.Threading.Tasks.Task OnStartAsync(GraphQL.Transport.OperationMessage message) { } diff --git a/tests/ApiApprovalTests/netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt b/tests/ApiApprovalTests/netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt index 685396c3..34257df1 100644 --- a/tests/ApiApprovalTests/netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt +++ b/tests/ApiApprovalTests/netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt @@ -274,8 +274,13 @@ namespace GraphQL.Server.Transports.AspNetCore.WebSockets public virtual System.Threading.Tasks.Task InitializeConnectionAsync() { } protected virtual System.Threading.Tasks.Task OnCloseConnectionAsync() { } protected abstract System.Threading.Tasks.Task OnConnectionAcknowledgeAsync(GraphQL.Transport.OperationMessage message); + protected virtual System.Threading.Tasks.Task OnConnectionInitAsync(GraphQL.Transport.OperationMessage message) { } + [System.Obsolete("Please use the OnConnectionInitAsync(message) and OnKeepAliveLoopAsync methods in" + + "stead. This method will be removed in a future version of this library.")] protected virtual System.Threading.Tasks.Task OnConnectionInitAsync(GraphQL.Transport.OperationMessage message, bool smartKeepAlive) { } protected virtual System.Threading.Tasks.Task OnConnectionInitWaitTimeoutAsync() { } + protected virtual System.Threading.Tasks.Task OnKeepAliveLoopAsync() { } + protected virtual System.Threading.Tasks.Task OnKeepAliveLoopAsync(System.TimeSpan keepAliveTimeout, GraphQL.Server.Transports.AspNetCore.WebSockets.KeepAliveMode keepAliveMode) { } public abstract System.Threading.Tasks.Task OnMessageReceivedAsync(GraphQL.Transport.OperationMessage message); protected virtual System.Threading.Tasks.Task OnNotAuthenticatedAsync(GraphQL.Transport.OperationMessage message) { } protected virtual System.Threading.Tasks.Task OnNotAuthorizedPolicyAsync(GraphQL.Transport.OperationMessage message, Microsoft.AspNetCore.Authorization.AuthorizationResult result) { } @@ -300,7 +305,9 @@ namespace GraphQL.Server.Transports.AspNetCore.WebSockets public bool DisconnectAfterAnyError { get; set; } public bool DisconnectAfterErrorEvent { get; set; } public System.TimeSpan? DisconnectionTimeout { get; set; } + public GraphQL.Server.Transports.AspNetCore.WebSockets.KeepAliveMode KeepAliveMode { get; set; } public System.TimeSpan? KeepAliveTimeout { get; set; } + public System.Collections.Generic.List SupportedWebSocketSubProtocols { get; set; } } public interface IOperationMessageProcessor : System.IDisposable { @@ -321,6 +328,13 @@ namespace GraphQL.Server.Transports.AspNetCore.WebSockets System.Threading.Tasks.Task ExecuteAsync(GraphQL.Server.Transports.AspNetCore.WebSockets.IOperationMessageProcessor operationMessageProcessor); System.Threading.Tasks.Task SendMessageAsync(GraphQL.Transport.OperationMessage message); } + public enum KeepAliveMode + { + Default = 0, + Timeout = 1, + Interval = 2, + TimeoutWithPayload = 3, + } public sealed class SubscriptionList : System.IDisposable { public SubscriptionList() { } @@ -364,6 +378,11 @@ namespace GraphQL.Server.Transports.AspNetCore.WebSockets.GraphQLWs public const string Pong = "pong"; public const string Subscribe = "subscribe"; } + public class PingPayload + { + public PingPayload() { } + public string? id { get; set; } + } public class SubscriptionServer : GraphQL.Server.Transports.AspNetCore.WebSockets.BaseSubscriptionServer { public SubscriptionServer(GraphQL.Server.Transports.AspNetCore.WebSockets.IWebSocketConnection connection, GraphQL.Server.Transports.AspNetCore.WebSockets.GraphQLWebSocketOptions options, GraphQL.Server.Transports.AspNetCore.IAuthorizationOptions authorizationOptions, GraphQL.IDocumentExecuter executer, GraphQL.IGraphQLSerializer serializer, Microsoft.Extensions.DependencyInjection.IServiceScopeFactory serviceScopeFactory, GraphQL.Server.Transports.AspNetCore.IUserContextBuilder userContextBuilder, GraphQL.Server.Transports.AspNetCore.WebSockets.IWebSocketAuthenticationService? authenticationService = null) { } @@ -377,6 +396,10 @@ namespace GraphQL.Server.Transports.AspNetCore.WebSockets.GraphQLWs protected override System.Threading.Tasks.Task ExecuteRequestAsync(GraphQL.Transport.OperationMessage message) { } protected virtual System.Threading.Tasks.Task OnCompleteAsync(GraphQL.Transport.OperationMessage message) { } protected override System.Threading.Tasks.Task OnConnectionAcknowledgeAsync(GraphQL.Transport.OperationMessage message) { } + [System.Obsolete("Please use the OnConnectionInitAsync and OnKeepAliveLoopAsync methods instead. Th" + + "is method will be removed in a future version of this library.")] + protected override System.Threading.Tasks.Task OnConnectionInitAsync(GraphQL.Transport.OperationMessage message, bool smartKeepAlive) { } + protected override System.Threading.Tasks.Task OnKeepAliveLoopAsync(System.TimeSpan keepAliveTimeout, GraphQL.Server.Transports.AspNetCore.WebSockets.KeepAliveMode keepAliveMode) { } public override System.Threading.Tasks.Task OnMessageReceivedAsync(GraphQL.Transport.OperationMessage message) { } protected virtual System.Threading.Tasks.Task OnPingAsync(GraphQL.Transport.OperationMessage message) { } protected virtual System.Threading.Tasks.Task OnPongAsync(GraphQL.Transport.OperationMessage message) { } @@ -415,6 +438,10 @@ namespace GraphQL.Server.Transports.AspNetCore.WebSockets.SubscriptionsTransport protected override System.Threading.Tasks.Task ErrorAccessDeniedAsync() { } protected override System.Threading.Tasks.Task ExecuteRequestAsync(GraphQL.Transport.OperationMessage message) { } protected override System.Threading.Tasks.Task OnConnectionAcknowledgeAsync(GraphQL.Transport.OperationMessage message) { } + [System.Obsolete("Please use the OnConnectionInitAsync and OnKeepAliveLoopAsync methods instead. Th" + + "is method will be removed in a future version of this library.")] + protected override System.Threading.Tasks.Task OnConnectionInitAsync(GraphQL.Transport.OperationMessage message, bool smartKeepAlive) { } + protected override System.Threading.Tasks.Task OnKeepAliveLoopAsync(System.TimeSpan keepAliveTimeout, GraphQL.Server.Transports.AspNetCore.WebSockets.KeepAliveMode keepAliveMode) { } public override System.Threading.Tasks.Task OnMessageReceivedAsync(GraphQL.Transport.OperationMessage message) { } protected override System.Threading.Tasks.Task OnSendKeepAliveAsync() { } protected virtual System.Threading.Tasks.Task OnStartAsync(GraphQL.Transport.OperationMessage message) { } diff --git a/tests/Transports.AspNetCore.Tests/WebSockets/TestBaseSubscriptionServer.cs b/tests/Transports.AspNetCore.Tests/WebSockets/TestBaseSubscriptionServer.cs index 15e6df12..c5749d5c 100644 --- a/tests/Transports.AspNetCore.Tests/WebSockets/TestBaseSubscriptionServer.cs +++ b/tests/Transports.AspNetCore.Tests/WebSockets/TestBaseSubscriptionServer.cs @@ -49,7 +49,9 @@ public Task Do_ErrorAccessDeniedAsync() => ErrorAccessDeniedAsync(); public Task Do_OnConnectionInitAsync(OperationMessage message, bool smartKeepAlive) +#pragma warning disable CS0618 // Type or member is obsolete => OnConnectionInitAsync(message, smartKeepAlive); +#pragma warning restore CS0618 // Type or member is obsolete public Task Do_SubscribeAsync(OperationMessage message, bool overwrite) => SubscribeAsync(message, overwrite); From 4c339d543f41b031bbc8e1d09244cf285f751a44 Mon Sep 17 00:00:00 2001 From: Shane32 Date: Mon, 7 Oct 2024 00:16:59 -0400 Subject: [PATCH 2/8] update --- src/Transports.AspNetCore/WebSockets/GraphQLWs/PingPayload.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Transports.AspNetCore/WebSockets/GraphQLWs/PingPayload.cs b/src/Transports.AspNetCore/WebSockets/GraphQLWs/PingPayload.cs index a673cf8e..1878f6df 100644 --- a/src/Transports.AspNetCore/WebSockets/GraphQLWs/PingPayload.cs +++ b/src/Transports.AspNetCore/WebSockets/GraphQLWs/PingPayload.cs @@ -1,4 +1,4 @@ -namespace GraphQL.Server.Transports.AspNetCore.WebSockets.GraphQLWs; +namespace GraphQL.Server.Transports.AspNetCore.WebSockets.GraphQLWs; /// /// The payload of the ping message. From 2cad736e7246d13d136cf0c9cf997f5da2fa0808 Mon Sep 17 00:00:00 2001 From: Shane32 Date: Sun, 20 Oct 2024 14:58:35 -0400 Subject: [PATCH 3/8] Update --- src/Transports.AspNetCore/WebSockets/BaseSubscriptionServer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Transports.AspNetCore/WebSockets/BaseSubscriptionServer.cs b/src/Transports.AspNetCore/WebSockets/BaseSubscriptionServer.cs index ca0186e7..4f053946 100644 --- a/src/Transports.AspNetCore/WebSockets/BaseSubscriptionServer.cs +++ b/src/Transports.AspNetCore/WebSockets/BaseSubscriptionServer.cs @@ -268,7 +268,7 @@ protected virtual async Task OnConnectionInitAsync(OperationMessage message) return; } await OnConnectionAcknowledgeAsync(message); - if (TryInitialize() == false) + if (!TryInitialize()) return; _ = OnKeepAliveLoopAsync(); From 4bc4f43d7334a34e728f85e6a4254509aa0a3d3b Mon Sep 17 00:00:00 2001 From: Shane32 Date: Sat, 26 Oct 2024 23:39:36 -0400 Subject: [PATCH 4/8] Add tests --- .../GraphQLWs/SubscriptionServer.cs | 2 +- .../SubscriptionServer.cs | 2 +- .../WebSockets/NewSubscriptionServerTests.cs | 97 ++++++++++++++++--- .../WebSockets/OldSubscriptionServerTests.cs | 8 ++ .../WebSockets/TestNewSubscriptionServer.cs | 5 + 5 files changed, 99 insertions(+), 15 deletions(-) diff --git a/src/Transports.AspNetCore/WebSockets/GraphQLWs/SubscriptionServer.cs b/src/Transports.AspNetCore/WebSockets/GraphQLWs/SubscriptionServer.cs index 7dbbcc2e..67806c17 100644 --- a/src/Transports.AspNetCore/WebSockets/GraphQLWs/SubscriptionServer.cs +++ b/src/Transports.AspNetCore/WebSockets/GraphQLWs/SubscriptionServer.cs @@ -127,7 +127,7 @@ public override async Task OnMessageReceivedAsync(OperationMessage message) protected override Task OnConnectionInitAsync(OperationMessage message, bool smartKeepAlive) { if (smartKeepAlive) - return base.OnConnectionInitAsync(message); + return OnConnectionInitAsync(message); else return base.OnConnectionInitAsync(message, smartKeepAlive); } diff --git a/src/Transports.AspNetCore/WebSockets/SubscriptionsTransportWs/SubscriptionServer.cs b/src/Transports.AspNetCore/WebSockets/SubscriptionsTransportWs/SubscriptionServer.cs index ccdebe6b..88514c53 100644 --- a/src/Transports.AspNetCore/WebSockets/SubscriptionsTransportWs/SubscriptionServer.cs +++ b/src/Transports.AspNetCore/WebSockets/SubscriptionsTransportWs/SubscriptionServer.cs @@ -115,7 +115,7 @@ public override async Task OnMessageReceivedAsync(OperationMessage message) protected override Task OnConnectionInitAsync(OperationMessage message, bool smartKeepAlive) { if (!smartKeepAlive) - return base.OnConnectionInitAsync(message); + return OnConnectionInitAsync(message); else return base.OnConnectionInitAsync(message, smartKeepAlive); } diff --git a/tests/Transports.AspNetCore.Tests/WebSockets/NewSubscriptionServerTests.cs b/tests/Transports.AspNetCore.Tests/WebSockets/NewSubscriptionServerTests.cs index 41351ed6..74bc9521 100644 --- a/tests/Transports.AspNetCore.Tests/WebSockets/NewSubscriptionServerTests.cs +++ b/tests/Transports.AspNetCore.Tests/WebSockets/NewSubscriptionServerTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using GraphQL.Server.Transports.AspNetCore.WebSockets.GraphQLWs; namespace Tests.WebSockets; @@ -67,6 +68,14 @@ public async Task Message_Initialize(bool initialized) else { _mockServer.Protected().Setup("OnConnectionInitAsync", message, true) + .CallBase().Verifiable(); + _mockServer.Protected().Setup("OnConnectionInitAsync", message) + .CallBase().Verifiable(); + _mockServer.Protected().Setup>("AuthorizeAsync", message) + .Returns(new ValueTask(true)).Verifiable(); + _mockServer.Protected().Setup("OnConnectionAcknowledgeAsync", message) + .Returns(Task.CompletedTask).Verifiable(); + _mockServer.Protected().Setup("OnKeepAliveLoopAsync") .Returns(Task.CompletedTask).Verifiable(); } _mockServer.Setup(x => x.OnMessageReceivedAsync(message)).CallBase().Verifiable(); @@ -99,11 +108,15 @@ public async Task Message_ThrowsWhenNotInitialized(string? messageType) } [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task Message_Ping(bool initialized) + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Message_Ping(bool initialized, bool withPayload) { var message = new OperationMessage { Type = "ping" }; + if (withPayload) + message.Payload = new PingPayload { id = Guid.NewGuid().ToString("N") }; _mockServer.Protected().Setup("OnPingAsync", message) .Returns(Task.CompletedTask).Verifiable(); if (initialized) @@ -117,11 +130,15 @@ public async Task Message_Ping(bool initialized) } [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task Message_Pong(bool initialized) + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Message_Pong(bool initialized, bool withPayload) { var message = new OperationMessage { Type = "pong" }; + if (withPayload) + message.Payload = new PingPayload { id = Guid.NewGuid().ToString("N") }; _mockServer.Protected().Setup("OnPongAsync", message) .Returns(Task.CompletedTask).Verifiable(); if (initialized) @@ -180,11 +197,31 @@ public async Task Message_Unknown(string? messageType) _mockServer.VerifyNoOtherCalls(); } - [Fact] - public async Task OnSendKeepAliveAsync() + [Theory] + [InlineData(KeepAliveMode.Default)] + [InlineData(KeepAliveMode.Interval)] + [InlineData(KeepAliveMode.Timeout)] + [InlineData(KeepAliveMode.TimeoutWithPayload)] + public async Task OnSendKeepAliveAsync(KeepAliveMode keepAliveMode) { + _options.WebSockets.KeepAliveMode = keepAliveMode; _mockStream.Setup(x => x.SendMessageAsync(It.IsAny())) - .Returns(o => o.Type == "pong" ? Task.CompletedTask : Task.FromException(new Exception())) + .Returns(async o => + { + o.Id.ShouldBeNull(); + if (keepAliveMode == KeepAliveMode.TimeoutWithPayload) + { + o.Type.ShouldBe("ping"); + var payload = o.Payload.ShouldBeOfType(); + var guid = Guid.ParseExact(payload.id.ShouldNotBeNull(), "N"); + guid.ShouldNotBe(Guid.Empty); + } + else + { + o.Type.ShouldBe("pong"); + o.Payload.ShouldBeNull(); + } + }) .Verifiable(); _mockServer.Protected().Setup("OnSendKeepAliveAsync").CallBase().Verifiable(); await _server.Do_OnSendKeepAliveAsync(); @@ -192,6 +229,25 @@ public async Task OnSendKeepAliveAsync() _mockServer.VerifyNoOtherCalls(); } + [Theory] + [InlineData(KeepAliveMode.Default)] + [InlineData(KeepAliveMode.Interval)] + [InlineData(KeepAliveMode.Timeout)] + [InlineData(KeepAliveMode.TimeoutWithPayload)] + public async Task OnKeepAliveLoopAsync(KeepAliveMode keepAliveMode) + { + _options.WebSockets.KeepAliveMode = keepAliveMode; + var defaultKeepAliveTimeout = TimeSpan.FromSeconds(10); + _mockServer.Protected().SetupGet("DefaultKeepAliveTimeout") + .Returns(defaultKeepAliveTimeout).Verifiable(); + _mockServer.Protected().Setup("OnKeepAliveLoopAsync").CallBase().Verifiable(); + _mockServer.Protected().Setup("OnKeepAliveLoopAsync", defaultKeepAliveTimeout, keepAliveMode) + .Returns(Task.CompletedTask).Verifiable(); + await _server.Do_OnKeepAliveLoopAsync(); + _mockServer.Verify(); + _mockServer.VerifyNoOtherCalls(); + } + [Fact] public async Task OnConnectionAcknowledgeAsync() { @@ -205,13 +261,28 @@ public async Task OnConnectionAcknowledgeAsync() _mockServer.VerifyNoOtherCalls(); } - [Fact] - public async Task OnPingAsync() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task OnPingAsync(bool withPayload) { + var payload = new { id = Guid.NewGuid().ToString("N") }; _mockStream.Setup(x => x.SendMessageAsync(It.IsAny())) - .Returns(o => o.Type == "pong" ? Task.CompletedTask : Task.FromException(new Exception())) + .Returns(async o => + { + o.Id.ShouldBeNull(); + o.Type.ShouldBe("pong"); + if (withPayload) + { + o.Payload.ShouldBe(payload); + } + else + { + o.Payload.ShouldBeNull(); + } + }) .Verifiable(); - var message = new OperationMessage(); + var message = new OperationMessage() { Payload = withPayload ? payload : null }; _mockServer.Protected().Setup("OnPingAsync", message).CallBase().Verifiable(); await _server.Do_OnPingAsync(message); _mockServer.Verify(); diff --git a/tests/Transports.AspNetCore.Tests/WebSockets/OldSubscriptionServerTests.cs b/tests/Transports.AspNetCore.Tests/WebSockets/OldSubscriptionServerTests.cs index ec1ec3e2..db1bd8ed 100644 --- a/tests/Transports.AspNetCore.Tests/WebSockets/OldSubscriptionServerTests.cs +++ b/tests/Transports.AspNetCore.Tests/WebSockets/OldSubscriptionServerTests.cs @@ -87,6 +87,14 @@ public async Task Message_Initialize(bool initialized) else { _mockServer.Protected().Setup("OnConnectionInitAsync", message, false) + .CallBase().Verifiable(); + _mockServer.Protected().Setup("OnConnectionInitAsync", message) + .CallBase().Verifiable(); + _mockServer.Protected().Setup>("AuthorizeAsync", message) + .Returns(new ValueTask(true)).Verifiable(); + _mockServer.Protected().Setup("OnConnectionAcknowledgeAsync", message) + .Returns(Task.CompletedTask).Verifiable(); + _mockServer.Protected().Setup("OnKeepAliveLoopAsync") .Returns(Task.CompletedTask).Verifiable(); } _mockServer.Setup(x => x.OnMessageReceivedAsync(message)).CallBase().Verifiable(); diff --git a/tests/Transports.AspNetCore.Tests/WebSockets/TestNewSubscriptionServer.cs b/tests/Transports.AspNetCore.Tests/WebSockets/TestNewSubscriptionServer.cs index c0874e44..5b68fbca 100644 --- a/tests/Transports.AspNetCore.Tests/WebSockets/TestNewSubscriptionServer.cs +++ b/tests/Transports.AspNetCore.Tests/WebSockets/TestNewSubscriptionServer.cs @@ -42,6 +42,9 @@ public Task Do_SendCompletedAsync(string id) public Task Do_ExecuteRequestAsync(OperationMessage message) => ExecuteRequestAsync(message); + public Task Do_OnKeepAliveLoopAsync() + => OnKeepAliveLoopAsync(); + public SubscriptionList Get_Subscriptions => Subscriptions; @@ -56,4 +59,6 @@ public SubscriptionList Get_Subscriptions public IDocumentExecuter Get_DocumentExecuter => DocumentExecuter; public IServiceScopeFactory Get_ServiceScopeFactory => ServiceScopeFactory; + + public TimeSpan Get_DefaultKeepAliveTimeout => DefaultKeepAliveTimeout; } From 1ab10e9985b37af92793b511801a7cd9887b7677 Mon Sep 17 00:00:00 2001 From: Shane32 Date: Sun, 27 Oct 2024 00:53:52 -0400 Subject: [PATCH 5/8] Update readme --- README.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 87412b51..00bd1599 100644 --- a/README.md +++ b/README.md @@ -730,10 +730,12 @@ methods allowing for different options for each configured endpoint. | Property | Description | Default value | |-----------------------------|----------------------|---------------| | `ConnectionInitWaitTimeout` | The amount of time to wait for a GraphQL initialization packet before the connection is closed. | 10 seconds | -| `KeepAliveTimeout` | The amount of time to wait between sending keep-alive packets. | disabled | | `DisconnectionTimeout` | The amount of time to wait to attempt a graceful teardown of the WebSockets protocol. | 10 seconds | | `DisconnectAfterErrorEvent` | Disconnects a subscription from the client if the subscription source dispatches an `OnError` event. | True | | `DisconnectAfterAnyError` | Disconnects a subscription from the client if there are any GraphQL errors during a subscription. | False | +| `KeepAliveMode` | The mode to use for sending keep-alive packets. | protocol-dependent | +| `KeepAliveTimeout` | The amount of time to wait between sending keep-alive packets. | disabled | +| `SupportedWebSocketSubProtocols` | A list of supported WebSocket sub-protocols. | `graphql-ws`, `graphql-transport-ws` | ### Multi-schema configuration @@ -800,6 +802,59 @@ public class MySchema : Schema } ``` +### Keep-alive configuration + +By default, the middleware will not send keep-alive packets to the client. As the underlying +operating system may not detect a disconnected client until a message is sent, you may wish to +enable keep-alive packets to be sent periodically. The default mode for keep-alive packets +differs depending on whether the client connected with the `graphql-ws` or `graphql-transport-ws` +sub-protocol. The `graphql-ws` sub-protocol will send a unidirectional keep-alive packet to the +client on a fixed schedule, while the `graphql-transport-ws` sub-protocol will only send +unidirectional keep-alive packets when the client has not sent a message within a certain time. +The differing behavior is due to the default implementation of the `graphql-ws` sub-protocol +client, which after receiving a single keep-alive packet, expects additional keep-alive packets +to be sent sooner than every 20 seconds, regardless of the client's activity. + +To configure keep-alive packets, set the `KeepAliveMode` and `KeepAliveTimeout` properties +within the `GraphQLWebSocketOptions` object. Set the `KeepAliveTimeout` property to +enable keep-alive packets, or use `TimeSpan.Zero` or `Timeout.InfiniteTimeSpan` to disable it. + +The `KeepAliveMode` property is only applicable to the `graphql-transport-ws` sub-protocol and +can be set to the options listed below: + +| Keep-alive mode | Description | +|-----------------|-------------| +| `Default` | Same as `Timeout`. | +| `Timeout` | Sends a unidirectional keep-alive message when no message has been received within the specified timeout period. | +| `Interval` | Sends a unidirectional keep-alive message at a fixed interval, regardless of message activity. | +| `TimeoutWithPayload` | Sends a bidirectional keep-alive message with a payload on a fixed interval, and validates the payload matches in the response. | + +The `TimeoutWithPayload` model is particularly useful when the server may send messages to the +client at a faster pace than the client can process them. In this case queued messages will be +limited to double the timeout period, as the keep-alive message is queued along with other +packets sent from the server to the client. The client will need to respond to process queued +messages and respond to the keep-alive message within the timeout period or the server will +disconnect the client. When the server forcibly disconnects the client, no graceful teardown +of the WebSocket protocol occurs, and any queued messages are discarded. + +When using the `TimeoutWithPayload` keep-alive mode, you may wish to enforce that the +`graphql-transport-ws` sub-protocol is in use by the client. This can be done by setting +the `SupportedWebSocketSubProtocols` property to only include the `graphql-transport-ws` +sub-protocol. + +```csharp +app.UseGraphQL("/graphql", options => +{ + // configure keep-alive packets + options.WebSockets.KeepAliveTimeout = TimeSpan.FromSeconds(10); + options.WebSockets.KeepAliveMode = KeepAliveMode.TimeoutWithPayload; + // set the supported sub-protocols to only include the graphql-transport-ws sub-protocol + options.WebSockets.SupportedWebSocketSubProtocols = [GraphQLWs.SubscriptionServer.SubProtocol]; +}); +``` + +Please note that some UI packages do not support the `graphql-transport-ws` sub-protocol. + ### Customizing middleware behavior GET/POST requests are handled directly by the `GraphQLHttpMiddleware`. From d5ec0df263d68adcdfe86798b62d4ba8cbbd82ba Mon Sep 17 00:00:00 2001 From: Shane32 Date: Sun, 27 Oct 2024 00:56:21 -0400 Subject: [PATCH 6/8] Update --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 00bd1599..a404d1cd 100644 --- a/README.md +++ b/README.md @@ -838,9 +838,9 @@ disconnect the client. When the server forcibly disconnects the client, no grac of the WebSocket protocol occurs, and any queued messages are discarded. When using the `TimeoutWithPayload` keep-alive mode, you may wish to enforce that the -`graphql-transport-ws` sub-protocol is in use by the client. This can be done by setting -the `SupportedWebSocketSubProtocols` property to only include the `graphql-transport-ws` -sub-protocol. +`graphql-transport-ws` sub-protocol is in use by the client, as the `graphql-ws` sub-protocol +does not support bidirectional keep-alive packets. This can be done by setting the +`SupportedWebSocketSubProtocols` property to only include the `graphql-transport-ws` sub-protocol. ```csharp app.UseGraphQL("/graphql", options => From 32cd6bcbc3c6653a30188017007a11996756e50b Mon Sep 17 00:00:00 2001 From: Shane32 Date: Sun, 27 Oct 2024 01:07:45 -0400 Subject: [PATCH 7/8] update --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a404d1cd..3210b30f 100644 --- a/README.md +++ b/README.md @@ -853,7 +853,7 @@ app.UseGraphQL("/graphql", options => }); ``` -Please note that some UI packages do not support the `graphql-transport-ws` sub-protocol. +Please note that the included UI packages are configured to use the `graphql-ws` sub-protocol. ### Customizing middleware behavior From 2141f56c342213d7308a23823ff0966b9263fb33 Mon Sep 17 00:00:00 2001 From: Shane Krueger Date: Sun, 27 Oct 2024 09:04:01 -0400 Subject: [PATCH 8/8] Update src/Transports.AspNetCore/WebSockets/GraphQLWs/SubscriptionServer.cs --- .../WebSockets/GraphQLWs/SubscriptionServer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Transports.AspNetCore/WebSockets/GraphQLWs/SubscriptionServer.cs b/src/Transports.AspNetCore/WebSockets/GraphQLWs/SubscriptionServer.cs index 67806c17..bf296346 100644 --- a/src/Transports.AspNetCore/WebSockets/GraphQLWs/SubscriptionServer.cs +++ b/src/Transports.AspNetCore/WebSockets/GraphQLWs/SubscriptionServer.cs @@ -159,7 +159,7 @@ async Task SecureKeepAliveLoopAsync(TimeSpan pingInterval, TimeSpan pongInterval { lastPongReceivedUtc = _lastPongReceivedUtc; } - var nextPing = _lastPongReceivedUtc.Add(pingInterval); + var nextPing = lastPongReceivedUtc.Add(pingInterval); interval = nextPing.Subtract(now); if (interval > TimeSpan.Zero) // could easily be zero or less, if pongInterval is equal or greater than pingInterval await Task.Delay(interval, CancellationToken);