Repository navigation
Bounded the memory a graphql-transport-ws client can make the server hold - #631
Open
xperiandri wants to merge 4 commits into
Open
xperiandri wants to merge 4 commits into
xperiandri wants to merge 4 commits into
Conversation
A client could make the server hold an arbitrary amount of memory per connection in two ways: the reader assembled a message of any size, and the inbox from the reader to the control loop was unbounded, so messages sent faster than the control loop handled them queued without limit. - Added GraphQLTransportWSOptions.MaxReceiveMessageSize, 4 MiB by default (GraphQLOptionsDefaults.WebSocketMaxReceiveMessageSize). The reader requests at most one byte past the limit, and a message exceeding it closes the connection with 1009 (MessageTooBig) without the rest of it being read. Breaking change: the record has a new field. - Bounded the inbox to 16 client messages with BoundedChannelFullMode.Wait: while it is full the reader does not read from the socket, so TCP flow control slows the client down. - Kept the single-owner loops free of deadlocks: subscription workers report their end through a separate unbounded queue, since a worker must never wait and the shutdown awaits the workers without reading it; the control loop handles those ends before every client message. The inbox is completed once the control loop stops, which fails a write the reader is waiting on. The reader stops after the first protocol failure, since the connection is closed on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The messages waiting for a client are serialized when they are queued and counted in bytes: once more than 16 MiB would wait for a client that does not read them, the pending send is cancelled, which aborts the socket. A client sending pings without reading the pongs made the connection hold every pong, without limit. A single larger message is still sent when nothing else waits. - The received messages wait in the inbox as their bytes and are deserialized by the control loop when it handles them: a parsed message of dense JSON took about ten times its size, so the inbox held far more than its documented bound. - A MaxReceiveMessageSize that is not positive now fails when the middleware is created instead of aborting every accepted connection. - Corrected the docs, which said the rest of a message over the size limit is never read: it is never buffered, but closing the socket may read and discard it. - Tests for the ids of subscriptions that ended while the inbox was full, a client that does not read, the byte limit of the sender queue and the startup check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
🔵 Needs a closer look
The security-sensitive concurrency, cancellation, and socket-abort behavior warrants final human review.
0 open findings
What changed in this PR
Bounds per-connection WebSocket memory usage through message-size limits and queue backpressure.
Changes:
- Limits incoming messages to 4 MiB by default.
- Bounds inbound messages and outbound serialized bytes.
- Adds validation and comprehensive WebSocket tests.
| File | Description |
|---|---|
RELEASE_NOTES.md |
Documents behavioral and breaking changes. |
GraphQLOptions.fs |
Adds the receive-size option and default. |
GraphQLWebsocketMiddleware.fs |
Validates the configured limit. |
ServiceCollectionExtensions.fs |
Applies the default limit. |
WebSocketMessaging.fs |
Introduces serialized queued messages. |
WebSocketTransport.fs |
Enforces size and outbound byte limits. |
WebSocketConnection.fs |
Adds bounded queues and backpressure handling. |
WebSocketConnectionTests.fs |
Covers limits, shutdown, and slow clients. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…onnection tests The tests name the close codes through `CustomWebSocketStatus` instead of spelling the numbers out, and assert with `Assert.Equal` and `Assert.InRange` instead of `Assert.True` on a comparison. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nnection tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Test Results 9 files 9 suites 14m 41s ⏱️ Results for commit af1fc52. |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A
graphql-transport-wsclient could make the server hold memory without limit, per connection, in three ways:Changes
GraphQLTransportWSOptions.MaxReceiveMessageSizeis 4 MiB by default. A message over it closes the connection with 1009 (MessageTooBig) as soon as one byte beyond the limit is received, without buffering more of it.BoundedChannelFullMode.Wait. While it is full, the reader does not read the socket, so TCP flow control slows the client down.Tests
The tests are xUnit, in
AspNetCore/WebSocketConnectionTests.fs:connection_init;The new tests fail when the behaviour is removed. The WebSocket tests passed 5 of 5 repeated runs.
An independent review ran a real Kestrel host and covered:
It found no deadlock, hang or leaked worker.
Verification:
Left for later
These predate this PR:
AsyncExecuteget no cancellation token, so a hanging one delays shutdown.completebefore it reports its end, so a client reusing an id at once could still, rarely, get 4409.🤖 Generated with Claude Code