feat(llc)!: bound and authenticate a connection attempt - #160
Conversation
`StreamWebSocketClient` treated opening the socket as the end of connecting: it called `onConnectionEstablished`, discarded whatever that returned, and waited indefinitely for a health check to arrive. Four consequences, all reachable in the guest flow that motivated this. `options` becomes `optionsBuilder`, called once per attempt. The options carry values that change over a client's lifetime — the auth type a connection needs depends on the token it will present, and a client that switches users presents a different one — so a single instance built at construction time describes only the first attempt. `onConnectionEstablished` becomes `onAuthenticate`, which is what it is called for and when: the socket is open, the state is `Authenticating`, and the connection is not usable until credentials have been sent. It is now a `WebSocketAuthenticator` — handed a `WsSender` and returning a `Result` — so a failure to send them is observed rather than dropped. A `void Function()` could not report one, and silently accepted an `async` callback whose future was then discarded. On failure the connection is closed with the new `AuthenticationFailed` source, carrying the cause, instead of being left waiting for a reply that cannot come. The sender exists because the authenticator runs while the connection is still being established, so it cannot be handed the client itself. `WebSocketOptions.connectTimeout` was declared and never read. It now bounds the whole attempt rather than just opening the socket, since an attempt that opens but never receives its first health check is exactly the one that hangs — and nothing else watches `Authenticating`. Abandoning it reports the new `ConnectTimeout` source. The field is no longer nullable: "the platform default" was never consulted, so `null` meant no timeout at all, and it now defaults to `WebSocketOptions.defaultConnectTimeout`. Neither new source enables automatic reconnection. A handshake that never completes and credentials the server rejected both fail the same way on a retry, unlike an unhealthy connection, which was established once and may be again. Fixes a health check arriving while disconnecting being treated as one arriving on a live connection: it set the state back to `Connected`, which replaced the `Disconnecting` source. A deliberate `UserInitiated` disconnect could therefore close as `ServerInitiated` and be automatically reconnected — the opposite of what the caller asked for. Pongs are now ignored once the connection is on its way down. Adds `ConnectUserDetailsRequest.fromUser`, since an authenticator builds its auth frame from the client's `User` and every product was mapping the same four fields by hand. `role` and `teams` are deliberately left out: the server assigns both and ignores them from a client. `name` comes from `originalName`, so a user with no name does not have their id sent as one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## feat/token-manager-user-switching #160 +/- ##
=====================================================================
+ Coverage 60.29% 62.40% +2.11%
=====================================================================
Files 192 192
Lines 7827 7873 +46
=====================================================================
+ Hits 4719 4913 +194
+ Misses 3108 2960 -148 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
renefloor
left a comment
There was a problem hiding this comment.
Reviewed the WS lifecycle changes with the branch checked out; suite is green (387 pass) and dart analyze --fatal-infos is clean. I probed the new lifecycle paths and four of them reproduced — details inline, ordered by how much I'd worry about them.
Worth fixing before merge
- An authenticator that throws (rather than returning a failed
Result) produces an unhandled async error and leaves the connection stuck inAuthenticating. This is the shape almost everyone will write, becauseTokenManager.getToken()throws. See the comment on_authenticate. - The new connect timer can overwrite a
ServerInitiatedsource and flipisAutomaticReconnectionEnabledfromtruetofalse— the same bug class this PR fixes for late pongs, but the guard only went into the pong path. See the comment ondisconnect.
Worth a deliberate decision
- Whether
ConnectTimeoutandAuthenticationFailedshould really block reconnection, givenUnHealthyConnectiondoesn't. See the comment onisAutomaticReconnectionEnabled.
Pre-existing, but this PR makes it sharper
connect() still doesn't guard Disconnecting, and onClose now cancels the connect timer — so a stale close from an old socket can disarm the new attempt's timeout. Comment on connect has the trace.
What I liked
optionsBuilder is the right call and correctly motivated — stream-auth-type depends on the token an attempt will present, so a single instance built at construction can only ever describe the first attempt. Handing the authenticator a WsSender instead of the client is the right boundary, and it fixes a doc example that genuinely never compiled. connectTimeout was dead API and bounding the whole handshake rather than just the socket open is the correct scope, since nothing watched Authenticating. The pong-while-disconnecting fix is a real bug with a regression test that pins the reconnect consequence rather than just the state. And 25 tests on a class that had none — with fake_async for the timers instead of real waits — is the right way to land this.
One small thing not worth its own comment: connect()'s doc still says it "completes when the connection attempt finishes". It resolves once the socket opens — before authentication, well before Connected. Given this PR is precisely about not treating an open socket as a finished attempt, that sentence should probably say so.
| } | ||
|
|
||
| Future<void> _authenticate() async { | ||
| final result = await onAuthenticate?.call(send); |
There was a problem hiding this comment.
An authenticator that throws instead of returning a failed Result isn't handled here, and since _authenticate() is unawaited the error escapes:
Bad state: token load failed
stream_web_socket_client.dart 203 StreamWebSocketClient._authenticate
stream_web_socket_client.dart 199 StreamWebSocketClient.onOpen
state: Authenticating()
Unhandled async error, and the connection sits in Authenticating until the 15s timeout — then reports ConnectTimeout, which carries no error, so the real cause is lost.
This isn't hypothetical. The natural authenticator for the flow this stack exists to serve is:
onAuthenticate: (send) async => send(ConnectRequest(token: await manager.getToken())),and getToken() throws (ClientException) on an unconfigured/reset manager or a failing provider — that's #159's own contract. The typedef asks for a Result, but the one authenticator everybody will write can't honour it without an explicit try/catch.
Could we route a throw to the same place a failed Result goes?
final result = await Result.guard(() => onAuthenticate!.call(send));so the cause lands in AuthenticationFailed(error: ...) instead of being lost to a timeout.
| if (connectionState.value is Disconnected) return; | ||
|
|
||
| // Stop the timeout from firing later and replacing this source. | ||
| _cancelConnectTimeout(); |
There was a problem hiding this comment.
Cancelling the timer here covers the case the test does not replace the source of a disconnect that came first pins — disconnect() ran first, so the timer never fires. But the reverse direction isn't covered, because disconnect() only early-returns on Disconnected, not Disconnecting.
onError (line 239) sets Disconnecting(ServerInitiated) and does not cancel the timer. If onClose doesn't follow promptly:
after onError: Disconnecting(ServerInitiated(...))
after timeout elapsed: Disconnecting(ConnectTimeout())
after onClose: Disconnected(ConnectTimeout()) autoReconnect = false
Without the timer that last state is Disconnected(ServerInitiated) with autoReconnect = **true** (web_socket_connection_state.dart:109-114). So a recoverable socket error becomes a permanent disconnect — which is the same failure this PR fixes for late pongs, just via the timer instead of a pong.
Same shape with lower stakes: if the timeout fires and the authenticator then returns a failure, the source is overwritten (ConnectTimeout → AuthenticationFailed). The engine guards the second close, and both sources are non-reconnectable, so that one is only misreporting:
after timeout: Disconnecting(ConnectTimeout()) engine closes = 1
after late auth failure: Disconnecting(AuthenticationFailed) engine closes = 1
Both fall out of one fix: have disconnect() return early (or at least not replace source) when the state is already Disconnecting. That seems better than adding _cancelConnectTimeout() to each new call site as they appear.
|
|
||
| // Open the connection using the engine. | ||
| // Open the connection using the engine, with options built for this attempt. | ||
| final options = optionsBuilder.call(); |
There was a problem hiding this comment.
Pre-existing, but the new timer gives it a sharper edge: connect() guards Connecting/Authenticating/Connected but not Disconnecting, so it proceeds while an old socket is still closing.
after disconnect: Disconnecting
after connect() during disconnecting: Authenticating <- new socket opened
after the OLD socket's onClose: Disconnected(ServerInitiated)
The stale close kills the new attempt — and because onClose now also calls _cancelConnectTimeout(), it disarms the new attempt's timer. If that socket then opens, we're back in Authenticating with nothing watching it, which is exactly the state the timeout was added for.
Adding Disconnecting to the early-return above would close it. Happy for it to be a follow-up since it predates this PR.
| SystemInitiated() => true, | ||
| UserInitiated() => false, | ||
| ConnectTimeout() => false, | ||
| AuthenticationFailed() => false, |
There was a problem hiding this comment.
I'd like to push back on both of these, and it's the PR description's own reasoning that makes me want to.
ConnectTimeout — UnHealthyConnection (no pong on an established connection) is retryable, but a missing first pong isn't. That's the same failure mode, usually a bad network, at a different moment. It also compounds connectTimeout going from "null = no timeout" to a mandatory 15s: a customer whose backend is slow to send the first health check now gets connections dropped where they previously worked, and not retried. A spurious timeout being permanent is a rough edge.
AuthenticationFailed — the description argues "credentials the server rejected fail the same way on a retry", but this source never means the server rejected anything. It fires when the client couldn't load or send credentials. send() failing because the socket died between onOpen and the send is exactly the transient case. A genuine "the server said no" arrives later, as an error frame.
One line either way, so mostly I'd like it decided deliberately rather than by analogy with UserInitiated.
There was a problem hiding this comment.
Decided as you asked, and I took your reading on one of the two.
ConnectTimeout is now reconnectable (web_socket_connection_state.dart:120). Your argument is the one that settles it: a first health check that never arrives is the same failure as one that stops arriving, and UnHealthyConnection already retries that.
Worth being explicit about what that does to the customer you raised, since it is a three-way change rather than a two-way one. For a backend slow to send the first health check: before this PR the connection hung indefinitely; with the timeout but non-reconnectable it dropped and stayed down; now it drops and reconnects with the recovery handler's backoff. So the flag turns "stays down" into "retries with backoff" rather than back into "connects eventually" — the 15s bound still applies. If that is the wrong trade for a slow backend, the lever is connectTimeout itself rather than the source, and it is per-attempt now.
AuthenticationFailed stays non-reconnectable, with your distinction written into the code as a comment: it is not the server refusing the credentials — that arrives as an error frame — but the client failing to load or send them, and it will fail the same way on a retry. The transient sub-case you named (the socket dying between onOpen and the send) is real, but it is also covered: that path closes the socket, and the resulting closure is reported by the engine rather than by this source. If it turns out to matter in practice, splitting the source is a smaller change than reversing this default.
Also fixed from your other comments: the throwing authenticator now goes through runSafely so the cause lands in AuthenticationFailed instead of escaping (:245), and disconnect early-returns when the connection is already Disconnecting, so the timer can no longer replace a ServerInitiated source — both with regression tests. The connectTimeout behaviour change is now in the changelog and the PR body, and connect's doc no longer claims its future completes when the attempt finishes.
| /// opens but is never established is abandoned once this elapses. | ||
| /// | ||
| /// Defaults to [defaultConnectTimeout]. | ||
| final Duration connectTimeout; |
There was a problem hiding this comment.
Agreed that the old null doc was a lie (nothing consulted a platform default, so null meant no timeout at all), and that a default is better than dead API.
Worth calling out in the changelog as a behaviour change though, not just an API one: every existing connection now gets abandoned after 15s if the first health check hasn't arrived, where before it waited indefinitely. Paired with ConnectTimeout not being reconnectable, a slow-first-pong backend goes from "connects eventually" to "drops and stays down".
| this.custom, | ||
| }); | ||
|
|
||
| factory ConnectUserDetailsRequest.fromUser( |
There was a problem hiding this comment.
Nit: no doc comment on new public API. The class has none either so it's consistent as-is — but the two decisions worth writing down are the ones a caller can't infer: role/teams omitted because the server assigns them, and name coming from originalName so a user with no name doesn't get their id sent as one. That last part is a good catch; every product was getting it wrong by hand.
…lpers
`getOrElse`, `getOrDefault`, `recover` and `recoverCatching` each declared a
type parameter of their own and then cast the success value into it —
`Success<T>(:final data) => data as R`. Nothing constrains `T` to be a subtype
of `R`, so the cast is unsound: with a callback that only throws, `R` infers as
`Never` and a *successful* result fails with a type error on the path that has
nothing wrong with it.
getOrElse THREW on a Success: type '(String, int)' is not a subtype of type 'Never'
That makes the natural way to turn a failure into an exception — the shorthand
`getOrThrow`'s own doc suggests — unusable. Dart cannot express Kotlin's
`T : R` bound, so the type parameter goes and the helpers return `T`. Widening
is still available through `fold`, which takes its return type honestly.
Source-breaking for callers that relied on widening; none exist in this repo or
in `stream-feeds-flutter`. Adds the first tests for `Result`, four of which pin
the success path of each helper against a throwing callback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five things about closing a connection, found while wiring `stream-feeds-flutter` onto this and in review of #160. `disconnect` returned while the socket was still closing, so a `connect` straight afterwards raced it: the engine's `open` closes any existing socket first, both closes ran to completion, and `onClose` fired twice — the second landing on a state of `Connecting` and reporting `ServerInitiated`, which is reconnect-eligible. One `disconnect(); connect();` pair could therefore end up with a spurious reconnect alongside the connection it just opened. The close is now awaited, which costs a socket flush: the returned future resolves when the close frame has been written, not when the peer replies. A failed close left the client reporting `Disconnecting` for good. The engine reports such a failure as a `Result` and skips notifying its listener, so nothing moved the state on. The connection is unusable either way, so it is now reported closed. `disconnect` no longer replaces the source of a closure already under way. `onError` sets `Disconnecting(ServerInitiated)` without cancelling the connect timer, so the timer could overwrite a reconnectable server error with a `ConnectTimeout`; the same shape turned a timeout into a late `AuthenticationFailed`. Whoever asked first describes why. An authenticator that throws now fails the connection instead of escaping. The `WebSocketAuthenticator` typedef asks for a `Result`, but the one authenticator everyone writes awaits a token — and loading one throws. The error escaped unhandled, since nothing observes that future, and the connection sat in `Authenticating` until the timeout reported a cause it does not carry. `ConnectTimeout` is now eligible for automatic reconnection. A first health check that never arrives is the same failure as one that stops arriving, which `UnHealthyConnection` already retries; making it permanent meant a backend slow to send that first check went from connecting eventually to staying down. `AuthenticationFailed` stays ineligible: it means the client could not produce credentials, not that the server refused them, and it will fail the same way on a retry. Adds `dispose`, so the client can be released rather than only closed — `StreamFeedsClient.dispose` had nothing to call, leaving both emitters open for the life of the process. It closes the connection, stops the health monitor and closes `events` and `connectionState`, and is idempotent through `Disposable`. Reporting a state guards on the emitter being closed rather than on disposal, so a close event arriving from the engine afterwards is ignored instead of thrown into a closed emitter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ConnectUserDetailsRequest.fromUser` shipped in #160 without a dartdoc, against the style guide's own rule for new public code. The two things a caller cannot infer are why `role` and `teams` are absent — the server assigns both and ignores them from a client — and that `includeDetails: false` sends the id alone. Also corrects `connect`'s dartdoc, which claimed its future completes when the connection attempt finishes. It resolves once the socket is open, before authentication and well before the connection is usable — which is precisely what the connect timeout exists to bound. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kotlin's `getOrElse`, `getOrDefault`, `recover` and `recoverCatching` widen through a second type parameter bounded by the receiver's — `<R, T : R>` — which is what makes their `value as T` sound. Dart has upper bounds only, so the bound cannot be stated and the previous `<R>` was a cast with nothing behind it. The capability is still reachable, just declared in a different place: `Result` is covariant, so naming the wider type on the result gives the same widening that Kotlin infers from the callback. ```dart final Result<num> widened = intResult; widened.getOrElse((_, _) => 0.5); ``` Documents that on both `get` helpers and pins it with a test, so the migration note is not the only record of it. Worth noting for the reviewer: Kotlin's `recover` also returns the receiver unchanged on success (`null -> this`) rather than rebuilding it, and its non-widening members — `getOrNull`, `getOrThrow` — take no type parameter either, which is the shape these four now have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`getOrElse` and `getOrDefault` return the receiver's type, so widening the result before or after the call reads the same. `recover` returns a `Result`, so the order matters: widening afterwards gives a `Result<T>` there is nothing left to widen. Kotlin's returns `Result<R>` and infers it from the transform; ours takes it from the receiver, so the receiver has to be widened first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ycle Keeps this stacked branch current with the #159 review fixes. # Conflicts: # packages/stream_core/CHANGELOG.md
Submit a pull request
Linear: FLU-
Github Issue: #
CLA
Description of the pull request
StreamWebSocketClienttreated opening the socket as the end of connecting: it calledonConnectionEstablished, discarded whatever that returned, and then waited indefinitely for a health check to arrive. Four consequences, all reachable in the guest flow that motivated this.options→optionsBuilderCalled once per attempt. The options carry values that change over a client's lifetime — the
stream-auth-typea connection needs depends on the token it will present, and a client that switches users presents a different one — so a single instance built at construction time describes only the first attempt.onConnectionEstablished→onAuthenticateRenamed for what it is called for and when: the socket is open, the state is
Authenticating, and the connection is not usable until credentials have been sent.The signature change is the substantive part. As a
void Function()it could not report a failure to send those credentials, and it silently accepted anasynccallback whose future was then discarded — so a token that failed to load left the connection sitting inAuthenticatinguntil something else closed it. It now returns aResult, and on failure the connection is closed with the newAuthenticationFailedsource carrying the cause.It is handed a
WsSenderrather than the client because it runs while the connection is being established — the client cannot hand out an interface that implies the connection is usable. It also fixes the doc example, which never compiled:onConnectionEstablished: () { client.send(...) }isreferenced_before_declaration.connectTimeoutwas dead APIDeclared on
WebSocketOptionsand never read. It now bounds the whole attempt rather than just opening the socket, because the attempt that hangs is precisely the one that opens and never receives its first health check — and nothing else watchesAuthenticating. Abandoning it reports the newConnectTimeoutsource.The field is no longer nullable. Its doc claimed
nullmeant "the platform default", which was never consulted, sonullmeant no timeout at all; it now defaults toWebSocketOptions.defaultConnectTimeout(15s, matching the other Stream SDKs).Neither new source enables automatic reconnection. A handshake that never completes and credentials the server rejected both fail the same way on a retry — unlike
UnHealthyConnection, which was established once and may be again.A health check arriving while disconnecting
Pre-existing, and the one bug fix here rather than API work. A pong was handled the same whether the connection was live or already on its way down, so it set the state back to
Connected— which replaced theDisconnectingsource. A deliberateUserInitiateddisconnect could therefore close asServerInitiatedand be automatically reconnected, the opposite of what the caller asked for. Pongs are now ignored once the state isDisconnectingorDisconnected.ConnectUserDetailsRequest.fromUserHere because an authenticator builds its auth frame from the client's
User, and every product was mapping the same four fields by hand.roleandteamsare deliberately left out — the server assigns both and ignores them from a client.namecomes fromoriginalName, so a user with no name does not have their id sent as one, which the hand-rolled mappings got wrong.Migration
Breaking, and
stream-video-flutterhas two call sites that will need updating when it bumps — it pinsstream_core: ^0.4.0, so nothing there breaks today:coordinator_ws.dart:37—options:→optionsBuilder:coordinator_ws.dart:41—onConnectionEstablished: _authenticateUser→onAuthenticate:, and_authenticateUser(:115) has to change shape fromFuture<void> Function()toFuture<Result<void>> Function(WsSender)coordinator_ws.dart:116— reads_client.options.urlfor a log line; theoptionsfield is gonesfu_ws.dart:67—options:→optionsBuilder:sfu_ws.dart:85—String get url => _client.options.url;is a public getter onSfuWs, so this one surfaces in video's own APIstream-feeds-flutterpins core to a git ref and its branch already uses the new API — it moves when the ref moves.Behaviour change, not just API
connectTimeoutwas declared and never read, so every connection previously waited indefinitely for its first health check. It is now abandoned after 15s. Video's two clients pass no timeout today and so inherit that default. Paired withConnectTimeoutnot being reconnectable, a backend slow to send the first health check goes from "connects eventually" to "drops and stays down" — see the open review thread on whether that source should block reconnection.Test plan
dart testinpackages/stream_core— 387 pass, up from 362 on the base branchdart analyze— clean25 new tests.
StreamWebSocketClienthad no test file at all before this, so everything below is new coverage rather than adjusted coverage:optionsBuilder— called for every attempt, not once per clientonAuthenticate— called once the socket is open whileAuthenticating, once per attempt, handed a sender that reaches the socket, and leaves the connectionAuthenticatingon success and when there is no authenticatorfake_async) — abandons an attempt that never becomes connected, abandons one whose authenticator never returns, is armed again for a later attempt, honours a timeout given in the options, does not fire once established, and does not replace the source of a disconnect that came firstisAutomaticReconnectionEnabled(3) — disabled forConnectTimeoutandAuthenticationFailed, still enabled forUnHealthyConnectioncloseReasonuniqueness across all six sources,defaultConnectTimeoutbeing what the options fall back to, and 4 forConnectUserDetailsRequest.fromUserAdds
fake_asyncas a dev dependency, used for the timeout tests so a 15s timer does not cost 15s of wall clock.Screenshots / Videos
n/a — no UI changes.
🤖 Generated with Claude Code