Repository navigation
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved cancellation and shutdown races can leak server operations and WebSocket connections, while decoding failures can leave subscribers waiting indefinitely.
7 open issues
Reader failures leave the WebSocket open without a reader · New Cancellation can send complete before subscribe · New Late channel registration can survive client shutdown · New Shutdown misses downstream subscriptions without active wire IDs · New Invalid demand is not handled or cleaned up promptly · New Setup failures leave subscribers unnotified and operations registered · New Pending delivery results are excluded from the buffer limit · New
What changed in this PR
Adds the client side of GraphQL G4: graphql-transport-ws subscriptions integrated with GraphqlClientAdapter. The PR also includes the prerequisite WebSocket lifecycle and shared-event-loop changes.
Changes:
- Adds multiplexed WebSocket subscriptions, connection initialization, demand-aware delivery, buffering limits, and cancellation.
- Lets WebSocket clients borrow the HTTP client’s Netty event loop without owning its lifecycle.
- Adds integration tests and release notes while keeping the WebSocket dependency optional.
| File | Description |
|---|---|
org.restlet/src/main/java/org/restlet/engine/netty/NettyClientHelper.java |
Exposes the connector’s event loop for extensions. |
org.restlet/src/main/java/org/restlet/Client.java |
Publishes the helper when the context is assigned. |
org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketTestCase.java |
Migrates setup to setter-based configuration. |
org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketSharedEventLoopTestCase.java |
Tests borrowed event-loop ownership and validation. |
org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketSessionTestCase.java |
Updates configuration tests and checks lifecycle restrictions. |
org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketCompressionTestCase.java |
Migrates compression tests to the new client API. |
org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketClient.java |
Adds setters, lifecycle methods, and HTTP-client borrowing. |
org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketConnector.java |
Supports shared event loops and tracks connections for shutdown. |
org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlWebSocketClientTestCase.java |
Tests multiplexing, demand, cancellation, limits, and failures. |
org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlWebSocketTransport.java |
Implements the GraphQL WebSocket client protocol. |
org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlSubscriptionTransport.java |
Isolates the optional subscription transport. |
org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlClientAdapter.java |
Adds transport selection and connection-init configuration. |
changes.md |
Documents GraphQL client support and connector changes. |
Review effort: Balanced
|
|
||
| /** The open connections, closed with the connector. */ | ||
| private final ChannelGroup channels = | ||
| new DefaultChannelGroup("restlet-websocket-client", GlobalEventExecutor.INSTANCE); |
There was a problem hiding this comment.
Not fixed in this PR: NettyWebSocketConnector comes from #1568 (fix/websocket-client-shared-event-loop), which this branch includes and where the same comment was posted (r4424962845). The fix belongs there and will come in when this branch is restacked on it.
ab5539d to
63ed523
Compare
950f70d to
0f851b5
Compare
Follow the Restlet API conventions used by the RPC extensions: constructors, getter and void setter pairs, and a start/stop lifecycle instead of a fluent builder. - Remove WebSocketClient.Builder, builder() and create(). Settings are now configured with setters on a new WebSocketClient(). - Add start(), stop() and isStarted(). close() stops the client, which can be started again. connect() starts a stopped client, like Restlet.handle(). - Reject setting changes while the client is started. The extension is unreleased, so the builder is removed rather than deprecated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
Also describe the setter-based configuration and restartable lifecycle of WebSocketClient in the changes log. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
WebSocketClient created its own Netty event loop group in every instance, next to the one of the core HTTP client connector, with a thread count that could not be configured. - Add NettyClientHelper.getEventLoopGroup(), an engine extension point exposing the client connector's event loop group. - Add WebSocketClient.setClient(Client): a borrowed, started Netty HTTP client whose event loop is shared. Without it, the client still creates its own threads. - Track the WebSocket connections in a channel group, so stopping the client closes its sessions without shutting down a shared event loop. - Store the helper in the "org.restlet.engine.helper" context attribute in Client.setContext(), as the constructors do. Clients added with Component.getClients().add(Protocol) get their context this way, and did not expose their helper. Fixes #1567 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
A connect() racing with stop() could register its channel after the channel group was closed. With a borrowed event loop, which isn't shut down, that connection stayed open. Keep the group closed so that late channels are closed when added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
- GraphqlClientAdapter.setSubscriptionTransport(WEBSOCKET): subscribe() multiplexes operations over one graphql-transport-ws connection, opened on the first demand at the endpoint with the ws or wss scheme. - The WebSocketClient borrows the event loop of the adapter's HTTP client (WebSocketClient.setClient, #1567), so no extra Netty threads are created. The request customizer adds handshake headers and setConnectionInitPayload sets the connection_init payload. - Each operation is delivered by its own virtual thread, honoring demand. The protocol has no flow control: results received without demand are buffered up to maxResponseBytes per operation, beyond which the operation fails and the server is sent complete. - Errors before execution are a single result; a closed connection fails its operations with the close code; cancel() sends complete; stop() fails open operations, closes the connection and stops the WebSocket client, but not the borrowed HTTP client. - The WebSocket code is only loaded when the transport is selected. Refs #1586 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
The client signals the overflow at once, possibly before delivering the first buffered result. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
Track subscribers from onSubscribe() so that stop() fails them all, signal non-positive demand without waiting for a result and complete the server operation, close the connection when its reader fails, send subscribe before any complete of a cancellation, signal setup failures, and count buffered results until they are delivered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0f851b5 to
50d00cb
Compare
|





Closes #1586. Part of #1581.
Summary
subscribe()multiplexes operations over onegraphql-transport-wsconnection, opened on the first demand (withconnection_initand a 10 s acknowledgement timeout) and reopened if it was closed.WebSocketClientborrows the event loop of the adapter's HTTP client throughWebSocketClient.setClient(fix: let WebSocketClient share a borrowed client's event loop #1568), so the extension creates no Netty event loop group.stop()stops the WebSocket client but never the borrowed HTTP client.graphql-transport-wshas no flow control: results received without demand are buffered up tomaxResponseBytesper operation; beyond it the operation fails with aGraphqlTransportExceptionand the server is sentcomplete, which cancels the server-side subscription.errormessage) are a single result with errors, as with HTTP and SSE. A closed connection fails its operations with the close code and reason.cancel()sendscomplete.connection_initpayload carries tokens for servers that authenticate there.org.restlet.ext.websocketstays optional.Finding on #1568
WebSocketClient.setClientrequires the borrowed client to have aContext(it reads the helper from the context attributes).new Client(Protocol.HTTP)has none, so it can't be borrowed:new Client(new Context(), Protocol.HTTP)is needed. Worth a clearer error message or a fallback there.Validation
./mvnw -B verify -P default,test -pl org.restlet.ext.graphql(with core and the WebSocket extension from this branch): 63 tests, 0 failures; 91.6% instruction coverage (gate 80%); Spotless, SpotBugs, Javadoc,-Werrorand dependency convergence pass.New
GraphqlWebSocketClientTestCase(7 tests) againstGraphqlServerAdapter: a subscription and a query share one upgrade (counted by a filter) with demand honored, the init payload and a handshake header reaching data fetchers; cancelling one of two subscriptions cancels it on the server; adapter stop fails the other one and keeps the borrowed client; errors before execution as results; buffered results bounded, cancelling the server-side subscription; server shutdown (1001) fails subscriptions; endpoint without WebSocket; a borrowed client that isn't a started Netty client is rejected atstart().🤖 Generated with Claude Code