Skip to content

Commit d8d46a5

Browse files
authored
Add KeepAliveMode and SupportedWebSocketSubProtocols options (#80)
1 parent 60a8415 commit d8d46a5

File tree

13 files changed

+416
-20
lines changed

13 files changed

+416
-20
lines changed

README.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -631,10 +631,12 @@ endpoint.
631631
| Property | Description | Default value |
632632
|-----------------------------|----------------------|---------------|
633633
| `ConnectionInitWaitTimeout` | The amount of time to wait for a GraphQL initialization packet before the connection is closed. | 10 seconds |
634-
| `KeepAliveTimeout` | The amount of time to wait between sending keep-alive packets. | 30 seconds |
635634
| `DisconnectionTimeout` | The amount of time to wait to attempt a graceful teardown of the WebSockets protocol. | 10 seconds |
636635
| `DisconnectAfterErrorEvent` | Disconnects a subscription from the client if the subscription source dispatches an `OnError` event. | True |
637636
| `DisconnectAfterAnyError` | Disconnects a subscription from the client there are any GraphQL errors during a subscription. | False |
637+
| `KeepAliveMode` | The mode to use for sending keep-alive packets. | protocol-dependent |
638+
| `KeepAliveTimeout` | The amount of time to wait between sending keep-alive packets. | disabled |
639+
| `SupportedWebSocketSubProtocols` | A list of supported WebSocket sub-protocols. | `graphql-ws`, `graphql-transport-ws` |
638640

639641
### Multi-schema configuration
640642

@@ -699,6 +701,59 @@ public class MySchema : Schema
699701
}
700702
```
701703

704+
### Keep-alive configuration
705+
706+
By default, the middleware will not send keep-alive packets to the client. As the underlying
707+
operating system may not detect a disconnected client until a message is sent, you may wish to
708+
enable keep-alive packets to be sent periodically. The default mode for keep-alive packets
709+
differs depending on whether the client connected with the `graphql-ws` or `graphql-transport-ws`
710+
sub-protocol. The `graphql-ws` sub-protocol will send a unidirectional keep-alive packet to the
711+
client on a fixed schedule, while the `graphql-transport-ws` sub-protocol will only send
712+
unidirectional keep-alive packets when the client has not sent a message within a certain time.
713+
The differing behavior is due to the default implementation of the `graphql-ws` sub-protocol
714+
client, which after receiving a single keep-alive packet, expects additional keep-alive packets
715+
to be sent sooner than every 20 seconds, regardless of the client's activity.
716+
717+
To configure keep-alive packets, set the `KeepAliveMode` and `KeepAliveTimeout` properties
718+
within the `GraphQLWebSocketOptions` object. Set the `KeepAliveTimeout` property to
719+
enable keep-alive packets, or use `TimeSpan.Zero` or `Timeout.InfiniteTimeSpan` to disable it.
720+
721+
The `KeepAliveMode` property is only applicable to the `graphql-transport-ws` sub-protocol and
722+
can be set to the options listed below:
723+
724+
| Keep-alive mode | Description |
725+
|-----------------|-------------|
726+
| `Default` | Same as `Timeout`. |
727+
| `Timeout` | Sends a unidirectional keep-alive message when no message has been received within the specified timeout period. |
728+
| `Interval` | Sends a unidirectional keep-alive message at a fixed interval, regardless of message activity. |
729+
| `TimeoutWithPayload` | Sends a bidirectional keep-alive message with a payload on a fixed interval, and validates the payload matches in the response. |
730+
731+
The `TimeoutWithPayload` model is particularly useful when the server may send messages to the
732+
client at a faster pace than the client can process them. In this case queued messages will be
733+
limited to double the timeout period, as the keep-alive message is queued along with other
734+
packets sent from the server to the client. The client will need to respond to process queued
735+
messages and respond to the keep-alive message within the timeout period or the server will
736+
disconnect the client. When the server forcibly disconnects the client, no graceful teardown
737+
of the WebSocket protocol occurs, and any queued messages are discarded.
738+
739+
When using the `TimeoutWithPayload` keep-alive mode, you may wish to enforce that the
740+
`graphql-transport-ws` sub-protocol is in use by the client, as the `graphql-ws` sub-protocol
741+
does not support bidirectional keep-alive packets. This can be done by setting the
742+
`SupportedWebSocketSubProtocols` property to only include the `graphql-transport-ws` sub-protocol.
743+
744+
```csharp
745+
app.UseGraphQL("/graphql", options =>
746+
{
747+
// configure keep-alive packets
748+
options.WebSockets.KeepAliveTimeout = TimeSpan.FromSeconds(10);
749+
options.WebSockets.KeepAliveMode = KeepAliveMode.TimeoutWithPayload;
750+
// set the supported sub-protocols to only include the graphql-transport-ws sub-protocol
751+
options.WebSockets.SupportedWebSocketSubProtocols = [GraphQLWs.SubscriptionServer.SubProtocol];
752+
});
753+
```
754+
755+
Please note that the included UI packages are configured to use the `graphql-ws` sub-protocol.
756+
702757
### Customizing middleware behavior
703758

704759
GET/POST requests are handled directly by the `GraphQLHttpMiddleware`.

src/GraphQL.AspNetCore3/GraphQLHttpMiddleware.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -686,7 +686,7 @@ protected virtual Task WriteJsonResponseAsync<TResult>(HttpContext context, Http
686686
/// <summary>
687687
/// Gets a list of WebSocket sub-protocols supported.
688688
/// </summary>
689-
protected virtual IEnumerable<string> SupportedWebSocketSubProtocols => _supportedSubProtocols;
689+
protected virtual IEnumerable<string> SupportedWebSocketSubProtocols => _options.WebSockets.SupportedWebSocketSubProtocols;
690690

691691
/// <summary>
692692
/// Creates an <see cref="IWebSocketConnection"/>, a WebSocket message pump.

src/GraphQL.AspNetCore3/WebSockets/BaseSubscriptionServer.cs

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -256,10 +256,32 @@ protected virtual Task OnNotAuthorizedPolicyAsync(OperationMessage message, Auth
256256
/// <br/><br/>
257257
/// Otherwise, the connection is acknowledged via <see cref="OnConnectionAcknowledgeAsync(OperationMessage)"/>,
258258
/// <see cref="TryInitialize"/> is called to indicate that this WebSocket connection is ready to accept requests,
259-
/// and keep-alive messages are sent via <see cref="OnSendKeepAliveAsync"/> if configured to do so.
260-
/// Keep-alive messages are only sent if no messages have been sent over the WebSockets connection for the
261-
/// length of time configured in <see cref="GraphQLWebSocketOptions.KeepAliveTimeout"/>.
259+
/// and <see cref="OnSendKeepAliveAsync"/> is called to start sending keep-alive messages if configured to do so.
262260
/// </summary>
261+
protected virtual async Task OnConnectionInitAsync(OperationMessage message)
262+
{
263+
if (!await AuthorizeAsync(message)) {
264+
return;
265+
}
266+
await OnConnectionAcknowledgeAsync(message);
267+
if (!TryInitialize())
268+
return;
269+
270+
_ = OnKeepAliveLoopAsync();
271+
}
272+
273+
/// <summary>
274+
/// Executes when the client is attempting to initialize the connection.
275+
/// <br/><br/>
276+
/// By default, this first checks <see cref="AuthorizeAsync(OperationMessage)"/> to validate that the
277+
/// request has passed authentication. If validation fails, the connection is closed with an Access
278+
/// Denied message.
279+
/// <br/><br/>
280+
/// Otherwise, the connection is acknowledged via <see cref="OnConnectionAcknowledgeAsync(OperationMessage)"/>,
281+
/// <see cref="TryInitialize"/> is called to indicate that this WebSocket connection is ready to accept requests,
282+
/// and <see cref="OnSendKeepAliveAsync"/> is called to start sending keep-alive messages if configured to do so.
283+
/// </summary>
284+
[Obsolete($"Please use the {nameof(OnConnectionInitAsync)}(message) and {nameof(OnKeepAliveLoopAsync)} methods instead. This method will be removed in a future version of this library.")]
263285
protected virtual async Task OnConnectionInitAsync(OperationMessage message, bool smartKeepAlive)
264286
{
265287
if (!await AuthorizeAsync(message)) {
@@ -272,12 +294,48 @@ protected virtual async Task OnConnectionInitAsync(OperationMessage message, boo
272294
var keepAliveTimeout = _options.KeepAliveTimeout ?? DefaultKeepAliveTimeout;
273295
if (keepAliveTimeout > TimeSpan.Zero) {
274296
if (smartKeepAlive)
275-
_ = StartSmartKeepAliveLoopAsync();
297+
_ = OnKeepAliveLoopAsync(keepAliveTimeout, KeepAliveMode.Timeout);
276298
else
277-
_ = StartKeepAliveLoopAsync();
299+
_ = OnKeepAliveLoopAsync(keepAliveTimeout, KeepAliveMode.Interval);
300+
}
301+
}
302+
303+
/// <summary>
304+
/// Starts sending keep-alive messages if configured to do so. Inspects the configured
305+
/// <see cref="GraphQLWebSocketOptions"/> and passes control to <see cref="OnKeepAliveLoopAsync(TimeSpan, KeepAliveMode)"/>
306+
/// if keep-alive messages are enabled.
307+
/// </summary>
308+
protected virtual Task OnKeepAliveLoopAsync()
309+
{
310+
return OnKeepAliveLoopAsync(
311+
_options.KeepAliveTimeout ?? DefaultKeepAliveTimeout,
312+
_options.KeepAliveMode);
313+
}
314+
315+
/// <summary>
316+
/// Sends keep-alive messages according to the specified timeout period and method.
317+
/// See <see cref="KeepAliveMode"/> for implementation details for each supported mode.
318+
/// </summary>
319+
protected virtual async Task OnKeepAliveLoopAsync(TimeSpan keepAliveTimeout, KeepAliveMode keepAliveMode)
320+
{
321+
if (keepAliveTimeout <= TimeSpan.Zero)
322+
return;
323+
324+
switch (keepAliveMode) {
325+
case KeepAliveMode.Default:
326+
case KeepAliveMode.Timeout:
327+
await StartSmartKeepAliveLoopAsync();
328+
break;
329+
case KeepAliveMode.Interval:
330+
await StartDumbKeepAliveLoopAsync();
331+
break;
332+
case KeepAliveMode.TimeoutWithPayload:
333+
throw new NotImplementedException($"{nameof(KeepAliveMode.TimeoutWithPayload)} is not implemented within the {nameof(BaseSubscriptionServer)} class.");
334+
default:
335+
throw new ArgumentOutOfRangeException(nameof(keepAliveMode));
278336
}
279337

280-
async Task StartKeepAliveLoopAsync()
338+
async Task StartDumbKeepAliveLoopAsync()
281339
{
282340
while (!CancellationToken.IsCancellationRequested) {
283341
await Task.Delay(keepAliveTimeout, CancellationToken);

src/GraphQL.AspNetCore3/WebSockets/GraphQLWebSocketOptions.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ public class GraphQLWebSocketOptions
2222
/// </summary>
2323
public TimeSpan? KeepAliveTimeout { get; set; }
2424

25+
/// <summary>
26+
/// Gets or sets the keep-alive mode used for websocket subscriptions.
27+
/// This property is only applicable when using the GraphQLWs protocol.
28+
/// </summary>
29+
public KeepAliveMode KeepAliveMode { get; set; } = KeepAliveMode.Default;
30+
2531
/// <summary>
2632
/// The amount of time to wait to attempt a graceful teardown of the WebSockets protocol.
2733
/// The default is 10 seconds.
@@ -38,4 +44,17 @@ public class GraphQLWebSocketOptions
3844
/// Disconnects a subscription from the client there are any GraphQL errors during a subscription.
3945
/// </summary>
4046
public bool DisconnectAfterAnyError { get; set; }
47+
48+
/// <summary>
49+
/// The list of supported WebSocket sub-protocols.
50+
/// Defaults to <see cref="GraphQLWs.SubscriptionServer.SubProtocol"/> and <see cref="SubscriptionsTransportWs.SubscriptionServer.SubProtocol"/>.
51+
/// Adding other sub-protocols require the <see cref="GraphQLHttpMiddleware.CreateMessageProcessor(IWebSocketConnection, string)"/> method
52+
/// to be overridden to handle the new sub-protocol.
53+
/// </summary>
54+
/// <remarks>
55+
/// When the <see cref="KeepAliveMode"/> is set to <see cref="KeepAliveMode.TimeoutWithPayload"/>, you may wish to remove
56+
/// <see cref="SubscriptionsTransportWs.SubscriptionServer.SubProtocol"/> from this list to prevent clients from using
57+
/// protocols which do not support the <see cref="KeepAliveMode.TimeoutWithPayload"/> keep-alive mode.
58+
/// </remarks>
59+
public List<string> SupportedWebSocketSubProtocols { get; set; } = [GraphQLWs.SubscriptionServer.SubProtocol, SubscriptionsTransportWs.SubscriptionServer.SubProtocol];
4160
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace GraphQL.AspNetCore3.WebSockets.GraphQLWs;
2+
3+
/// <summary>
4+
/// The payload of the ping message.
5+
/// </summary>
6+
public class PingPayload
7+
{
8+
/// <summary>
9+
/// The unique identifier of the ping message.
10+
/// </summary>
11+
public string? id { get; set; }
12+
}

src/GraphQL.AspNetCore3/WebSockets/GraphQLWs/SubscriptionServer.cs

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ namespace GraphQL.AspNetCore3.WebSockets.GraphQLWs;
66
public class SubscriptionServer : BaseSubscriptionServer
77
{
88
private readonly IWebSocketAuthenticationService? _authenticationService;
9+
private readonly IGraphQLSerializer _serializer;
10+
private readonly GraphQLWebSocketOptions _options;
11+
private DateTime _lastPongReceivedUtc;
12+
private string? _lastPingId;
13+
private readonly object _lastPingLock = new();
914

1015
/// <summary>
1116
/// The WebSocket sub-protocol used for this protocol.
@@ -69,6 +74,8 @@ public SubscriptionServer(
6974
UserContextBuilder = userContextBuilder ?? throw new ArgumentNullException(nameof(userContextBuilder));
7075
Serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
7176
_authenticationService = authenticationService;
77+
_serializer = serializer;
78+
_options = options;
7279
}
7380

7481
/// <inheritdoc/>
@@ -84,7 +91,9 @@ public override async Task OnMessageReceivedAsync(OperationMessage message)
8491
if (Initialized) {
8592
await ErrorTooManyInitializationRequestsAsync(message);
8693
} else {
94+
#pragma warning disable CS0618 // Type or member is obsolete
8795
await OnConnectionInitAsync(message, true);
96+
#pragma warning restore CS0618 // Type or member is obsolete
8897
}
8998
return;
9099
}
@@ -105,6 +114,64 @@ public override async Task OnMessageReceivedAsync(OperationMessage message)
105114
}
106115
}
107116

117+
/// <inheritdoc/>
118+
[Obsolete($"Please use the {nameof(OnConnectionInitAsync)} and {nameof(OnKeepAliveLoopAsync)} methods instead. This method will be removed in a future version of this library.")]
119+
protected override Task OnConnectionInitAsync(OperationMessage message, bool smartKeepAlive)
120+
{
121+
if (smartKeepAlive)
122+
return OnConnectionInitAsync(message);
123+
else
124+
return base.OnConnectionInitAsync(message, smartKeepAlive);
125+
}
126+
127+
/// <inheritdoc/>
128+
protected override Task OnKeepAliveLoopAsync(TimeSpan keepAliveTimeout, KeepAliveMode keepAliveMode)
129+
{
130+
if (keepAliveMode == KeepAliveMode.TimeoutWithPayload) {
131+
if (keepAliveTimeout <= TimeSpan.Zero)
132+
return Task.CompletedTask;
133+
return SecureKeepAliveLoopAsync(keepAliveTimeout, keepAliveTimeout);
134+
}
135+
return base.OnKeepAliveLoopAsync(keepAliveTimeout, keepAliveMode);
136+
137+
// pingInterval is the time since the last pong was received before sending a new ping
138+
// pongInterval is the time to wait for a pong after a ping was sent before forcibly closing the connection
139+
async Task SecureKeepAliveLoopAsync(TimeSpan pingInterval, TimeSpan pongInterval)
140+
{
141+
lock (_lastPingLock)
142+
_lastPongReceivedUtc = DateTime.UtcNow;
143+
while (!CancellationToken.IsCancellationRequested) {
144+
// Wait for the next ping interval
145+
TimeSpan interval;
146+
var now = DateTime.UtcNow;
147+
DateTime lastPongReceivedUtc;
148+
lock (_lastPingLock) {
149+
lastPongReceivedUtc = _lastPongReceivedUtc;
150+
}
151+
var nextPing = lastPongReceivedUtc.Add(pingInterval);
152+
interval = nextPing.Subtract(now);
153+
if (interval > TimeSpan.Zero) // could easily be zero or less, if pongInterval is equal or greater than pingInterval
154+
await Task.Delay(interval, CancellationToken);
155+
156+
// Send a new ping message
157+
await OnSendKeepAliveAsync();
158+
159+
// Wait for the pong response
160+
await Task.Delay(pongInterval, CancellationToken);
161+
bool abort;
162+
lock (_lastPingLock) {
163+
abort = _lastPongReceivedUtc == lastPongReceivedUtc;
164+
}
165+
if (abort) {
166+
// Forcibly close the connection if the client has not responded to the keep-alive message.
167+
// Do not send a close message to the client or wait for a response.
168+
Connection.HttpContext.Abort();
169+
return;
170+
}
171+
}
172+
}
173+
}
174+
108175
/// <summary>
109176
/// Pong is a required response to a ping, and also a unidirectional keep-alive packet,
110177
/// whereas ping is a bidirectional keep-alive packet.
@@ -123,11 +190,37 @@ protected virtual Task OnPingAsync(OperationMessage message)
123190
/// Executes when a pong message is received.
124191
/// </summary>
125192
protected virtual Task OnPongAsync(OperationMessage message)
126-
=> Task.CompletedTask;
193+
{
194+
if (_options.KeepAliveMode == KeepAliveMode.TimeoutWithPayload) {
195+
try {
196+
var pingId = _serializer.ReadNode<PingPayload>(message.Payload)?.id;
197+
lock (_lastPingLock) {
198+
if (_lastPingId == pingId)
199+
_lastPongReceivedUtc = DateTime.UtcNow;
200+
}
201+
} catch { } // ignore deserialization errors in case the pong message does not match the expected format
202+
}
203+
return Task.CompletedTask;
204+
}
127205

128206
/// <inheritdoc/>
129207
protected override Task OnSendKeepAliveAsync()
130-
=> Connection.SendMessageAsync(_pongMessage);
208+
{
209+
if (_options.KeepAliveMode == KeepAliveMode.TimeoutWithPayload) {
210+
var lastPingId = Guid.NewGuid().ToString("N");
211+
lock (_lastPingLock) {
212+
_lastPingId = lastPingId;
213+
}
214+
return Connection.SendMessageAsync(
215+
new() {
216+
Type = MessageType.Ping,
217+
Payload = new PingPayload { id = lastPingId }
218+
}
219+
);
220+
} else {
221+
return Connection.SendMessageAsync(_pongMessage);
222+
}
223+
}
131224

132225
private static readonly OperationMessage _connectionAckMessage = new() { Type = MessageType.ConnectionAck };
133226
/// <inheritdoc/>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
namespace GraphQL.AspNetCore3.WebSockets;
2+
3+
/// <summary>
4+
/// Specifies the mode of keep-alive behavior.
5+
/// </summary>
6+
public enum KeepAliveMode
7+
{
8+
/// <summary>
9+
/// Same as <see cref="Timeout"/>: Sends a unidirectional keep-alive message when no message has been received within the specified timeout period.
10+
/// </summary>
11+
Default = 0,
12+
13+
/// <summary>
14+
/// Sends a unidirectional keep-alive message when no message has been received within the specified timeout period.
15+
/// </summary>
16+
Timeout = 1,
17+
18+
/// <summary>
19+
/// Sends a unidirectional keep-alive message at a fixed interval, regardless of message activity.
20+
/// </summary>
21+
Interval = 2,
22+
23+
/// <summary>
24+
/// Sends a Ping message with a payload after the specified timeout from the last received Pong,
25+
/// and waits for a corresponding Pong response. Requires that the client reflects the payload
26+
/// in the response. Forcibly disconnects the client if the client does not respond with a Pong
27+
/// message within the specified timeout. This means that a dead connection will be closed after
28+
/// a maximum of double the <see cref="GraphQLWebSocketOptions.KeepAliveTimeout"/> period.
29+
/// </summary>
30+
/// <remarks>
31+
/// This mode is particularly useful when backpressure causes subscription messages to be delayed
32+
/// due to a slow or unresponsive client connection. The server can detect that the client is not
33+
/// processing messages in a timely manner and disconnect the client to free up resources.
34+
/// </remarks>
35+
TimeoutWithPayload = 3,
36+
}

0 commit comments

Comments
 (0)