Skip to content

fix(gateway): use unpooled connection for WebSocket upstreams#1324

Draft
mikhailm-coder wants to merge 2 commits into
mainfrom
hotfix/gateway-ws-unpooled-upstream
Draft

fix(gateway): use unpooled connection for WebSocket upstreams#1324
mikhailm-coder wants to merge 2 commits into
mainfrom
hotfix/gateway-ws-unpooled-upstream

Conversation

@mikhailm-coder

@mikhailm-coder mikhailm-coder commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Draft — posting for review of the approach + the SO_LINGER question. Not urgent to merge; CI needs to validate the build.

Problem

Agents (MeshCentral meshagent) and NATS clients across multiple tenants are stuck in a WebSocket reconnect loop. Client logs show Connection FAILED: No HTTP response / Network timeout at ~once/minute for hours; REST auth on the same clients succeeds (agent_auth_service: Successfully authenticated using refresh token), so it is WebSocket-specific, not connectivity or credentials.

Gateway logs (12h, tenant tenant-010-15) show the WS upstream bridge failing:

  • 2094× Debug ws upstream connection FAILED url=ws://meshcentral…:8383/…/agent.ashx : reactor.netty.channel.AbortedException: Connection has been closed BEFORE send operation
  • 231× the same to ws://nats…
  • 859× Cannot enable websocket if headers have already been sent

MeshCentral received 4882 upgrades but only 2746 delivered a first frame — connections upgrade then die immediately.

Root cause (confirmed in config)

NettySocketConfig.reactorNettyWebSocketClient() built the WS upstream client with:

HttpClient httpClient = HttpClient.create()          // shared pool, NO idle eviction
        .option(ChannelOption.SO_LINGER, 0)          // abortive close = RST
        .option(ChannelOption.TCP_NODELAY, true);
  • HttpClient.create() uses Reactor Netty's default pool with maxIdleTime / maxLifeTime / evictInBackground all unset → idle connections are never evicted. MeshCentral (agentidletimeout ≈ 150s) and NATS close idle connections server-side, so the pool hands out a socket the upstream already closed → AbortedException: Connection has been closed BEFORE send operation on the first relayed frame.
  • It also bypasses the spring.cloud.gateway.httpclient tuning (that only applies to the SCG-managed client), so the WS path can't be fixed via yaml.
  • SO_LINGER=0 makes every close an abortive RST, which the agent surfaces as No HTTP response (reset, no HTTP reply) rather than a clean close.

REST survives the same pool because WebClient/Reactor Netty retry idempotent requests on a reset; the WS retry (FluxRetryWhen) instead re-runs after the client upgrade has started → Cannot enable websocket if headers have already been sent.

Fix

HttpClient httpClient = HttpClient.create(ConnectionProvider.newConnection())  // fresh connection per handshake
        .option(ChannelOption.TCP_NODELAY, true);

WebSocket upstreams are long-lived and dedicated per session — pooling gives no benefit and is the sole source of the staleness. A fresh connection per handshake eliminates the stale-reuse AbortedException. Dropping SO_LINGER=0 lets the upstream close gracefully (FIN) instead of RST.

Alternative considered

Keep pooling but add a dedicated ConnectionProvider with maxIdleTime (< MeshCentral's ~150s) + evictInBackground. Rejected as more complex for zero pooling benefit on WS — happy to switch if preferred.

Open question for reviewers (the SO_LINGER review)

This PR only removes SO_LINGER=0 from the WS client. The same option is also set on the inbound server child channel (nettyServerCustomizer) and the REST client customizer (gatewayHttpClientCustomizer). Removing it from the inbound server side would make agent-facing closes graceful FINs instead of RSTs — arguably cleaner, but broader blast radius, so I left those untouched here. Let me know if we want a follow-up.

Testing

  • No local JVM/Maven in my env — relying on CI to build.
  • Recommended validation: watch gateway logs for AbortedException: Connection has been closed BEFORE send operation (should drop to ~0) and MeshCentral first-frame / upgrade ratio (should approach 1.0) for tenant tenant-010-15.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved WebSocket connection handling for more reliable, graceful disconnects.
    • Kept low-latency socket settings in place while updating the underlying connection behavior.

The WS upstream client (reactorNettyWebSocketClient) used HttpClient.create()
— Reactor Netty's shared pool with no idle eviction (no maxIdleTime /
evictInBackground / maxLifeTime). MeshCentral (~150s agent idle) and NATS
close idle connections server-side, so the pool hands back a socket the
upstream already closed; the first relayed frame then fails with
"AbortedException: Connection has been closed BEFORE send operation".
Combined with SO_LINGER=0 (abortive RST) this surfaced on agents as
repeated "No HTTP response" and drove a fleet-wide reconnect loop.

WebSocket upstreams are long-lived and dedicated per session, so pooling
gives no benefit and only introduces staleness. Use a fresh connection per
handshake (ConnectionProvider.newConnection()) and drop SO_LINGER=0 so the
upstream closes gracefully.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: dfaf29ac-c713-47bd-8fc2-ca02aa151513

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The NettySocketConfig class was modified to change how the HttpClient for the reactorNettyWebSocketClient bean is constructed, switching from the default factory to ConnectionProvider.newConnection(), while retaining TCP_NODELAY=true socket option.

Changes

WebSocket HttpClient connection provider update

Layer / File(s) Summary
HttpClient construction via ConnectionProvider
openframe-gateway-service-core/src/main/java/com/openframe/gateway/config/NettySocketConfig.java
Imports ConnectionProvider and changes HttpClient creation in reactorNettyWebSocketClient to use ConnectionProvider.newConnection() with an explanatory comment on unpooled/graceful close behavior, replacing the prior default HttpClient.create() with SO_LINGER=0, and retains TCP_NODELAY=true.

Estimated code review effort: 1 (Trivial) | ~5 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: switching WebSocket upstreams to unpooled connections in the gateway.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch hotfix/gateway-ws-unpooled-upstream

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mikhailm-coder

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
openframe-gateway-service-core/src/main/java/com/openframe/gateway/config/NettySocketConfig.java (2)

13-13: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider HttpClient.newConnection() instead of HttpClient.create(ConnectionProvider.newConnection()).

Reactor Netty exposes HttpClient.newConnection() as a direct shorthand for "an unpooled HttpClient", equivalent to HttpClient.create(ConnectionProvider.newConnection()). Using it removes the need for the ConnectionProvider import and is more idiomatic for this use case.

♻️ Proposed simplification
-import reactor.netty.resources.ConnectionProvider;
+// ConnectionProvider import no longer needed
     `@Bean`("reactorNettyWebSocketClient")
     public WebSocketClient reactorNettyWebSocketClient() {
         // Unpooled + graceful close: WS upstreams are long-lived per session; a pooled, non-evicted idle connection can be handed out already closed (AbortedException before send), and SO_LINGER=0 would RST it.
-        HttpClient httpClient = HttpClient.create(ConnectionProvider.newConnection())
+        HttpClient httpClient = HttpClient.newConnection()
                 .option(ChannelOption.TCP_NODELAY, true);
         return new ReactorNettyWebSocketClient(httpClient);
     }

Also applies to: 37-39

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@openframe-gateway-service-core/src/main/java/com/openframe/gateway/config/NettySocketConfig.java`
at line 13, In NettySocketConfig, replace the unpooled client construction in
the HttpClient setup with the direct HttpClient.newConnection() shorthand
instead of using HttpClient.create(ConnectionProvider.newConnection()). Update
the related code path in the configuration to use this idiomatic API and remove
the now-unused ConnectionProvider import.

37-39: 🩺 Stability & Availability | 🔵 Trivial

Note: dropping SO_LINGER=0 trades RST avoidance for potential TIME_WAIT buildup on this client.

The prior SO_LINGER=0 was specifically there to avoid TIME_WAIT accumulation; removing it for graceful close is the right call given the AbortedException/RST issue described, but under high reconnect churn (the exact symptom being fixed) this WS client could now accumulate TIME_WAIT sockets. Worth keeping an eye on ephemeral port/TIME_WAIT metrics post-deploy given the connection volumes cited in the PR (~4882 MeshCentral upgrade attempts/12h).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@openframe-gateway-service-core/src/main/java/com/openframe/gateway/config/NettySocketConfig.java`
around lines 37 - 39, Keep the SO_LINGER=0 removal in
NettySocketConfig.HttpClient setup, but make the TIME_WAIT tradeoff explicit by
adding a brief note near HttpClient.create(ConnectionProvider.newConnection())
and ensuring the WebSocket client has operational visibility for reconnect
churn. If there is an existing metric/health hook in this client path, wire it
to report ephemeral-port/TIME_WAIT pressure so this
ConnectionProvider/TCP_NODELAY configuration can be monitored after deploy.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@openframe-gateway-service-core/src/main/java/com/openframe/gateway/config/NettySocketConfig.java`:
- Around line 37-39: Update the NettySocketConfig documentation to match the
current WebSocket client bean behavior. In the section describing socket tuning
for the three beans, remove or qualify the claim that SO_LINGER=0 is applied
across all of them, and explicitly note that the WebSocket client in
NettySocketConfig omits it intentionally. Keep the wording aligned with the
HttpClient setup so future readers can distinguish the shared options from the
bean-specific exception.

---

Nitpick comments:
In
`@openframe-gateway-service-core/src/main/java/com/openframe/gateway/config/NettySocketConfig.java`:
- Line 13: In NettySocketConfig, replace the unpooled client construction in the
HttpClient setup with the direct HttpClient.newConnection() shorthand instead of
using HttpClient.create(ConnectionProvider.newConnection()). Update the related
code path in the configuration to use this idiomatic API and remove the
now-unused ConnectionProvider import.
- Around line 37-39: Keep the SO_LINGER=0 removal in
NettySocketConfig.HttpClient setup, but make the TIME_WAIT tradeoff explicit by
adding a brief note near HttpClient.create(ConnectionProvider.newConnection())
and ensuring the WebSocket client has operational visibility for reconnect
churn. If there is an existing metric/health hook in this client path, wire it
to report ephemeral-port/TIME_WAIT pressure so this
ConnectionProvider/TCP_NODELAY configuration can be monitored after deploy.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cdb8b034-26f8-4080-a32a-63d352e11f8f

📥 Commits

Reviewing files that changed from the base of the PR and between 5047d0a and e33db7e.

📒 Files selected for processing (1)
  • openframe-gateway-service-core/src/main/java/com/openframe/gateway/config/NettySocketConfig.java

Address CodeRabbit: .NettySocketConfig.md claimed SO_LINGER=0 applies to all
three beans, but the WebSocket client now intentionally omits it and uses an
unpooled connection provider. Scope the socket-option notes accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant