[grid] Router bypass WebSocket data path via transparent TCP tunnel#17146
Conversation
PR TypeEnhancement Description
|
| Relevant files | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Enhancement |
| ||||||||||||
| Tests |
| ||||||||||||
| Configuration changes |
|
PR Compliance Guide 🔍Below is a summary of compliance checks for this PR:
Compliance status legend🟢 - Fully Compliant🟡 - Partial Compliant 🔴 - Not Compliant ⚪ - Requires Further Human Verification 🏷️ - Compliance label |
||||||||||||||||||||||||||
PR Code Suggestions ✨Explore these optional code suggestions:
|
|||||||||||
There was a problem hiding this comment.
Pull request overview
This PR optimizes Selenium Grid Router WebSocket (e.g., BiDi) handling by bypassing application-layer proxying and instead establishing a transparent TCP tunnel to the target Node for eligible WebSocket upgrade requests, reducing per-frame overhead.
Changes:
- Add a Netty pipeline interceptor that detects session-scoped WS upgrades and bridges client↔node channels via a raw TCP tunnel (with TLS support for https nodes and graceful fallback).
- Extend Netty server initialization APIs to optionally enable the tunnel via a resolver function.
- Add integration-style tests validating text/binary tunneling, HTTPS-node tunneling, fallback behavior, and a full Grid session lifecycle path.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| java/src/org/openqa/selenium/netty/server/TcpUpgradeTunnelHandler.java | Implements WS-upgrade interception and tunnel establishment (incl. TLS-to-node + fallback). |
| java/src/org/openqa/selenium/netty/server/TcpTunnelHandler.java | Adds byte-forwarding tunnel handler for bridged channels. |
| java/src/org/openqa/selenium/netty/server/SeleniumHttpInitializer.java | Wires the optional tunnel handler into the server pipeline. |
| java/src/org/openqa/selenium/netty/server/NettyServer.java | Adds an overload to pass an optional tunnel resolver into the initializer. |
| java/src/org/openqa/selenium/grid/router/httpd/RouterServer.java | Provides a session-id→node-uri resolver to enable tunneling in the Router. |
| java/src/org/openqa/selenium/grid/TemplateGridServerCommand.java | Plumbs the optional tunnel resolver through the common server command wiring. |
| java/test/org/openqa/selenium/grid/router/TunnelWebsocketTest.java | Adds tests covering tunneling, fallback, HTTPS-node support, and full lifecycle. |
| java/test/org/openqa/selenium/grid/router/BUILD.bazel | Adds test dependencies needed for the new integration test coverage. |
Signed-off-by: Viet Nguyen Duc <[email protected]>
f544c71 to
d383b00
Compare
Signed-off-by: Viet Nguyen Duc <[email protected]>
…17146) * [grid] Router bypass WebSocket data path via transparent TCP tunnel * [grid] Fix WebSocket proxy race on upstream close in ProxyNodeWebsockets --------- Signed-off-by: Viet Nguyen Duc <[email protected]>
The optional transparent TCP tunnel introduced in 9a2df3a (2026-02-27, "[grid] Router bypass WebSocket data path via transparent TCP tunnel", SeleniumHQ#17146) forwarded inbound bytes from one channel to the other with no flow control. A slow drain on one leg let the opposite leg keep shipping bytes, which Netty queued in the outbound buffer indefinitely. A session with sustained bidirectional traffic and a slow client could grow the Router's heap until the per-channel outbound queue was many megabytes. Hook channelWritabilityChanged on each tunnel handler and mirror the state onto the peer's read side via setAutoRead. When this channel's outbound buffer crosses the high-water mark Netty fires the event and the peer stops reading; when the buffer drains below the low-water mark the peer resumes. The two legs of the tunnel each watch their own writability and toggle the other's read side, so backpressure propagates in both directions. The thresholds come from the channel's existing WriteBufferWaterMark (Netty's default of 32 KiB low / 64 KiB high, or whatever an operator configures). No new numeric cap is introduced. A small EmbeddedChannel test drives the writability transitions both ways and asserts the peer's autoRead flag tracks them. The existing end-to-end TunnelWebsocketTest continues to cover the happy path.
The optional transparent TCP tunnel introduced in 9a2df3a (2026-02-27, "[grid] Router bypass WebSocket data path via transparent TCP tunnel", SeleniumHQ#17146) forwarded inbound bytes from one channel to the other with no flow control. A slow drain on one leg let the opposite leg keep shipping bytes, which Netty queued in the outbound buffer indefinitely. A session with sustained bidirectional traffic and a slow client could grow the Router's heap until the per-channel outbound queue was many megabytes. Hook channelWritabilityChanged on each tunnel handler and mirror the state onto the peer's read side via setAutoRead. When this channel's outbound buffer crosses the high-water mark Netty fires the event and the peer stops reading; when the buffer drains below the low-water mark the peer resumes. The two legs of the tunnel each watch their own writability and toggle the other's read side, so backpressure propagates in both directions. The thresholds come from the channel's existing WriteBufferWaterMark (Netty's default of 32 KiB low / 64 KiB high, or whatever an operator configures). No new numeric cap is introduced. A small EmbeddedChannel test drives the writability transitions both ways and asserts the peer's autoRead flag tracks them. The existing end-to-end TunnelWebsocketTest continues to cover the happy path.
…7543) The optional transparent TCP tunnel introduced in 9a2df3a (2026-02-27, "[grid] Router bypass WebSocket data path via transparent TCP tunnel", #17146) forwarded inbound bytes from one channel to the other with no flow control. A slow drain on one leg let the opposite leg keep shipping bytes, which Netty queued in the outbound buffer indefinitely. A session with sustained bidirectional traffic and a slow client could grow the Router's heap until the per-channel outbound queue was many megabytes. Hook channelWritabilityChanged on each tunnel handler and mirror the state onto the peer's read side via setAutoRead. When this channel's outbound buffer crosses the high-water mark Netty fires the event and the peer stops reading; when the buffer drains below the low-water mark the peer resumes. The two legs of the tunnel each watch their own writability and toggle the other's read side, so backpressure propagates in both directions. The thresholds come from the channel's existing WriteBufferWaterMark (Netty's default of 32 KiB low / 64 KiB high, or whatever an operator configures). No new numeric cap is introduced. A small EmbeddedChannel test drives the writability transitions both ways and asserts the peer's autoRead flag tracks them. The existing end-to-end TunnelWebsocketTest continues to cover the happy path.
🔗 Related Issues
The Grid Router was proxying all WebSocket (e.g. BiDi) connections at the application layer: it deserialized every frame through the full Netty HTTP/WS pipeline, passed it through the Selenium handler chain,
and re-serialized it on the way out. This adds CPU overhead and latency on every message for the entire lifetime of a session - a significant cost for real-time bidirectional protocols.
💥 What does this PR do?
Add a transparent TCP tunnel in the Netty pipeline. When the Router receives a WebSocket upgrade for a known session, instead of going through
ProxyWebsocketsIntoGrid, it:TcpTunnelHandler(raw byte forwarder), and strips all HTTP/WS codec handlers from the client pipelineRobustness for complex deployments
Two additional fixes ensure the tunnel degrades gracefully in complex network topologies (Docker, Kubernetes, cloud VPC):
Graceful fallback on connect failure - In the original implementation, if the raw TCP connect to the Node failed (e.g. Node is unreachable due to K8s port-forward topology, split-network/NAT, or a transient failure), the client channel was simply closed. Now the original
HttpRequestis fired back through the pipeline viactx.fireChannelRead(req), allowingProxyWebsocketsIntoGridto attempt the connection via the Grid'sHttpClient.Factory(which respects configured HTTP proxies and other NetworkOptions settings). The default port for https:// Node URIs is also fixed from 80 to 443.TLS support for HTTPS nodes — The original bootstrap used a plain
NioSocketChannelwith no SSL handler, so any Node registered with an https:// URI would fail. Now when the Node URI scheme is https, alazily-initialised client SslContext (built with
InsecureTrustManagerFactory, appropriate for internal Grid traffic where nodes commonly use self-signed certificates) is added as the first handler in thenode-side bootstrap pipeline. The SSL handler is intentionally left in place after the tunnel is established — it transparently handles TLS framing for the raw byte stream.
Fallback behaviour summary
The tunnel is bypassed (falls through to
ProxyWebsocketsIntoGrid) when any of the following are true:🔧 Implementation Notes
💡 Additional Considerations
🔄 Types of changes