Skip to content

Ember Web Socket Client (continued) - #7883

Open
dabrowski-adam wants to merge 127 commits into
http4s:series/0.23from
dabrowski-adam:ember-wsclient-fix
Open

dabrowski-adam wants to merge 127 commits into
http4s:series/0.23from
dabrowski-adam:ember-wsclient-fix

Conversation

@dabrowski-adam

@dabrowski-adam dabrowski-adam commented Jun 30, 2026

Copy link
Copy Markdown

This PR expands upon #7261. It contains the changes I made to make the tests pass on joan38/kubernetes-client#239 and also some improvements I didn't think of before that Claude Fable pointed out (which I have verified).

Closes #5227

@dabrowski-adam
dabrowski-adam force-pushed the ember-wsclient-fix branch 3 times, most recently from 10026ff to 71d6126 Compare July 1, 2026 06:33
When opening a WebSocket connection, the HTTP client parses the 101
Switching Protocols response off the socket. A 101 is treated as having
no body, so any bytes the parser read past the response header terminator
are returned via its drain. Those trailing bytes are the beginning of the
WebSocket stream, which the server may coalesce into the same TCP segment
as the handshake response.

EmberClientBuilder stored only the raw socket in the WebSocket response
attribute and discarded the drain, so the WebSocket read loop read from
socket.reads directly and lost any frames the server sent immediately
after the handshake. This caused receive/receiveStream to hang until the
test timeout, deterministically on Scala.js and intermittently on the JVM.

Capture the parser's leftover bytes for the upgrade and replay them ahead
of socket.reads in the read loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dabrowski-adam and others added 11 commits July 1, 2026 21:59
A 101 Switching Protocols hijacks the raw socket for the WebSocket, but
EmberClientBuilder still ran postProcessResponse in the response
finalizer. At teardown that returned the connection to the HTTP pool
marked reusable and seeded with leftover WebSocket bytes in nextBytes,
started a pre-emptive read racing the WebSocket reader, and
double-evaluated the parser drain.

Skip post-processing for a successful upgrade so the connection stays
DontReuse (the pool closes it) and the parser's drain seeds only the
WebSocket stream, never nextBytes for a reused HTTP connection.

Add a regression test that coalesces the first frame with the 101
response in a single write, exercising the leftover-replay path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
WSClientHighLevel.connectHighLevel documents that pongs are replied
automatically, and RFC 6455 §5.5.2 requires an endpoint to answer a Ping
with a Pong. The receiving endpoint may be the client, since a server is
allowed to ping. EmberWSClient passed respondToPings = false with no
lower layer that pongs, so high-level connections silently dropped
server pings. Pass respondToPings = true so the documented behavior
holds. The low-level connect surface is unaffected and still leaves ping
handling to the caller.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
WSFrame extends Product with Serializable, so the least upper bound of
its case classes is WSFrame itself and munit's assertEquals accepts the
Some(...) and List(...) literals without the : WSFrame / : WSDataFrame
ascriptions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the hand-written `implicit evidence$1: Foldable[G]` parameter
with a `G[_]: Foldable` context bound and eta-expand `send`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sequence the four handshake checks with *> (short-circuiting on the
first Left, as mapN did) instead of a tuple mapN with a throwaway
destructure. The checks are annotated with their common
Either[ServerHandshakeError, _] type so the chain unifies the error type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Map over the single effect (clientHandshake) instead of lifting the pure
serverHandshake result into F with pure[F] inside a for-comprehension,
and use a MonadThrow context bound.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
toWebSocketFrame only needs pure and fromEither, so drop the
over-constrained Concurrent bound in favor of MonadThrow and use a
single F summoner instead of alternating Applicative/MonadThrow. Removes
the now-unused Applicative and Concurrent imports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace `if (x.isDefined) F.unit else offer` with `offer.whenA(closed.isEmpty)`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Destructure the makeCaseFull result into (response, drain) instead of
referring to it positionally as responseResource._1 / ._2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the four match blocks with Either.cond for the boolean guards and
Option#toRight for the Sec-WebSocket-Accept extraction. The leftmost check
carries the ServerHandshakeError type so the *> chain unifies; the middle
checks widen into it via covariance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@rossabaker rossabaker left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the submission, but I have major concerns.

  1. We can't accept commits that list AI tools (such as Claude) as a co-author, as they can't consent to the license. Please rebase or amend your commits to remove the Co-authored-by: trailer and update the PR.

  2. More importantly, this is a sophisticated new feature, and the maintainers are stretched thin. I spent my holiday weekend dealing with security issues, primarily in Ember and web sockets. This is right at the intersection, and history tells me it will be be laborious to harden and maintain. Who volunteers to keep this running over the long haul if we add it?

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ember Client WebSocket support

5 participants