Repository navigation
Conversation
`WebSocketClient.connect` could leave its caller waiting forever, with no error, in four ways: - A server that accepts the connection and never answers the upgrade request left the connect pending: nothing bounds the TLS handshake or the HTTP upgrade, only the TCP connect. - A server that closes cleanly before answering (an end of stream on `ws`, a close_notify on `wss`) raised no error, so nothing failed the upgrade. - The connect succeeded as soon as the 101 arrived, before `onUpgrade` ran; a connection lost in between left the caller waiting for an `onUpgrade` that never came. - A proxy refusing the CONNECT of a `wss` connection closed the connection without failing the connect. `Configuration.upgradeTimeout` (default `nil`, today's behaviour) bounds the TCP connect, TLS handshake, proxy CONNECT and upgrade together: when it runs out the connect fails with `ChannelError.connectTimeout` and the connection is closed, even one made after the timeout. Once the server has accepted the upgrade the timeout no longer applies. The connection that was made fails the upgrade with `ChannelError.eof` if it ends before the upgrade completes (only that one: the attempts a Happy Eyeballs connect races against it close too), a refused CONNECT fails it with the proxy's error, and the upgrade completes once `onUpgrade` has run. The connection, the future `connect` returns and its timeout share one event loop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background
We keep a WebSocket from a fleet of edge devices to a cloud service with
WebSocketClient, and reconnect whenever it drops. Aconnectthat never completes parks that loop for good: there is no error to back off from and no retry, until the process restarts. Todayconnectcan do that in four ways:ws, or a TLS close_notify onwss, raises no error.HTTPUpgradeRequestHandlerhas nochannelInactive, so nothing fails the upgrade promise.onUpgrade. The future succeeds in the upgrade's completion handler, which NIO calls before the upgrader adds the WebSocket handlers. A caller that waits foronUpgradeto get itsWebSocketthen waits forever.wssconnection. The refusal closes the channel, but the connect has already succeeded and nothing fails the upgrade promise.Changes
WebSocketClient.Configuration.upgradeTimeout: TimeAmount?,nilby default (today's behaviour).connectfails withChannelError.connectTimeoutand the connection is closed, even one made after the timeout.ChannelError.eof. This applies to that connection only: NIO closes the attempts a Happy Eyeballs connect raced against it, and those must not fail anything.NIOHTTP1ProxyConnectHandler.Error.proxyAuthenticationRequired.onUpgradehas run, not when the 101 arrives.EventLoopGroupshuts down.connectandonUpgradenow say when the future completes and how it fails.No public API changes apart from the added configuration property (semver-minor).
Tests
Eight new tests in
WebSocketKitTestsrun against a raw NIO server that misbehaves on purpose. Five of them fail onmaintoday:maintestUpgradeTimeoutClosesAStalledHandshaketestServerClosingBeforeTheUpgradeFailsTheConnectconnectnever completes (the test gives up after 10 s)testServerClosingTLSCleanlyBeforeTheUpgradeFailsTheConnectconnectnever completestestProxyRefusingTheTunnelFailsTheConnectconnectnever completestestConnectCompletesOnlyOnceOnUpgradeHasRuntestUpgradeTimeoutLeavesAnUpgradedConnectionAlonetestServerRefusingTheUpgradeFailsWithItsStatustestConnectCompletesOnTheWebSocketsEventLoopFor the
maincolumn, the test file was compiled againstmainwith anupgradeTimeoutproperty that nothing reads.SWIFTNIO_STRICT=1. On Linux (swift:6.3.2-noble) every test passes when run on its own.--sanitize=thread, TSan reports one Swift access race, in NIO'sNIOWebSocketClientUpgrader.randomRequestKey(), during the existingtestAlternateWebsocketConnectMethods. It is a false positive: the generator is a zero-sized local, and TSan sees every thread's copy at one static address.mainreproduces it with nothing but a few threads callingrandomRequestKey().🤖 Generated with Claude Code