Skip to content

fix: let WebSocketClient share a borrowed client's event loop - #1568

Open
jlouvel wants to merge 2 commits into
3.0from
fix/websocket-client-shared-event-loop
Open

jlouvel wants to merge 2 commits into
3.0from
fix/websocket-client-shared-event-loop

Conversation

@jlouvel

@jlouvel jlouvel commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

Fixes #1567.

Stacked on #1566 (replaces the WebSocketClient builder with setters). Merge #1566 first; this PR's base then moves to 3.0.

Summary

Each WebSocketClient created its own Netty event loop group, next to the one of the core HTTP client connector, with a thread count that couldn't be configured. A WebSocket client can now borrow a started Netty HTTP Client and run on its event loop:

Client http = new Client(new Context(), Protocol.HTTP);
http.start();

WebSocketClient ws = new WebSocketClient();
ws.setClient(http);
try (ws; WebSocketSession session = ws.connect("ws://host/")) {
    ...
}

Without setClient, the WebSocket client still creates its own threads, so existing code is unaffected.

Changes

Core

  • NettyClientHelper.getEventLoopGroup(): a new engine extension point that exposes the client connector's event loop group, or null when the connector is stopped. It is documented like NettyConnectionUpgrade: meant for Restlet extensions, and callers must not shut the group down.
  • Client.setContext() now stores the helper in the org.restlet.engine.helper context attribute, as the constructors already do. Clients added with Component.getClients().add(Protocol) get their context through setContext(), so they didn't expose their helper, contrary to the Client Javadoc.

WebSocket extension

  • WebSocketClient.setClient(Client) / getClient(). On start(), the client looks up the borrowed client's NettyClientHelper through the context attribute. It throws IllegalStateException if the client has no context, doesn't use the Netty HTTP connector, or isn't started.
  • NettyWebSocketConnector tracks its connections in a channel group. On stop(), it closes them and shuts down the event loop only if it created it. It used to rely on shutting down its own group to close sessions.
  • If the borrowed client is stopped, its open WebSocket sessions close, and connect() throws an IOException.

Only the event loop is shared. TLS and the other settings remain those of the WebSocketClient. Sharing the client connector's TLS configuration could be a follow-up.

Testing

  • New WebSocketSharedEventLoopTestCase (4 tests):
    • Stopping the WebSocket client closes its sessions and leaves the borrowed event loop running.
    • Stopping the borrowed client closes the sessions.
    • A client whose context is set after construction is accepted.
    • Unsuitable clients are rejected.
  • mvn -pl org.restlet.ext.websocket verify passes locally: 32 tests.
  • In org.restlet, the Client, ClientList, ClientResource, child client dispatcher, SSL client and Netty connection upgrade tests pass locally (55 tests in those classes). The full core suite was not run locally.

🤖 Generated with Claude Code

@jlouvel

jlouvel commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator Author

A usability point found while building the GraphQL WebSocket client on this PR (#1594):

The most common way to create a client, new Client(Protocol.HTTP), can't be borrowed. Without a context, the helper is never stored in org.restlet.engine.helper, so start() throws "The borrowed client must have a context and use the Netty HTTP connector". The workaround is new Client(new Context(), Protocol.HTTP), which the PR description shows, but it's easy to miss and the error doesn't say what to change.

Options:

  1. Resolve the helper without going through the context, for example with an engine-level accessor on Client, so that any started Netty client can be borrowed.
  2. Or keep the context lookup, document the requirement on setClient, and make the message say it, e.g. "create the client with new Client(new Context(), Protocol.HTTP)".

Option 1 seems better, since setClient is meant to accept the client an application already has.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A concurrent connect() can register a channel after shutdown closes the channel group, leaving it open on a borrowed event loop; the group needs stayClosed = true to close late registrations.

1 open issue
What changed in this PR

This PR lets WebSocket clients reuse a started Netty HTTP client’s event loop, reducing duplicate I/O threads while preserving independently owned threads by default.

Changes:

  • Adds borrowed-client configuration and a core event-loop accessor.
  • Tracks WebSocket connections for cleanup without stopping borrowed resources.
  • Adds lifecycle and validation tests, fixes helper exposure through Client.setContext(), and updates the changelog.
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 setting a context.
org.restlet.ext.websocket/​src/​test/​java/​org/​restlet/​ext/​websocket/​WebSocketSharedEventLoopTestCase.java Tests borrowed-loop lifecycle and client validation.
org.restlet.ext.websocket/​src/​main/​java/​org/​restlet/​ext/​websocket/​WebSocketClient.java Adds borrowed-client settings and startup validation.
org.restlet.ext.websocket/​src/​main/​java/​org/​restlet/​ext/​websocket/​internal/​NettyWebSocketConnector.java Supports shared event loops and explicit connection cleanup.
changes.md Documents event-loop sharing and the context-helper fix.

Review effort: Balanced

@jlouvel
jlouvel force-pushed the fix/websocket-client-shared-event-loop branch from c24fe96 to eec9acc Compare October 31, 2026 07:17
Base automatically changed from refactor/websocket-client-setters to 3.0 November 1, 2026 02:07
jlouvel and others added 2 commits October 31, 2026 22:08
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>
@jlouvel
jlouvel force-pushed the fix/websocket-client-shared-event-loop branch from eec9acc to 5c2aa48 Compare November 1, 2026 02:12
@sonarqubecloud

sonarqubecloud Bot commented Nov 1, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants