From 56bf6dc92a8512bb435c9b941fc5776bae7eb597 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 11:12:32 +0200 Subject: [PATCH 01/97] feat(llc)!: let a TokenManager switch users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TokenManager` could only ever serve the user it was constructed with: `userId` was final and the `tokenProvider` setter could not assign, because the field was final too. A flow whose user is only known after an authenticated request — a guest, whose id and token are both issued in exchange for an anonymous one — had no way to adopt the result. - Add `setTokenProvider(userId, tokenProvider:)`, which changes the user and the provider together so the manager can never report one user while holding another's token, and expires the cached token. - Remove the `tokenProvider` setter, superseded by the above. - Discard a token that finishes loading after the manager was pointed at another user, so it cannot be cached for the wrong one. Alongside that, three defects in the same area: - `getToken()` consulted its cache only when a concurrent caller had populated it while waiting for the lock, so a sequential call always reloaded — a dynamic provider was invoked on every request. - `AuthInterceptor` read `user_id` from the manager after awaiting the token, so the two could describe different users. It now takes both from the loaded token. - `DynamicTokenProvider` validated only the token type, so a loader returning someone else's token authenticated every later request as that user. It now checks the `user_id` claim, as the static provider already did. And `UserToken.anonymous` no longer takes a `userId`: anonymous tokens always use `UserToken.anonymousUserId`, any other id was ignored, and `rawValue` is now rejected unless its `user_id` claim matches. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 28 +++-- .../lib/src/user/token_manager.dart | 83 ++++++++++---- .../lib/src/user/token_provider.dart | 44 +++++--- .../stream_core/lib/src/user/user_token.dart | 37 +++++-- .../interceptors/auth_interceptor_test.dart | 103 +++++++++++++----- .../test/user/token_manager_test.dart | 101 +++++++++++++---- .../test/user/token_provider_test.dart | 49 ++++++++- 7 files changed, 336 insertions(+), 109 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 93f58768..2b739d60 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -1,24 +1,28 @@ ## Upcoming +### 💥 BREAKING CHANGES + +- Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `UserToken.anonymousUserId` +- Removed the `TokenManager.tokenProvider` setter, use `setTokenProvider` instead + ### ✨ Features -- Added `AuthInterceptor.withProvider`, which takes a `TokenManager Function()` getter instead of a fixed `TokenManager` instance. This lets callers swap the active `TokenManager` at runtime — e.g. after a guest token exchange resolves a server-assigned user id — and have the interceptor pick up the new instance (and its `userId`) on the next request. The existing `AuthInterceptor(dio, tokenManager)` constructor is unchanged. -- Added `teams` field to `User` class. -- Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful - token load. -- Added optional `rawValue` parameter to `UserToken.anonymous` so anonymous tokens can carry - a JWT (e.g. call-restricted tokens for closed livestreams). +- Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token +- Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load +- Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access +- Added `UserToken.anonymousUserId`, the user id used for anonymous authentication +- Added `AuthInterceptor.withProvider`, which takes a `TokenManager Function()` getter instead of a fixed instance +- Added `teams` field to `User` class -### 🐞 Fixed +### 🐛 Bug Fixes -- `TokenManager.getToken()` now returns the cached token instead of contacting the - `TokenProvider` on every call. -- The `TokenManager.tokenProvider` setter now stores the new provider, previously it only - expired the cached token. +- Fixed `TokenManager.getToken()` contacting the `TokenProvider` on every call instead of returning the cached token +- Fixed `AuthInterceptor` sending a `user_id` that could disagree with the token in the `Authorization` header +- Fixed `DynamicTokenProvider` accepting a token issued for a different user than the one requested ### 🔄 Changed -- Raised the minimum Dart SDK to `^3.12.0`. +- Raised the minimum Dart SDK to `^3.12.0` ## 0.4.0 diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index 4fbb5a82..92c7f8c7 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -6,7 +6,8 @@ import 'user_token.dart'; /// A callback invoked whenever the manager caches a newly loaded token. /// /// Invoked synchronously after the token is cached, before it is returned to -/// the caller that triggered the load. The manager does not await the result. +/// the caller that triggered the load. Throwing from it surfaces to that +/// caller, even though the token was loaded and cached successfully. typedef OnTokenUpdated = void Function(UserToken token); /// Manages user authentication tokens with caching and thread-safe access. @@ -32,36 +33,66 @@ typedef OnTokenUpdated = void Function(UserToken token); /// manager.expireToken(); /// ``` class TokenManager { - /// Creates a [TokenManager] for the specified [userId] with the given [_tokenProvider]. + /// Creates a [TokenManager] for the specified `userId` with the given + /// `tokenProvider`. /// - /// The [userId] identifies the user for whom tokens will be managed. - /// The [_tokenProvider] is used to load tokens when needed. + /// The `userId` identifies the user for whom tokens will be managed. + /// The `tokenProvider` is used to load tokens when needed. /// - /// An optional [onTokenUpdated] callback is invoked after every successful + /// An optional `onTokenUpdated` callback is invoked after every successful /// token load. It is not invoked for callers served from the cache. TokenManager({ - required this.userId, + required this._userId, required this._tokenProvider, - this.onTokenUpdated, + this._onTokenUpdated, }); /// The unique identifier of the user whose tokens are managed. - final String userId; - - /// Invoked after every successful token load. - final OnTokenUpdated? onTokenUpdated; + /// + /// Changes when the manager is pointed at another user with + /// [setTokenProvider]. + String get userId => _userId; + String _userId; // The provider used to load tokens when needed. TokenProvider _tokenProvider; - /// Replaces the provider used to load tokens. + // Invoked after every successful token load. + final OnTokenUpdated? _onTokenUpdated; + + /// Points this manager at `userId`, loading its tokens from `tokenProvider`. + /// + /// The user and the provider change together, so the manager can never report + /// one user while holding another's token. Expires the cached token, so the + /// next [getToken] call loads a fresh one for the new user. + /// + /// Use this to reuse a manager across users, and when a user's identity is + /// only known after an authenticated request — a guest, whose id and token + /// are both issued in exchange for an anonymous one: /// - /// Expires the cached token when the provider changes, so the next - /// [getToken] call loads a fresh token from the new provider. - set tokenProvider(TokenProvider provider) { - if (_tokenProvider == provider) return; - _tokenProvider = provider; - expireToken(); + /// ```dart + /// // Authenticate anonymously while the real identity is being obtained. + /// final manager = TokenManager( + /// userId: UserToken.anonymousUserId, + /// tokenProvider: TokenProvider.static(UserToken.anonymous()), + /// ); + /// + /// // Adopt the identity once it is known. + /// manager.setTokenProvider( + /// userId, + /// tokenProvider: TokenProvider.static(UserToken(rawToken)), + /// ); + /// ``` + void setTokenProvider( + String userId, { + required TokenProvider tokenProvider, + }) { + _userId = userId; + _tokenProvider = tokenProvider; + + // The cached token belongs to the previous user and provider, so drop it + // and let the next `getToken` call load a fresh one. + return expireToken(); } // The currently cached token, if any. @@ -99,12 +130,20 @@ class TokenManager { }); } - // Loads a token from the provider, caches it, and notifies the - // [onTokenUpdated] callback. + // Loads a token from the provider and, unless the manager has since been + // pointed at another user, caches it and notifies `onTokenUpdated`. Future _loadAndNotify() async { - final updatedToken = await _tokenProvider.loadToken(userId); + final loadingFor = _userId; + final updatedToken = await _tokenProvider.loadToken(loadingFor); + + // Only cache the token if the manager still points at the user it was + // loaded for; `setTokenProvider` may have run during the load, and the + // token belongs to whoever we were before. + if (loadingFor != _userId) return updatedToken; + _cachedToken = updatedToken; - onTokenUpdated?.call(updatedToken); + _onTokenUpdated?.call(updatedToken); + return updatedToken; } diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index 1f6ddb62..bec7813c 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -42,8 +42,8 @@ abstract interface class TokenProvider { /// Returns a [Future] that resolves to a [UserToken] configured for either /// JWT authentication or anonymous access, depending on the provider type. /// - /// Throws an [ArgumentError] if the loaded token is not valid (for JWT providers) - /// or if the 'user_id' claim is missing or empty (for JWT tokens). + /// Throws an [ArgumentError] if the token does not belong to [userId], or if + /// it is not valid for the provider's authentication type. Future loadToken(String userId); } @@ -56,7 +56,7 @@ abstract interface class TokenProvider { /// Useful for scenarios where tokens don't expire, long-lived tokens, /// or for testing purposes. class StaticTokenProvider implements TokenProvider { - /// Creates a static token provider with the given [_rawToken]. + /// Creates a static token provider with the given `token`. const StaticTokenProvider(this._rawToken); // The pre-configured token. @@ -73,11 +73,13 @@ class StaticTokenProvider implements TokenProvider { @override Future loadToken(String userId) async { // Validate that the token's user_id matches the requested userId - if (_rawToken.userId == userId) return _rawToken; + if (_rawToken.userId != userId) { + throw ArgumentError( + 'User ID mismatch: expected "${_rawToken.userId}", got "$userId"', + ); + } - throw ArgumentError( - 'User ID mismatch: expected "${_rawToken.userId}", got "$userId"', - ); + return _rawToken; } } @@ -87,7 +89,7 @@ class StaticTokenProvider implements TokenProvider { /// for users when needed. The loader function is called with the user ID /// and must return a fresh JWT token, typically used for token refresh scenarios. class DynamicTokenProvider implements TokenProvider { - /// Creates a dynamic token provider with the given [_loader] function. + /// Creates a dynamic token provider with the given `loader` function. const DynamicTokenProvider(this._loader); // The function used to load tokens for users. @@ -95,21 +97,31 @@ class DynamicTokenProvider implements TokenProvider { /// Loads a fresh JWT token for the specified [userId] using the configured loader. /// - /// Calls the [_loader] function with the [userId] to fetch a fresh JWT token - /// and returns the [UserToken] instance from the result. + /// Calls the loader with [userId] to fetch a fresh JWT token and returns the + /// [UserToken] instance from the result. /// /// Returns a [Future] that resolves to a [UserToken] configured for JWT authentication. /// - /// Throws an [ArgumentError] if the token returned by the loader is not a JWT token - /// or if the 'user_id' claim is missing or empty. + /// Throws an [ArgumentError] if the token returned by the loader is not a JWT + /// token, or if its 'user_id' claim is not [userId]. @override Future loadToken(String userId) async { final token = await _loader.call(userId); + + // Validate that the token's user_id matches the requested userId + if (token.userId != userId) { + throw ArgumentError( + 'User ID mismatch: expected "$userId", got "${token.userId}"', + ); + } + // Validate that the returned token is a JWT token - if (token.authType == AuthType.jwt) return token; + if (token.authType != AuthType.jwt) { + throw ArgumentError( + 'Token type mismatch: expected jwt, got ${token.authType.headerValue}', + ); + } - throw ArgumentError( - 'Token type mismatch: expected jwt, got ${token.authType.headerValue}', - ); + return token; } } diff --git a/packages/stream_core/lib/src/user/user_token.dart b/packages/stream_core/lib/src/user/user_token.dart index 1dfca446..0e0e0961 100644 --- a/packages/stream_core/lib/src/user/user_token.dart +++ b/packages/stream_core/lib/src/user/user_token.dart @@ -25,7 +25,7 @@ typedef UserTokenLoader = Future Function(String userId); /// /// Create an anonymous token: /// ```dart -/// final token = UserToken.anonymous(userId: 'guest-123'); +/// final token = UserToken.anonymous(); /// print(token.authType); // AuthType.anonymous /// ``` class UserToken extends Equatable { @@ -58,19 +58,33 @@ class UserToken extends Equatable { /// Creates an anonymous user token. /// - /// Creates a token for anonymous authentication with the specified [userId]. - /// When [userId] is not provided, defaults to '!anon' for anonymous users. + /// Anonymous tokens always use [anonymousUserId] as their user id. /// /// An optional [rawValue] can carry a JWT that is sent along with anonymous - /// requests, e.g. a call-restricted token granting an anonymous user access - /// to specific resources (such as a closed livestream). When omitted, the - /// token carries no raw value and requests are sent without credentials. + /// requests, granting the caller access to the specific resources its claims + /// name. When omitted, the token carries no raw value and requests are sent + /// without credentials. /// /// Returns a [UserToken] configured for anonymous access. - factory UserToken.anonymous({String? userId, String rawValue = ''}) { + /// + /// Throws an [ArgumentError] if [rawValue] is given and is not a valid JWT, + /// or if its 'user_id' claim is not [anonymousUserId]. + factory UserToken.anonymous({String rawValue = ''}) { + if (rawValue.isNotEmpty) { + final jwtBody = JsonWebToken.unverified(rawValue); + final claim = jwtBody.claims.getTyped('user_id'); + if (claim != anonymousUserId) { + throw ArgumentError.value( + rawValue, + 'rawValue', + 'Invalid anonymous JWT token: user_id claim must be "$anonymousUserId", got "$claim"', + ); + } + } + return UserToken._( rawValue: rawValue, - userId: userId ?? '!anon', + userId: anonymousUserId, authType: AuthType.anonymous, ); } @@ -81,16 +95,19 @@ class UserToken extends Equatable { required this.authType, }); + /// The user id used for anonymous authentication. + static const anonymousUserId = '!anon'; + /// The raw token value. /// /// For JWT tokens, contains the complete JWT string. For anonymous tokens, - /// this field is empty as no token value is required. + /// it is empty unless one was supplied to grant restricted access. final String rawValue; /// The unique identifier of the user. /// /// For JWT tokens, this value is extracted from the 'user_id' claim. - /// For anonymous tokens, this can be a custom identifier or defaults to '!anon'. + /// For anonymous tokens, it is always [anonymousUserId]. final String userId; /// The authentication type of this token. diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index 37708067..2c0ed9e0 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -115,43 +115,42 @@ void main() { ); test( - 'picks up a TokenManager swapped in while the token is loading, so the ' - 'user_id query parameter reflects a server-resolved id (guest exchange)', + 'sends the user id a guest exchange returned, once the token manager is ' + 'pointed at it', () async { - // Simulates the guest flow: the token provider resolves to a - // server-assigned id and swaps in a new TokenManager carrying that id - // before the request headers are written. The interceptor reads the - // manager through the getter, so it observes the swapped instance. - late TokenManager tokenManager; - tokenManager = TokenManager( - userId: 'requested-id', - tokenProvider: TokenProvider.dynamic((_) async { - final token = _generateTestUserToken('server-assigned-id'); - tokenManager = TokenManager( - userId: token.userId, - tokenProvider: TokenProvider.static(token), - ); - return token; - }), + // Simulates the guest flow: the exchange is authenticated anonymously, + // then the manager is pointed at the id the exchange returned. The user + // id and the token change together, so the `user_id` query parameter + // always describes the token in the `Authorization` header. + const serverId = 'server-assigned-id'; + + final tokenManager = TokenManager( + userId: UserToken.anonymousUserId, + tokenProvider: TokenProvider.static(UserToken.anonymous()), ); final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); final adapter = _CapturingHttpClientAdapter(); dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor.withProvider(dio, tokenManagerProvider: () => tokenManager)); + dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + + tokenManager.setTokenProvider( + serverId, + tokenProvider: TokenProvider.static(_generateTestUserToken(serverId)), + ); await dio.get('/test'); + expect(adapter.lastRequest?.queryParameters['user_id'], serverId); expect( - adapter.lastRequest?.queryParameters['user_id'], - 'server-assigned-id', + adapter.lastRequest?.headers['stream-auth-type'], + AuthType.jwt.headerValue, ); }, ); test( - 'uses the current TokenManager userId when nothing swaps it ' - '(regular/anonymous users)', + 'uses the current TokenManager userId when nothing swaps it', () async { final tokenManager = TokenManager( userId: 'user-123', @@ -171,6 +170,60 @@ void main() { }, ); + test( + 'sends an anonymous token as an empty Authorization header with the ' + 'anonymous auth type', + () async { + final tokenManager = TokenManager( + userId: UserToken.anonymousUserId, + tokenProvider: TokenProvider.static(UserToken.anonymous()), + ); + + final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); + final adapter = _CapturingHttpClientAdapter(); + dio.httpClientAdapter = adapter; + dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + + await dio.get('/test'); + + expect(adapter.lastRequest?.headers['Authorization'], isEmpty); + expect( + adapter.lastRequest?.headers['stream-auth-type'], + AuthType.anonymous.headerValue, + ); + expect( + adapter.lastRequest?.queryParameters['user_id'], + UserToken.anonymousUserId, + ); + }, + ); + + test( + 'sends a restricted anonymous token as the Authorization header', + () async { + final restricted = _generateTestUserToken(UserToken.anonymousUserId); + final tokenManager = TokenManager( + userId: UserToken.anonymousUserId, + tokenProvider: TokenProvider.static( + UserToken.anonymous(rawValue: restricted.rawValue), + ), + ); + + final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); + final adapter = _CapturingHttpClientAdapter(); + dio.httpClientAdapter = adapter; + dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + + await dio.get('/test'); + + expect(adapter.lastRequest?.headers['Authorization'], restricted.rawValue); + expect( + adapter.lastRequest?.headers['stream-auth-type'], + AuthType.anonymous.headerValue, + ); + }, + ); + test( 'does not retry a token-expired response when using a static provider ' '(e.g. a guest token): the error is surfaced to the caller instead of ' @@ -203,9 +256,9 @@ void main() { '(guest exchange resolving mid-flight)', () async { // Starts on a dynamic manager and swaps to a static one carrying the - // server-resolved id once the request is already in flight, mirroring - // the guest flow. onError observes the swapped-in (static) manager and - // must forward the error rather than expire + retry. + // exchanged id once the request is already in flight, mirroring the + // guest flow. onError observes the swapped-in (static) manager and must + // forward the error rather than expire + retry. var tokenManager = TokenManager( userId: 'requested-id', tokenProvider: TokenProvider.dynamic( diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 0d3272a9..89d461e1 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; @@ -19,7 +20,15 @@ class _CountingProvider implements TokenProvider { } } -UserToken _token(String value) => UserToken.anonymous(userId: value); +/// Builds a JWT [UserToken] with the given [userId] claim, sufficient for +/// [UserToken]'s unverified parsing. +UserToken _token(String userId) { + String encode(Map json) => base64Url.encode(utf8.encode(jsonEncode(json))).replaceAll('=', ''); + final header = encode({'alg': 'HS256', 'typ': 'JWT'}); + final payload = encode({'user_id': userId}); + final signature = encode({'sig': 'fake'}); + return UserToken('$header.$payload.$signature'); +} void main() { group('TokenManager', () { @@ -112,39 +121,90 @@ void main() { }); }); - group('tokenProvider setter', () { - test('swaps the provider and expires the cached token', () async { - final oldProvider = _CountingProvider((_) async => _token('old')); - final newProvider = _CountingProvider((_) async => _token('new')); + group('setTokenProvider', () { + test('points the manager at another user', () async { final manager = TokenManager( userId: 'user-1', - tokenProvider: oldProvider, + tokenProvider: TokenProvider.static(_token('user-1')), ); - expect(await manager.getToken(), _token('old')); + expect((await manager.getToken()).userId, 'user-1'); - manager.tokenProvider = newProvider; + manager.setTokenProvider( + 'user-2', + tokenProvider: TokenProvider.static(_token('user-2')), + ); - expect(manager.peekToken(), isNull); - expect(await manager.getToken(), _token('new')); - expect(oldProvider.loadCount, 1); - expect(newProvider.loadCount, 1); + expect(manager.userId, 'user-2'); + expect((await manager.getToken()).userId, 'user-2'); }); - test('keeps the cached token when the provider is unchanged', () async { - final provider = _CountingProvider((_) async => _token('token-1')); + test('expires the token cached for the previous user', () async { final manager = TokenManager( userId: 'user-1', - tokenProvider: provider, + tokenProvider: TokenProvider.static(_token('user-1')), ); await manager.getToken(); - manager.tokenProvider = provider; + expect(manager.peekToken(), _token('user-1')); - expect(manager.peekToken(), _token('token-1')); + manager.setTokenProvider( + 'user-2', + tokenProvider: TokenProvider.static(_token('user-2')), + ); + + expect(manager.peekToken(), isNull); }); - test('usesStaticProvider reflects the swapped provider', () { + test( + 'supports a guest exchange, which is authenticated anonymously before ' + 'its user id and token are known', + () async { + const serverId = 'guest-abc-guest-123'; + + final manager = TokenManager( + userId: UserToken.anonymousUserId, + tokenProvider: TokenProvider.static(UserToken.anonymous()), + ); + + final anonymous = await manager.getToken(); + expect(anonymous.authType, AuthType.anonymous); + expect(anonymous.rawValue, isEmpty); + + manager.setTokenProvider( + serverId, + tokenProvider: TokenProvider.static(_token(serverId)), + ); + + final guest = await manager.getToken(); + expect(manager.userId, serverId); + expect(guest.authType, AuthType.jwt); + expect(guest.userId, serverId); + }, + ); + + test('a load in flight does not cache its token over the new user', () async { + final slowLoad = Completer(); + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider((_) => slowLoad.future), + ); + + final pending = manager.getToken(); + + manager.setTokenProvider( + 'user-2', + tokenProvider: TokenProvider.static(_token('user-2')), + ); + slowLoad.complete(_token('user-1')); + await pending; + + // user-1's token must not be waiting in the cache for user-2 to send. + expect(manager.peekToken(), isNull); + expect((await manager.getToken()).userId, 'user-2'); + }); + + test('usesStaticProvider reflects the new provider', () { final manager = TokenManager( userId: 'user-1', tokenProvider: TokenProvider.static(_token('user-1')), @@ -152,7 +212,10 @@ void main() { expect(manager.usesStaticProvider, isTrue); - manager.tokenProvider = _CountingProvider((_) async => _token('t')); + manager.setTokenProvider( + 'user-1', + tokenProvider: _CountingProvider((_) async => _token('user-1')), + ); expect(manager.usesStaticProvider, isFalse); }); diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index a0b9746c..78524540 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -24,24 +24,51 @@ void main() { }); test('carries an optional raw value for restricted access', () { - final token = UserToken.anonymous(rawValue: 'restricted-jwt'); + final restricted = _fakeJwt(UserToken.anonymousUserId); + final token = UserToken.anonymous(rawValue: restricted); expect(token.userId, '!anon'); - expect(token.rawValue, 'restricted-jwt'); + expect(token.rawValue, restricted); expect(token.authType, AuthType.anonymous); }); + + test( + 'rejects a raw value claiming a real user, so an anonymous token cannot ' + 'stand in for someone else', + () { + expect( + () => UserToken.anonymous(rawValue: _fakeJwt('alice')), + throwsArgumentError, + ); + }, + ); + + test('rejects a raw value that is not a JWT at all', () { + expect( + () => UserToken.anonymous(rawValue: 'not-a-jwt'), + throwsArgumentError, + ); + }); + + test('rejects a raw value whose segments are not valid base64', () { + // Shaped like a JWT, so parsing gets further before failing. + expect( + () => UserToken.anonymous(rawValue: 'a.b.c'), + throwsFormatException, + ); + }); }); group('StaticTokenProvider', () { test('returns the token when the user ID matches', () async { - final token = UserToken.anonymous(userId: 'user-1'); + final token = UserToken(_fakeJwt('user-1')); final provider = TokenProvider.static(token); expect(await provider.loadToken('user-1'), token); }); test('throws when the user ID does not match', () { - final token = UserToken.anonymous(userId: 'user-1'); + final token = UserToken(_fakeJwt('user-1')); final provider = TokenProvider.static(token); expect(() => provider.loadToken('user-2'), throwsArgumentError); @@ -62,10 +89,22 @@ void main() { test('throws when the loader returns a non-JWT token', () { final provider = TokenProvider.dynamic( - (userId) async => UserToken.anonymous(userId: userId), + (_) async => UserToken.anonymous(), ); expect(() => provider.loadToken('user-1'), throwsArgumentError); }); + + test( + 'throws when the loader returns a token for a different user, which would ' + 'otherwise authenticate every later request as that user', + () { + final provider = TokenProvider.dynamic( + (_) async => UserToken(_fakeJwt('someone-else')), + ); + + expect(() => provider.loadToken('user-1'), throwsArgumentError); + }, + ); }); } From 84e948eb009ddd15a87639dc228f969f9dcf9fa4 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 11:19:09 +0200 Subject: [PATCH 02/97] docs(llc): drop the AuthInterceptor user_id entry from the changelog The entry claimed a fix this branch does not make. `AuthInterceptor` reads `user_id` from the token manager rather than from the loaded token on purpose: taking it from the token would make every request internally consistent and therefore always accepted, hiding a manager/token divergence instead of surfacing it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 2b739d60..c42cfae8 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -17,7 +17,6 @@ ### 🐛 Bug Fixes - Fixed `TokenManager.getToken()` contacting the `TokenProvider` on every call instead of returning the cached token -- Fixed `AuthInterceptor` sending a `user_id` that could disagree with the token in the `Authorization` header - Fixed `DynamicTokenProvider` accepting a token issued for a different user than the one requested ### 🔄 Changed From 8e963f4ee42e6504b2f873dd2f325debb8169aa3 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 11:22:09 +0200 Subject: [PATCH 03/97] test(llc): cover the deliberate user_id/token divergence in AuthInterceptor `setTokenProvider` makes it reachable for a request to carry `user_id` for one user and a token for another, when the manager is re-pointed while a token is loading. That is allowed on purpose so the server rejects it; deriving `user_id` from the token would make the request self-consistent and silently act as the token's owner. Pin it with a test so it is not "fixed" the other way, and trim the comment that claimed the opposite. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/interceptors/auth_interceptor.dart | 7 +--- .../interceptors/auth_interceptor_test.dart | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index 489ea92d..863c5ae6 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -55,11 +55,8 @@ class AuthInterceptor extends QueuedInterceptor { try { final token = await _effectiveTokenManager.getToken(); - // Re-read the token manager after awaiting the token: loading it may - // have swapped in a new manager carrying a server-resolved user id - // (e.g. a guest exchange). Reading `userId` here keeps the `user_id` - // query parameter consistent with the identity in the `Authorization` - // header below. + // Read from the manager rather than the token, so a token that belongs + // to someone else is rejected instead of silently accepted. options.queryParameters['user_id'] = _effectiveTokenManager.userId; options.headers['Authorization'] = token.rawValue; options.headers['stream-auth-type'] = token.authType.headerValue; diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index 2c0ed9e0..58051428 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:stream_core/stream_core.dart'; @@ -224,6 +225,46 @@ void main() { }, ); + test( + 'sends the token manager user id, not the loaded token user id, so a ' + 'manager pointed at another user mid-load is rejected rather than ' + 'silently authenticated as whoever the token belongs to', + () async { + final slowLoad = Completer(); + final tokenManager = TokenManager( + userId: 'user-1', + tokenProvider: TokenProvider.dynamic((_) => slowLoad.future), + ); + + final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); + final adapter = _CapturingHttpClientAdapter(); + dio.httpClientAdapter = adapter; + dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + + final pending = dio.get('/test'); + await pumpEventQueue(); + + // The load is already running for user-1 when the manager moves on. + final userTwoToken = _generateTestUserToken('user-2'); + tokenManager.setTokenProvider( + 'user-2', + tokenProvider: TokenProvider.static(userTwoToken), + ); + slowLoad.complete(_generateTestUserToken('user-1')); + await pending; + + // The mismatch is deliberate: `user_id` describes who we believe we + // are, so a request carrying someone else's token is rejected and the + // divergence surfaces. Deriving `user_id` from the token instead would + // make the request self-consistent and silently act as that user. + expect(adapter.lastRequest?.queryParameters['user_id'], 'user-2'); + expect( + adapter.lastRequest?.headers['Authorization'], + isNot(userTwoToken.rawValue), + ); + }, + ); + test( 'does not retry a token-expired response when using a static provider ' '(e.g. a guest token): the error is surfaced to the caller instead of ' From ed252e881eaef1000657e04297fb90b90ba813ed Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 11:32:18 +0200 Subject: [PATCH 04/97] fix(llc): discard a token load invalidated while it was in flight The stale-load guard compared user ids, which let two cases through: `setTokenProvider` with the same user id and a new provider, and a plain `expireToken()` during a load. Both ended up caching the token the caller had just asked to stop using. Loads now carry a generation stamp that `expireToken` bumps, which subsumes the user id case. Also address review feedback: order `DynamicTokenProvider`'s checks so a non-JWT token is reported as the wrong type rather than the wrong user, align `StaticTokenProvider`'s mismatch message with it, and document that `UserToken.anonymous` throws FormatException for an unparsable rawValue. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 3 +- .../lib/src/user/token_manager.dart | 25 ++++++++--- .../lib/src/user/token_provider.dart | 17 ++++--- .../stream_core/lib/src/user/user_token.dart | 5 ++- .../test/user/token_manager_test.dart | 45 +++++++++++++++++++ .../test/user/token_provider_test.dart | 13 +++++- 6 files changed, 90 insertions(+), 18 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index c42cfae8..4f5fe5c0 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -9,7 +9,7 @@ - Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token - Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load -- Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access +- Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access, provided its `user_id` claim is `UserToken.anonymousUserId` - Added `UserToken.anonymousUserId`, the user id used for anonymous authentication - Added `AuthInterceptor.withProvider`, which takes a `TokenManager Function()` getter instead of a fixed instance - Added `teams` field to `User` class @@ -18,6 +18,7 @@ - Fixed `TokenManager.getToken()` contacting the `TokenProvider` on every call instead of returning the cached token - Fixed `DynamicTokenProvider` accepting a token issued for a different user than the one requested +- Fixed `TokenManager` caching a token that finished loading after `expireToken` or `setTokenProvider` had invalidated it ### 🔄 Changed diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index 92c7f8c7..dbf67c3a 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -98,6 +98,10 @@ class TokenManager { // The currently cached token, if any. UserToken? _cachedToken; + // Bumped every time the cached token is invalidated, so a load that started + // before that point can tell its result is no longer wanted. + var _generation = 0; + /// Returns the currently cached token without loading a new one. /// /// Returns the cached [UserToken] if available, or null if no token @@ -130,16 +134,17 @@ class TokenManager { }); } - // Loads a token from the provider and, unless the manager has since been - // pointed at another user, caches it and notifies `onTokenUpdated`. + // Loads a token from the provider and, unless the cached token was + // invalidated while it loaded, caches it and notifies `onTokenUpdated`. Future _loadAndNotify() async { final loadingFor = _userId; + final loadingGeneration = _generation; final updatedToken = await _tokenProvider.loadToken(loadingFor); - // Only cache the token if the manager still points at the user it was - // loaded for; `setTokenProvider` may have run during the load, and the - // token belongs to whoever we were before. - if (loadingFor != _userId) return updatedToken; + // Only cache the token if nothing invalidated the cache while it loaded. + // `setTokenProvider` or `expireToken` may have run, which means this token + // is the one the caller asked us to stop using. + if (loadingGeneration != _generation) return updatedToken; _cachedToken = updatedToken; _onTokenUpdated?.call(updatedToken); @@ -152,5 +157,11 @@ class TokenManager { /// Clears the cached token, forcing the next call to [getToken] to /// load a fresh token from the provider. This is useful when a token /// becomes invalid or needs to be refreshed. - void expireToken() => _cachedToken = null; + /// + /// A load already in flight is discarded too, rather than caching the token + /// this call asked to stop using. + void expireToken() { + _generation++; + _cachedToken = null; + } } diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index bec7813c..843fb399 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -75,7 +75,7 @@ class StaticTokenProvider implements TokenProvider { // Validate that the token's user_id matches the requested userId if (_rawToken.userId != userId) { throw ArgumentError( - 'User ID mismatch: expected "${_rawToken.userId}", got "$userId"', + 'User ID mismatch: expected "$userId", got "${_rawToken.userId}"', ); } @@ -108,17 +108,20 @@ class DynamicTokenProvider implements TokenProvider { Future loadToken(String userId) async { final token = await _loader.call(userId); - // Validate that the token's user_id matches the requested userId - if (token.userId != userId) { + // Validate the type before the user id, so a non-JWT token is reported as + // the wrong type rather than as belonging to the wrong user: an anonymous + // token always carries `UserToken.anonymousUserId`, so it would otherwise + // fail the user id check first. + if (token.authType != AuthType.jwt) { throw ArgumentError( - 'User ID mismatch: expected "$userId", got "${token.userId}"', + 'Token type mismatch: expected jwt, got ${token.authType.headerValue}', ); } - // Validate that the returned token is a JWT token - if (token.authType != AuthType.jwt) { + // Validate that the token's user_id matches the requested userId + if (token.userId != userId) { throw ArgumentError( - 'Token type mismatch: expected jwt, got ${token.authType.headerValue}', + 'User ID mismatch: expected "$userId", got "${token.userId}"', ); } diff --git a/packages/stream_core/lib/src/user/user_token.dart b/packages/stream_core/lib/src/user/user_token.dart index 0e0e0961..a4a0621a 100644 --- a/packages/stream_core/lib/src/user/user_token.dart +++ b/packages/stream_core/lib/src/user/user_token.dart @@ -67,8 +67,9 @@ class UserToken extends Equatable { /// /// Returns a [UserToken] configured for anonymous access. /// - /// Throws an [ArgumentError] if [rawValue] is given and is not a valid JWT, - /// or if its 'user_id' claim is not [anonymousUserId]. + /// Throws an [ArgumentError] if [rawValue] is given and its 'user_id' claim + /// is not [anonymousUserId], and a [FormatException] if it cannot be parsed + /// as a JWT. factory UserToken.anonymous({String rawValue = ''}) { if (rawValue.isNotEmpty) { final jwtBody = JsonWebToken.unverified(rawValue); diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 89d461e1..c24379e2 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -119,6 +119,26 @@ void main() { expect(await manager.getToken(), _token('v2')); expect(provider.loadCount, 2); }); + + test( + 'discards a load in flight, rather than caching the token it was told ' + 'to stop using', + () async { + final slowLoad = Completer(); + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider((_) => slowLoad.future), + ); + + final pending = manager.getToken(); + + manager.expireToken(); + slowLoad.complete(_token('user-1')); + await pending; + + expect(manager.peekToken(), isNull); + }, + ); }); group('setTokenProvider', () { @@ -204,6 +224,31 @@ void main() { expect((await manager.getToken()).userId, 'user-2'); }); + test( + 'discards a load in flight when only the provider changes, so the ' + 'replaced provider cannot cache its token for the same user', + () async { + final slowLoad = Completer(); + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider((_) => slowLoad.future), + ); + + final pending = manager.getToken(); + + // Same user, fresh provider — the user id guard alone would let the + // replaced provider's token through. + manager.setTokenProvider( + 'user-1', + tokenProvider: TokenProvider.static(_token('user-1')), + ); + slowLoad.complete(_token('user-1')); + await pending; + + expect(manager.peekToken(), isNull); + }, + ); + test('usesStaticProvider reflects the new provider', () { final manager = TokenManager( userId: 'user-1', diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index 78524540..36742cf5 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -92,7 +92,18 @@ void main() { (_) async => UserToken.anonymous(), ); - expect(() => provider.loadToken('user-1'), throwsArgumentError); + // Reported as the wrong type, not the wrong user: an anonymous token + // also carries a user id that cannot match the one requested. + expect( + () => provider.loadToken('user-1'), + throwsA( + isArgumentError.having( + (it) => it.message, + 'message', + contains('Token type mismatch'), + ), + ), + ); }); test( From a64dbb0fd8dae2b58471f1cf3acee4555bf79356 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 11:33:32 +0200 Subject: [PATCH 05/97] refactor(llc): make token provider mismatches assertable without their text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ordering test matched on the message prose, which means rewording the error breaks the test. Throw ArgumentError.value with a name instead — as UserToken already does — so a test can assert which check failed rather than how it was phrased. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/user/token_provider.dart | 18 ++++++++++++------ .../test/user/token_provider_test.dart | 8 +------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index 843fb399..aa28350d 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -74,8 +74,10 @@ class StaticTokenProvider implements TokenProvider { Future loadToken(String userId) async { // Validate that the token's user_id matches the requested userId if (_rawToken.userId != userId) { - throw ArgumentError( - 'User ID mismatch: expected "$userId", got "${_rawToken.userId}"', + throw ArgumentError.value( + _rawToken.userId, + 'userId', + 'User ID mismatch: expected "$userId"', ); } @@ -113,15 +115,19 @@ class DynamicTokenProvider implements TokenProvider { // token always carries `UserToken.anonymousUserId`, so it would otherwise // fail the user id check first. if (token.authType != AuthType.jwt) { - throw ArgumentError( - 'Token type mismatch: expected jwt, got ${token.authType.headerValue}', + throw ArgumentError.value( + token.authType.headerValue, + 'authType', + 'Token type mismatch: expected ${AuthType.jwt.headerValue}', ); } // Validate that the token's user_id matches the requested userId if (token.userId != userId) { - throw ArgumentError( - 'User ID mismatch: expected "$userId", got "${token.userId}"', + throw ArgumentError.value( + token.userId, + 'userId', + 'User ID mismatch: expected "$userId"', ); } diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index 36742cf5..700f40be 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -96,13 +96,7 @@ void main() { // also carries a user id that cannot match the one requested. expect( () => provider.loadToken('user-1'), - throwsA( - isArgumentError.having( - (it) => it.message, - 'message', - contains('Token type mismatch'), - ), - ), + throwsA(isArgumentError.having((it) => it.name, 'name', 'authType')), ); }); From 64a85e0aaa64cc21eae2ff14c3f169d5cac4687c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 11:35:50 +0200 Subject: [PATCH 06/97] refactor(llc): drop the redundant prefix from token mismatch messages `Invalid argument (authType)` already says what failed, so restating it as "Token type mismatch" left three colons in one line. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/lib/src/user/token_provider.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index aa28350d..814d0f05 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -77,7 +77,7 @@ class StaticTokenProvider implements TokenProvider { throw ArgumentError.value( _rawToken.userId, 'userId', - 'User ID mismatch: expected "$userId"', + 'Expected "$userId"', ); } @@ -118,7 +118,7 @@ class DynamicTokenProvider implements TokenProvider { throw ArgumentError.value( token.authType.headerValue, 'authType', - 'Token type mismatch: expected ${AuthType.jwt.headerValue}', + 'Expected ${AuthType.jwt.headerValue}', ); } @@ -127,7 +127,7 @@ class DynamicTokenProvider implements TokenProvider { throw ArgumentError.value( token.userId, 'userId', - 'User ID mismatch: expected "$userId"', + 'Expected "$userId"', ); } From 07db567bfbde0ec187d223f71c1d4348afc6e492 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 11:37:34 +0200 Subject: [PATCH 07/97] test(llc): share one JWT builder across the token tests Three test files each defined their own, and two of them claimed alg HS256 while attaching a base64 blob that is not a signature. Adopt the alg=none builder stream_feeds_test already uses, which is an honest unsigned JWT, and expose both the raw string and the UserToken since both are needed. Co-Authored-By: Claude Opus 5 (1M context) --- .../interceptors/auth_interceptor_test.dart | 33 +++----- .../stream_core/test/helpers/user_token.dart | 25 +++++++ .../test/user/token_manager_test.dart | 75 ++++++++----------- .../test/user/token_provider_test.dart | 24 ++---- 4 files changed, 76 insertions(+), 81 deletions(-) create mode 100644 packages/stream_core/test/helpers/user_token.dart diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index 58051428..82df36f7 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -4,6 +4,8 @@ import 'dart:convert'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; +import '../../helpers/user_token.dart'; + // A minimal HttpClientAdapter that captures the outgoing RequestOptions and // always responds with an empty successful response. class _CapturingHttpClientAdapter implements HttpClientAdapter { @@ -69,19 +71,6 @@ class _TokenExpiredHttpClientAdapter implements HttpClientAdapter { void close({bool force = false}) {} } -UserToken _generateTestUserToken(String userId) { - String b64UrlNoPad(Object jsonObj) { - final bytes = utf8.encode(jsonEncode(jsonObj)); - return base64Url.encode(bytes).replaceAll('=', ''); - } - - final header = {'alg': 'none', 'typ': 'JWT'}; - final payload = {'user_id': userId}; - - final jwt = '${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'; - return UserToken(jwt); -} - void main() { group('AuthInterceptor', () { test( @@ -92,7 +81,7 @@ void main() { final tokenManager = TokenManager( userId: 'user-123', tokenProvider: TokenProvider.static( - _generateTestUserToken('user-123'), + generateTestUserToken('user-123'), ), ); @@ -137,7 +126,7 @@ void main() { tokenManager.setTokenProvider( serverId, - tokenProvider: TokenProvider.static(_generateTestUserToken(serverId)), + tokenProvider: TokenProvider.static(generateTestUserToken(serverId)), ); await dio.get('/test'); @@ -156,7 +145,7 @@ void main() { final tokenManager = TokenManager( userId: 'user-123', tokenProvider: TokenProvider.static( - _generateTestUserToken('user-123'), + generateTestUserToken('user-123'), ), ); @@ -202,7 +191,7 @@ void main() { test( 'sends a restricted anonymous token as the Authorization header', () async { - final restricted = _generateTestUserToken(UserToken.anonymousUserId); + final restricted = generateTestUserToken(UserToken.anonymousUserId); final tokenManager = TokenManager( userId: UserToken.anonymousUserId, tokenProvider: TokenProvider.static( @@ -245,12 +234,12 @@ void main() { await pumpEventQueue(); // The load is already running for user-1 when the manager moves on. - final userTwoToken = _generateTestUserToken('user-2'); + final userTwoToken = generateTestUserToken('user-2'); tokenManager.setTokenProvider( 'user-2', tokenProvider: TokenProvider.static(userTwoToken), ); - slowLoad.complete(_generateTestUserToken('user-1')); + slowLoad.complete(generateTestUserToken('user-1')); await pending; // The mismatch is deliberate: `user_id` describes who we believe we @@ -272,7 +261,7 @@ void main() { () async { final tokenManager = TokenManager( userId: 'guest-1', - tokenProvider: TokenProvider.static(_generateTestUserToken('guest-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('guest-1')), ); final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); @@ -303,7 +292,7 @@ void main() { var tokenManager = TokenManager( userId: 'requested-id', tokenProvider: TokenProvider.dynamic( - (_) async => _generateTestUserToken('requested-id'), + (_) async => generateTestUserToken('requested-id'), ), ); @@ -313,7 +302,7 @@ void main() { tokenManager = TokenManager( userId: 'server-assigned-id', tokenProvider: TokenProvider.static( - _generateTestUserToken('server-assigned-id'), + generateTestUserToken('server-assigned-id'), ), ); }, diff --git a/packages/stream_core/test/helpers/user_token.dart b/packages/stream_core/test/helpers/user_token.dart new file mode 100644 index 00000000..fb32afd6 --- /dev/null +++ b/packages/stream_core/test/helpers/user_token.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; + +import 'package:stream_core/stream_core.dart'; + +/// Builds an unsigned JWT carrying [userId] as its 'user_id' claim. +/// +/// Sufficient for [UserToken]'s unverified parsing — nothing in these tests +/// checks a signature. +String generateTestJwt(String userId) { + String b64UrlNoPad(Object jsonObj) { + final bytes = utf8.encode(jsonEncode(jsonObj)); + return base64Url.encode(bytes).replaceAll('=', ''); + } + + final header = {'alg': 'none', 'typ': 'JWT'}; + final payload = {'user_id': userId}; + + // Trailing dot = empty signature, which is what alg=none means. + return '${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'; +} + +/// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. +UserToken generateTestUserToken(String userId) { + return UserToken(generateTestJwt(userId)); +} diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index c24379e2..f4e57afc 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -1,9 +1,10 @@ import 'dart:async'; -import 'dart:convert'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; +import '../helpers/user_token.dart'; + /// A token provider that counts loads and delegates to a configurable loader. class _CountingProvider implements TokenProvider { _CountingProvider(this._load); @@ -20,21 +21,11 @@ class _CountingProvider implements TokenProvider { } } -/// Builds a JWT [UserToken] with the given [userId] claim, sufficient for -/// [UserToken]'s unverified parsing. -UserToken _token(String userId) { - String encode(Map json) => base64Url.encode(utf8.encode(jsonEncode(json))).replaceAll('=', ''); - final header = encode({'alg': 'HS256', 'typ': 'JWT'}); - final payload = encode({'user_id': userId}); - final signature = encode({'sig': 'fake'}); - return UserToken('$header.$payload.$signature'); -} - void main() { group('TokenManager', () { group('getToken', () { test('loads from the provider and caches the result', () async { - final provider = _CountingProvider((_) async => _token('token-1')); + final provider = _CountingProvider((_) async => generateTestUserToken('token-1')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, @@ -43,17 +34,17 @@ void main() { final first = await manager.getToken(); final second = await manager.getToken(); - expect(first, _token('token-1')); - expect(second, _token('token-1')); + expect(first, generateTestUserToken('token-1')); + expect(second, generateTestUserToken('token-1')); expect(provider.loadCount, 1); - expect(manager.peekToken(), _token('token-1')); + expect(manager.peekToken(), generateTestUserToken('token-1')); }); test('passes the manager userId to the provider', () async { String? requestedUserId; final provider = _CountingProvider((userId) async { requestedUserId = userId; - return _token('token-1'); + return generateTestUserToken('token-1'); }); final manager = TokenManager( userId: 'user-1', @@ -74,10 +65,10 @@ void main() { ); final futures = [manager.getToken(), manager.getToken()]; - completer.complete(_token('token-1')); + completer.complete(generateTestUserToken('token-1')); final tokens = await Future.wait(futures); - expect(tokens, everyElement(_token('token-1'))); + expect(tokens, everyElement(generateTestUserToken('token-1'))); expect(provider.loadCount, 1); }); @@ -86,7 +77,7 @@ void main() { final provider = _CountingProvider((_) async { attempts++; if (attempts == 1) throw StateError('load failed'); - return _token('token-2'); + return generateTestUserToken('token-2'); }); final manager = TokenManager( userId: 'user-1', @@ -97,7 +88,7 @@ void main() { expect(manager.peekToken(), isNull); final token = await manager.getToken(); - expect(token, _token('token-2')); + expect(token, generateTestUserToken('token-2')); expect(provider.loadCount, 2); }); }); @@ -105,18 +96,18 @@ void main() { group('expireToken', () { test('clears the cache and forces a reload', () async { var version = 0; - final provider = _CountingProvider((_) async => _token('v${++version}')); + final provider = _CountingProvider((_) async => generateTestUserToken('v${++version}')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, ); - expect(await manager.getToken(), _token('v1')); + expect(await manager.getToken(), generateTestUserToken('v1')); manager.expireToken(); expect(manager.peekToken(), isNull); - expect(await manager.getToken(), _token('v2')); + expect(await manager.getToken(), generateTestUserToken('v2')); expect(provider.loadCount, 2); }); @@ -133,7 +124,7 @@ void main() { final pending = manager.getToken(); manager.expireToken(); - slowLoad.complete(_token('user-1')); + slowLoad.complete(generateTestUserToken('user-1')); await pending; expect(manager.peekToken(), isNull); @@ -145,14 +136,14 @@ void main() { test('points the manager at another user', () async { final manager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(_token('user-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), ); expect((await manager.getToken()).userId, 'user-1'); manager.setTokenProvider( 'user-2', - tokenProvider: TokenProvider.static(_token('user-2')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), ); expect(manager.userId, 'user-2'); @@ -162,15 +153,15 @@ void main() { test('expires the token cached for the previous user', () async { final manager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(_token('user-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), ); await manager.getToken(); - expect(manager.peekToken(), _token('user-1')); + expect(manager.peekToken(), generateTestUserToken('user-1')); manager.setTokenProvider( 'user-2', - tokenProvider: TokenProvider.static(_token('user-2')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), ); expect(manager.peekToken(), isNull); @@ -193,7 +184,7 @@ void main() { manager.setTokenProvider( serverId, - tokenProvider: TokenProvider.static(_token(serverId)), + tokenProvider: TokenProvider.static(generateTestUserToken(serverId)), ); final guest = await manager.getToken(); @@ -214,9 +205,9 @@ void main() { manager.setTokenProvider( 'user-2', - tokenProvider: TokenProvider.static(_token('user-2')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), ); - slowLoad.complete(_token('user-1')); + slowLoad.complete(generateTestUserToken('user-1')); await pending; // user-1's token must not be waiting in the cache for user-2 to send. @@ -240,9 +231,9 @@ void main() { // replaced provider's token through. manager.setTokenProvider( 'user-1', - tokenProvider: TokenProvider.static(_token('user-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), ); - slowLoad.complete(_token('user-1')); + slowLoad.complete(generateTestUserToken('user-1')); await pending; expect(manager.peekToken(), isNull); @@ -252,14 +243,14 @@ void main() { test('usesStaticProvider reflects the new provider', () { final manager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(_token('user-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), ); expect(manager.usesStaticProvider, isTrue); manager.setTokenProvider( 'user-1', - tokenProvider: _CountingProvider((_) async => _token('user-1')), + tokenProvider: _CountingProvider((_) async => generateTestUserToken('user-1')), ); expect(manager.usesStaticProvider, isFalse); @@ -270,11 +261,11 @@ void main() { test('reflects the provider type', () { final staticManager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(_token('user-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), ); final dynamicManager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => _token('t')), + tokenProvider: _CountingProvider((_) async => generateTestUserToken('t')), ); expect(staticManager.usesStaticProvider, isTrue); @@ -286,7 +277,7 @@ void main() { test('fires once per load with the loaded token', () async { final updates = []; var version = 0; - final provider = _CountingProvider((_) async => _token('v${++version}')); + final provider = _CountingProvider((_) async => generateTestUserToken('v${++version}')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, @@ -299,14 +290,14 @@ void main() { manager.expireToken(); await manager.getToken(); - expect(updates, [_token('v1'), _token('v2')]); + expect(updates, [generateTestUserToken('v1'), generateTestUserToken('v2')]); }); test('is invoked before the token is returned', () async { UserToken? notified; final manager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => _token('token-1')), + tokenProvider: _CountingProvider((_) async => generateTestUserToken('token-1')), onTokenUpdated: (token) => notified = token, ); @@ -320,7 +311,7 @@ void main() { Future? reentrantCall; manager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => _token('token-1')), + tokenProvider: _CountingProvider((_) async => generateTestUserToken('token-1')), onTokenUpdated: (_) { reentrantCall = manager.getToken(); }, diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index 700f40be..809cf14a 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -1,17 +1,7 @@ -import 'dart:convert'; - import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; -/// Builds an unsigned JWT with the given [userId] claim, sufficient for -/// [UserToken]'s unverified parsing. -String _fakeJwt(String userId) { - String encode(Map json) => base64Url.encode(utf8.encode(jsonEncode(json))).replaceAll('=', ''); - final header = encode({'alg': 'HS256', 'typ': 'JWT'}); - final payload = encode({'user_id': userId}); - final signature = encode({'sig': 'fake'}); - return '$header.$payload.$signature'; -} +import '../helpers/user_token.dart'; void main() { group('UserToken.anonymous', () { @@ -24,7 +14,7 @@ void main() { }); test('carries an optional raw value for restricted access', () { - final restricted = _fakeJwt(UserToken.anonymousUserId); + final restricted = generateTestJwt(UserToken.anonymousUserId); final token = UserToken.anonymous(rawValue: restricted); expect(token.userId, '!anon'); @@ -37,7 +27,7 @@ void main() { 'stand in for someone else', () { expect( - () => UserToken.anonymous(rawValue: _fakeJwt('alice')), + () => UserToken.anonymous(rawValue: generateTestJwt('alice')), throwsArgumentError, ); }, @@ -61,14 +51,14 @@ void main() { group('StaticTokenProvider', () { test('returns the token when the user ID matches', () async { - final token = UserToken(_fakeJwt('user-1')); + final token = generateTestUserToken('user-1'); final provider = TokenProvider.static(token); expect(await provider.loadToken('user-1'), token); }); test('throws when the user ID does not match', () { - final token = UserToken(_fakeJwt('user-1')); + final token = generateTestUserToken('user-1'); final provider = TokenProvider.static(token); expect(() => provider.loadToken('user-2'), throwsArgumentError); @@ -78,7 +68,7 @@ void main() { group('DynamicTokenProvider', () { test('returns JWT tokens from the loader', () async { final provider = TokenProvider.dynamic( - (userId) async => UserToken(_fakeJwt(userId)), + (userId) async => generateTestUserToken(userId), ); final token = await provider.loadToken('user-1'); @@ -105,7 +95,7 @@ void main() { 'otherwise authenticate every later request as that user', () { final provider = TokenProvider.dynamic( - (_) async => UserToken(_fakeJwt('someone-else')), + (_) async => generateTestUserToken('someone-else'), ); expect(() => provider.loadToken('user-1'), throwsArgumentError); From c91b3affb68cfbd6266529cf32192bd837a89977 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 11:43:28 +0200 Subject: [PATCH 08/97] refactor(llc)!: remove AuthInterceptor.withProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It existed so callers could swap in a whole new TokenManager once a guest exchange resolved its user id. `setTokenProvider` does that on the manager itself, so the indirection buys nothing and leaves two ways to do one thing. The interceptor file reverts to its pre-#128 state exactly. Never shipped — #128 added it in this same unreleased cycle — so its changelog entry is dropped rather than recorded as a breaking change. Also from review: document the FormatException that `UserToken`'s factories can throw, note that `setTokenProvider` discards an in-flight load, and fix a test comment that restated a guarantee the file's own test contradicts. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 1 - .../api/interceptors/auth_interceptor.dart | 51 +++---------------- .../lib/src/user/token_manager.dart | 9 ++-- .../stream_core/lib/src/user/user_token.dart | 8 +-- .../interceptors/auth_interceptor_test.dart | 46 +++++------------ 5 files changed, 29 insertions(+), 86 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 4f5fe5c0..de8f5ea1 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -11,7 +11,6 @@ - Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load - Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access, provided its `user_id` claim is `UserToken.anonymousUserId` - Added `UserToken.anonymousUserId`, the user id used for anonymous authentication -- Added `AuthInterceptor.withProvider`, which takes a `TokenManager Function()` getter instead of a fixed instance - Added `teams` field to `User` class ### 🐛 Bug Fixes diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index 863c5ae6..ea7a0ed3 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -4,48 +4,16 @@ import '../../errors.dart'; import '../../user.dart'; import '../stream_core_dio_error.dart'; -/// Provides the [TokenManager] currently in use by an [AuthInterceptor]. -/// -/// A getter rather than a fixed reference so the caller can swap the underlying -/// [TokenManager] at runtime — e.g. after a guest token exchange resolves a -/// server-assigned user id — and have the interceptor pick up the new instance. -typedef TokenManagerProvider = TokenManager Function(); - /// Authentication interceptor that refreshes the token if /// an auth error is received class AuthInterceptor extends QueuedInterceptor { - /// Initialize a new auth interceptor backed by a fixed [tokenManager]. - /// - /// Use this when the [TokenManager] never changes for the lifetime of the - /// interceptor. If you need to swap the manager at runtime — e.g. after a - /// guest token exchange resolves a server-assigned user id — use - /// [AuthInterceptor.withProvider] instead. - AuthInterceptor( - this._dio, - TokenManager tokenManager, - ) : _tokenManager = tokenManager, - _tokenManagerProvider = null; - - /// Initialize a new auth interceptor backed by a [_tokenManagerProvider]. - /// - /// The provider is a getter rather than a fixed reference so the caller can - /// swap the underlying [TokenManager] — e.g. after a guest token exchange - /// resolves a server-assigned user id — and have this interceptor pick up - /// the new instance on its next request. - AuthInterceptor.withProvider( - this._dio, { - required TokenManagerProvider this._tokenManagerProvider, - }) : _tokenManager = null; + /// Initialize a new auth interceptor + AuthInterceptor(this._dio, this._tokenManager); final Dio _dio; - final TokenManager? _tokenManager; - - /// Provides the token manager currently in use. - final TokenManagerProvider? _tokenManagerProvider; - - /// The token manager currently in use. - TokenManager get _effectiveTokenManager => _tokenManager ?? _tokenManagerProvider!.call(); + /// The token manager used in the client + final TokenManager _tokenManager; @override Future onRequest( @@ -53,11 +21,9 @@ class AuthInterceptor extends QueuedInterceptor { RequestInterceptorHandler handler, ) async { try { - final token = await _effectiveTokenManager.getToken(); + final token = await _tokenManager.getToken(); - // Read from the manager rather than the token, so a token that belongs - // to someone else is rejected instead of silently accepted. - options.queryParameters['user_id'] = _effectiveTokenManager.userId; + options.queryParameters['user_id'] = _tokenManager.userId; options.headers['Authorization'] = token.rawValue; options.headers['stream-auth-type'] = token.authType.headerValue; @@ -91,11 +57,10 @@ class AuthInterceptor extends QueuedInterceptor { final error = StreamApiError.fromJson(data); if (error.isTokenExpiredError) { - final tokenManager = _effectiveTokenManager; // Don't try to refresh the token if we're using a static provider - if (tokenManager.usesStaticProvider) return handler.next(err); + if (_tokenManager.usesStaticProvider) return handler.next(err); // Otherwise, mark the current token as expired. - tokenManager.expireToken(); + _tokenManager.expireToken(); try { final options = err.requestOptions; diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index dbf67c3a..dfecb0b8 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -62,9 +62,10 @@ class TokenManager { /// Points this manager at `userId`, loading its tokens from `tokenProvider`. /// - /// The user and the provider change together, so the manager can never report - /// one user while holding another's token. Expires the cached token, so the - /// next [getToken] call loads a fresh one for the new user. + /// The user and the provider change together, so the manager can never cache + /// one user's token under another. Expires the cached token, and discards a + /// load already in flight, so the next [getToken] call loads a fresh one for + /// the new user. /// /// Use this to reuse a manager across users, and when a user's identity is /// only known after an authenticated request — a guest, whose id and token @@ -92,7 +93,7 @@ class TokenManager { // The cached token belongs to the previous user and provider, so drop it // and let the next `getToken` call load a fresh one. - return expireToken(); + expireToken(); } // The currently cached token, if any. diff --git a/packages/stream_core/lib/src/user/user_token.dart b/packages/stream_core/lib/src/user/user_token.dart index a4a0621a..1337c10e 100644 --- a/packages/stream_core/lib/src/user/user_token.dart +++ b/packages/stream_core/lib/src/user/user_token.dart @@ -36,8 +36,8 @@ class UserToken extends Equatable { /// /// Returns a [UserToken] configured for JWT authentication. /// - /// Throws an [ArgumentError] if the [rawValue] is not a valid JWT token - /// or if the 'user_id' claim is missing or empty. + /// Throws an [ArgumentError] if the 'user_id' claim is missing or empty, and + /// a [FormatException] if [rawValue] cannot be parsed as a JWT. factory UserToken(String rawValue) { final jwtBody = JsonWebToken.unverified(rawValue); final userId = jwtBody.claims.getTyped('user_id'); @@ -76,9 +76,9 @@ class UserToken extends Equatable { final claim = jwtBody.claims.getTyped('user_id'); if (claim != anonymousUserId) { throw ArgumentError.value( - rawValue, + claim, 'rawValue', - 'Invalid anonymous JWT token: user_id claim must be "$anonymousUserId", got "$claim"', + 'Expected a JWT claiming user_id "$anonymousUserId"', ); } } diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index 82df36f7..1cc064f6 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -109,9 +109,8 @@ void main() { 'pointed at it', () async { // Simulates the guest flow: the exchange is authenticated anonymously, - // then the manager is pointed at the id the exchange returned. The user - // id and the token change together, so the `user_id` query parameter - // always describes the token in the `Authorization` header. + // then the manager is pointed at the id the exchange returned before + // the next request goes out, so nothing is in flight across the swap. const serverId = 'server-assigned-id'; final tokenManager = TokenManager( @@ -139,27 +138,6 @@ void main() { }, ); - test( - 'uses the current TokenManager userId when nothing swaps it', - () async { - final tokenManager = TokenManager( - userId: 'user-123', - tokenProvider: TokenProvider.static( - generateTestUserToken('user-123'), - ), - ); - - final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); - final adapter = _CapturingHttpClientAdapter(); - dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor.withProvider(dio, tokenManagerProvider: () => tokenManager)); - - await dio.get('/test'); - - expect(adapter.lastRequest?.queryParameters['user_id'], 'user-123'); - }, - ); - test( 'sends an anonymous token as an empty Authorization header with the ' 'anonymous auth type', @@ -267,7 +245,7 @@ void main() { final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); final adapter = _TokenExpiredHttpClientAdapter(); dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor.withProvider(dio, tokenManagerProvider: () => tokenManager)); + dio.interceptors.add(AuthInterceptor(dio, tokenManager)); await expectLater( dio.get('/test'), @@ -282,14 +260,14 @@ void main() { test( 'forwards a token-expired error without retrying when the token manager ' - 'is swapped to a static provider after the request was dispatched ' + 'is pointed at a static provider after the request was dispatched ' '(guest exchange resolving mid-flight)', () async { - // Starts on a dynamic manager and swaps to a static one carrying the + // Starts on a dynamic provider and adopts a static one carrying the // exchanged id once the request is already in flight, mirroring the - // guest flow. onError observes the swapped-in (static) manager and must - // forward the error rather than expire + retry. - var tokenManager = TokenManager( + // guest flow. onError sees the static provider and must forward the + // error rather than expire + retry. + final tokenManager = TokenManager( userId: 'requested-id', tokenProvider: TokenProvider.dynamic( (_) async => generateTestUserToken('requested-id'), @@ -299,8 +277,8 @@ void main() { final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); final adapter = _TokenExpiredHttpClientAdapter( onFetch: () { - tokenManager = TokenManager( - userId: 'server-assigned-id', + tokenManager.setTokenProvider( + 'server-assigned-id', tokenProvider: TokenProvider.static( generateTestUserToken('server-assigned-id'), ), @@ -308,14 +286,14 @@ void main() { }, ); dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor.withProvider(dio, tokenManagerProvider: () => tokenManager)); + dio.interceptors.add(AuthInterceptor(dio, tokenManager)); await expectLater( dio.get('/test'), throwsA(isA()), ); - // The swapped-in manager is static, so the error is surfaced without a + // The adopted provider is static, so the error is surfaced without a // refresh-and-retry: the request is attempted exactly once. expect(adapter.requestCount, 1); }, From 0d8822630b53e2583078ac64a4e60c1686cb156d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 11:54:47 +0200 Subject: [PATCH 09/97] style(llc): align with STYLE_GUIDE and TESTING conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use `### 🛑 Breaking / Removals`; the guide lists `### 💥 BREAKING CHANGES` as grandfathered, for existing entries only - Shorten test names to the behaviour and move the rationale into the body, per TESTING.md — a name should be scannable in the runner output - Drop "positional constructor / backwards-compatible API" from a test name; with `withProvider` gone there is only one constructor - Recommend rather than instruct in `setTokenProvider`'s dartdoc, and trim two inline comments to the why Pre-existing and deliberately left: the nested `group('TokenManager')` > `group('getToken')` layout, which the guide would rather see split into files. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- .../stream_core/lib/src/user/token_manager.dart | 11 +++++------ .../stream_core/lib/src/user/token_provider.dart | 6 ++---- .../api/interceptors/auth_interceptor_test.dart | 16 ++++++---------- .../test/user/token_manager_test.dart | 9 +++------ .../test/user/token_provider_test.dart | 8 ++++---- 6 files changed, 21 insertions(+), 31 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index de8f5ea1..481bd544 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -1,6 +1,6 @@ ## Upcoming -### 💥 BREAKING CHANGES +### 🛑 Breaking / Removals - Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `UserToken.anonymousUserId` - Removed the `TokenManager.tokenProvider` setter, use `setTokenProvider` instead diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index dfecb0b8..a06c9e3a 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -67,9 +67,9 @@ class TokenManager { /// load already in flight, so the next [getToken] call loads a fresh one for /// the new user. /// - /// Use this to reuse a manager across users, and when a user's identity is - /// only known after an authenticated request — a guest, whose id and token - /// are both issued in exchange for an anonymous one: + /// To reuse a manager across users, or to authenticate as a user whose + /// identity is only known after an authenticated request — a guest, whose id + /// and token are both issued in exchange for an anonymous one — consider: /// /// ```dart /// // Authenticate anonymously while the real identity is being obtained. @@ -142,9 +142,8 @@ class TokenManager { final loadingGeneration = _generation; final updatedToken = await _tokenProvider.loadToken(loadingFor); - // Only cache the token if nothing invalidated the cache while it loaded. - // `setTokenProvider` or `expireToken` may have run, which means this token - // is the one the caller asked us to stop using. + // `setTokenProvider` or `expireToken` may have run while this loaded, in + // which case the token is the one the caller asked to stop using. if (loadingGeneration != _generation) return updatedToken; _cachedToken = updatedToken; diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index 814d0f05..8525412e 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -110,10 +110,8 @@ class DynamicTokenProvider implements TokenProvider { Future loadToken(String userId) async { final token = await _loader.call(userId); - // Validate the type before the user id, so a non-JWT token is reported as - // the wrong type rather than as belonging to the wrong user: an anonymous - // token always carries `UserToken.anonymousUserId`, so it would otherwise - // fail the user id check first. + // Checked before the user id: an anonymous token carries a user id that can + // never match, so it would otherwise be reported as the wrong user. if (token.authType != AuthType.jwt) { throw ArgumentError.value( token.authType.headerValue, diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index 1cc064f6..5dece376 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -74,9 +74,8 @@ class _TokenExpiredHttpClientAdapter implements HttpClientAdapter { void main() { group('AuthInterceptor', () { test( - 'uses the TokenManager passed to the positional constructor, setting the ' - 'Authorization header and user_id query parameter (backwards-compatible ' - 'API)', + 'sets the Authorization header, the auth type, and the user_id query ' + 'parameter', () async { final tokenManager = TokenManager( userId: 'user-123', @@ -193,10 +192,11 @@ void main() { ); test( - 'sends the token manager user id, not the loaded token user id, so a ' - 'manager pointed at another user mid-load is rejected rather than ' - 'silently authenticated as whoever the token belongs to', + 'sends the token manager user id, not the loaded token user id', () async { + // The mismatch is deliberate: a request carrying someone else's token + // is rejected, where deriving `user_id` from the token would make it + // self-consistent and silently act as the token's owner. final slowLoad = Completer(); final tokenManager = TokenManager( userId: 'user-1', @@ -220,10 +220,6 @@ void main() { slowLoad.complete(generateTestUserToken('user-1')); await pending; - // The mismatch is deliberate: `user_id` describes who we believe we - // are, so a request carrying someone else's token is rejected and the - // divergence surfaces. Deriving `user_id` from the token instead would - // make the request self-consistent and silently act as that user. expect(adapter.lastRequest?.queryParameters['user_id'], 'user-2'); expect( adapter.lastRequest?.headers['Authorization'], diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index f4e57afc..80197c2b 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -112,8 +112,7 @@ void main() { }); test( - 'discards a load in flight, rather than caching the token it was told ' - 'to stop using', + 'discards a load in flight', () async { final slowLoad = Completer(); final manager = TokenManager( @@ -168,8 +167,7 @@ void main() { }); test( - 'supports a guest exchange, which is authenticated anonymously before ' - 'its user id and token are known', + 'adopts a user id and token that were not known up front', () async { const serverId = 'guest-abc-guest-123'; @@ -216,8 +214,7 @@ void main() { }); test( - 'discards a load in flight when only the provider changes, so the ' - 'replaced provider cannot cache its token for the same user', + 'discards a load in flight when only the provider changes', () async { final slowLoad = Completer(); final manager = TokenManager( diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index 809cf14a..fed37963 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -23,9 +23,9 @@ void main() { }); test( - 'rejects a raw value claiming a real user, so an anonymous token cannot ' - 'stand in for someone else', + 'rejects a raw value claiming a real user', () { + // An anonymous token must not be able to stand in for someone else. expect( () => UserToken.anonymous(rawValue: generateTestJwt('alice')), throwsArgumentError, @@ -91,9 +91,9 @@ void main() { }); test( - 'throws when the loader returns a token for a different user, which would ' - 'otherwise authenticate every later request as that user', + 'throws when the loader returns a token for a different user', () { + // Caching it would authenticate every later request as that user. final provider = TokenProvider.dynamic( (_) async => generateTestUserToken('someone-else'), ); From b9d19281256e451e98bd853438df65bde9342a28 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 11:58:28 +0200 Subject: [PATCH 10/97] test(llc): keep the JWT builder local to each test file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STYLE_GUIDE asks tests to embrace duplication and stay self-contained, and `test/helpers/` had no precedent in the repo — those three imports were the only cross-test-file imports that existed. Each file carries its own builder again, all three now the honest alg=none one rather than the two that claimed HS256 over a fake signature. token_provider_test keeps a string variant since it feeds `UserToken.anonymous(rawValue:)` directly. Also keep `### 💥 BREAKING CHANGES`, the form already used three times in this changelog. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- .../interceptors/auth_interceptor_test.dart | 32 +++++--- .../stream_core/test/helpers/user_token.dart | 25 ------ .../test/user/token_manager_test.dart | 79 +++++++++++-------- .../test/user/token_provider_test.dart | 31 ++++++-- 5 files changed, 93 insertions(+), 76 deletions(-) delete mode 100644 packages/stream_core/test/helpers/user_token.dart diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 481bd544..de8f5ea1 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -1,6 +1,6 @@ ## Upcoming -### 🛑 Breaking / Removals +### 💥 BREAKING CHANGES - Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `UserToken.anonymousUserId` - Removed the `TokenManager.tokenProvider` setter, use `setTokenProvider` instead diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index 5dece376..36715163 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -4,8 +4,6 @@ import 'dart:convert'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; -import '../../helpers/user_token.dart'; - // A minimal HttpClientAdapter that captures the outgoing RequestOptions and // always responds with an empty successful response. class _CapturingHttpClientAdapter implements HttpClientAdapter { @@ -71,6 +69,20 @@ class _TokenExpiredHttpClientAdapter implements HttpClientAdapter { void close({bool force = false}) {} } +/// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. +UserToken _generateTestUserToken(String userId) { + String b64UrlNoPad(Object jsonObj) { + final bytes = utf8.encode(jsonEncode(jsonObj)); + return base64Url.encode(bytes).replaceAll('=', ''); + } + + final header = {'alg': 'none', 'typ': 'JWT'}; + final payload = {'user_id': userId}; + + // Trailing dot = empty signature, which is what alg=none means. + return UserToken('${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'); +} + void main() { group('AuthInterceptor', () { test( @@ -80,7 +92,7 @@ void main() { final tokenManager = TokenManager( userId: 'user-123', tokenProvider: TokenProvider.static( - generateTestUserToken('user-123'), + _generateTestUserToken('user-123'), ), ); @@ -124,7 +136,7 @@ void main() { tokenManager.setTokenProvider( serverId, - tokenProvider: TokenProvider.static(generateTestUserToken(serverId)), + tokenProvider: TokenProvider.static(_generateTestUserToken(serverId)), ); await dio.get('/test'); @@ -168,7 +180,7 @@ void main() { test( 'sends a restricted anonymous token as the Authorization header', () async { - final restricted = generateTestUserToken(UserToken.anonymousUserId); + final restricted = _generateTestUserToken(UserToken.anonymousUserId); final tokenManager = TokenManager( userId: UserToken.anonymousUserId, tokenProvider: TokenProvider.static( @@ -212,12 +224,12 @@ void main() { await pumpEventQueue(); // The load is already running for user-1 when the manager moves on. - final userTwoToken = generateTestUserToken('user-2'); + final userTwoToken = _generateTestUserToken('user-2'); tokenManager.setTokenProvider( 'user-2', tokenProvider: TokenProvider.static(userTwoToken), ); - slowLoad.complete(generateTestUserToken('user-1')); + slowLoad.complete(_generateTestUserToken('user-1')); await pending; expect(adapter.lastRequest?.queryParameters['user_id'], 'user-2'); @@ -235,7 +247,7 @@ void main() { () async { final tokenManager = TokenManager( userId: 'guest-1', - tokenProvider: TokenProvider.static(generateTestUserToken('guest-1')), + tokenProvider: TokenProvider.static(_generateTestUserToken('guest-1')), ); final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); @@ -266,7 +278,7 @@ void main() { final tokenManager = TokenManager( userId: 'requested-id', tokenProvider: TokenProvider.dynamic( - (_) async => generateTestUserToken('requested-id'), + (_) async => _generateTestUserToken('requested-id'), ), ); @@ -276,7 +288,7 @@ void main() { tokenManager.setTokenProvider( 'server-assigned-id', tokenProvider: TokenProvider.static( - generateTestUserToken('server-assigned-id'), + _generateTestUserToken('server-assigned-id'), ), ); }, diff --git a/packages/stream_core/test/helpers/user_token.dart b/packages/stream_core/test/helpers/user_token.dart deleted file mode 100644 index fb32afd6..00000000 --- a/packages/stream_core/test/helpers/user_token.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'dart:convert'; - -import 'package:stream_core/stream_core.dart'; - -/// Builds an unsigned JWT carrying [userId] as its 'user_id' claim. -/// -/// Sufficient for [UserToken]'s unverified parsing — nothing in these tests -/// checks a signature. -String generateTestJwt(String userId) { - String b64UrlNoPad(Object jsonObj) { - final bytes = utf8.encode(jsonEncode(jsonObj)); - return base64Url.encode(bytes).replaceAll('=', ''); - } - - final header = {'alg': 'none', 'typ': 'JWT'}; - final payload = {'user_id': userId}; - - // Trailing dot = empty signature, which is what alg=none means. - return '${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'; -} - -/// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. -UserToken generateTestUserToken(String userId) { - return UserToken(generateTestJwt(userId)); -} diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 80197c2b..d3058b9c 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -1,10 +1,9 @@ import 'dart:async'; +import 'dart:convert'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; -import '../helpers/user_token.dart'; - /// A token provider that counts loads and delegates to a configurable loader. class _CountingProvider implements TokenProvider { _CountingProvider(this._load); @@ -21,11 +20,25 @@ class _CountingProvider implements TokenProvider { } } +/// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. +UserToken _generateTestUserToken(String userId) { + String b64UrlNoPad(Object jsonObj) { + final bytes = utf8.encode(jsonEncode(jsonObj)); + return base64Url.encode(bytes).replaceAll('=', ''); + } + + final header = {'alg': 'none', 'typ': 'JWT'}; + final payload = {'user_id': userId}; + + // Trailing dot = empty signature, which is what alg=none means. + return UserToken('${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'); +} + void main() { group('TokenManager', () { group('getToken', () { test('loads from the provider and caches the result', () async { - final provider = _CountingProvider((_) async => generateTestUserToken('token-1')); + final provider = _CountingProvider((_) async => _generateTestUserToken('token-1')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, @@ -34,17 +47,17 @@ void main() { final first = await manager.getToken(); final second = await manager.getToken(); - expect(first, generateTestUserToken('token-1')); - expect(second, generateTestUserToken('token-1')); + expect(first, _generateTestUserToken('token-1')); + expect(second, _generateTestUserToken('token-1')); expect(provider.loadCount, 1); - expect(manager.peekToken(), generateTestUserToken('token-1')); + expect(manager.peekToken(), _generateTestUserToken('token-1')); }); test('passes the manager userId to the provider', () async { String? requestedUserId; final provider = _CountingProvider((userId) async { requestedUserId = userId; - return generateTestUserToken('token-1'); + return _generateTestUserToken('token-1'); }); final manager = TokenManager( userId: 'user-1', @@ -65,10 +78,10 @@ void main() { ); final futures = [manager.getToken(), manager.getToken()]; - completer.complete(generateTestUserToken('token-1')); + completer.complete(_generateTestUserToken('token-1')); final tokens = await Future.wait(futures); - expect(tokens, everyElement(generateTestUserToken('token-1'))); + expect(tokens, everyElement(_generateTestUserToken('token-1'))); expect(provider.loadCount, 1); }); @@ -77,7 +90,7 @@ void main() { final provider = _CountingProvider((_) async { attempts++; if (attempts == 1) throw StateError('load failed'); - return generateTestUserToken('token-2'); + return _generateTestUserToken('token-2'); }); final manager = TokenManager( userId: 'user-1', @@ -88,7 +101,7 @@ void main() { expect(manager.peekToken(), isNull); final token = await manager.getToken(); - expect(token, generateTestUserToken('token-2')); + expect(token, _generateTestUserToken('token-2')); expect(provider.loadCount, 2); }); }); @@ -96,18 +109,18 @@ void main() { group('expireToken', () { test('clears the cache and forces a reload', () async { var version = 0; - final provider = _CountingProvider((_) async => generateTestUserToken('v${++version}')); + final provider = _CountingProvider((_) async => _generateTestUserToken('v${++version}')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, ); - expect(await manager.getToken(), generateTestUserToken('v1')); + expect(await manager.getToken(), _generateTestUserToken('v1')); manager.expireToken(); expect(manager.peekToken(), isNull); - expect(await manager.getToken(), generateTestUserToken('v2')); + expect(await manager.getToken(), _generateTestUserToken('v2')); expect(provider.loadCount, 2); }); @@ -123,7 +136,7 @@ void main() { final pending = manager.getToken(); manager.expireToken(); - slowLoad.complete(generateTestUserToken('user-1')); + slowLoad.complete(_generateTestUserToken('user-1')); await pending; expect(manager.peekToken(), isNull); @@ -135,14 +148,14 @@ void main() { test('points the manager at another user', () async { final manager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), + tokenProvider: TokenProvider.static(_generateTestUserToken('user-1')), ); expect((await manager.getToken()).userId, 'user-1'); manager.setTokenProvider( 'user-2', - tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), + tokenProvider: TokenProvider.static(_generateTestUserToken('user-2')), ); expect(manager.userId, 'user-2'); @@ -152,15 +165,15 @@ void main() { test('expires the token cached for the previous user', () async { final manager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), + tokenProvider: TokenProvider.static(_generateTestUserToken('user-1')), ); await manager.getToken(); - expect(manager.peekToken(), generateTestUserToken('user-1')); + expect(manager.peekToken(), _generateTestUserToken('user-1')); manager.setTokenProvider( 'user-2', - tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), + tokenProvider: TokenProvider.static(_generateTestUserToken('user-2')), ); expect(manager.peekToken(), isNull); @@ -182,7 +195,7 @@ void main() { manager.setTokenProvider( serverId, - tokenProvider: TokenProvider.static(generateTestUserToken(serverId)), + tokenProvider: TokenProvider.static(_generateTestUserToken(serverId)), ); final guest = await manager.getToken(); @@ -203,9 +216,9 @@ void main() { manager.setTokenProvider( 'user-2', - tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), + tokenProvider: TokenProvider.static(_generateTestUserToken('user-2')), ); - slowLoad.complete(generateTestUserToken('user-1')); + slowLoad.complete(_generateTestUserToken('user-1')); await pending; // user-1's token must not be waiting in the cache for user-2 to send. @@ -228,9 +241,9 @@ void main() { // replaced provider's token through. manager.setTokenProvider( 'user-1', - tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), + tokenProvider: TokenProvider.static(_generateTestUserToken('user-1')), ); - slowLoad.complete(generateTestUserToken('user-1')); + slowLoad.complete(_generateTestUserToken('user-1')); await pending; expect(manager.peekToken(), isNull); @@ -240,14 +253,14 @@ void main() { test('usesStaticProvider reflects the new provider', () { final manager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), + tokenProvider: TokenProvider.static(_generateTestUserToken('user-1')), ); expect(manager.usesStaticProvider, isTrue); manager.setTokenProvider( 'user-1', - tokenProvider: _CountingProvider((_) async => generateTestUserToken('user-1')), + tokenProvider: _CountingProvider((_) async => _generateTestUserToken('user-1')), ); expect(manager.usesStaticProvider, isFalse); @@ -258,11 +271,11 @@ void main() { test('reflects the provider type', () { final staticManager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), + tokenProvider: TokenProvider.static(_generateTestUserToken('user-1')), ); final dynamicManager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => generateTestUserToken('t')), + tokenProvider: _CountingProvider((_) async => _generateTestUserToken('t')), ); expect(staticManager.usesStaticProvider, isTrue); @@ -274,7 +287,7 @@ void main() { test('fires once per load with the loaded token', () async { final updates = []; var version = 0; - final provider = _CountingProvider((_) async => generateTestUserToken('v${++version}')); + final provider = _CountingProvider((_) async => _generateTestUserToken('v${++version}')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, @@ -287,14 +300,14 @@ void main() { manager.expireToken(); await manager.getToken(); - expect(updates, [generateTestUserToken('v1'), generateTestUserToken('v2')]); + expect(updates, [_generateTestUserToken('v1'), _generateTestUserToken('v2')]); }); test('is invoked before the token is returned', () async { UserToken? notified; final manager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => generateTestUserToken('token-1')), + tokenProvider: _CountingProvider((_) async => _generateTestUserToken('token-1')), onTokenUpdated: (token) => notified = token, ); @@ -308,7 +321,7 @@ void main() { Future? reentrantCall; manager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => generateTestUserToken('token-1')), + tokenProvider: _CountingProvider((_) async => _generateTestUserToken('token-1')), onTokenUpdated: (_) { reentrantCall = manager.getToken(); }, diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index fed37963..85724c47 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -1,7 +1,24 @@ +import 'dart:convert'; + import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; -import '../helpers/user_token.dart'; +/// Builds an unsigned JWT carrying [userId] as its 'user_id' claim. +String _generateTestJwt(String userId) { + String b64UrlNoPad(Object jsonObj) { + final bytes = utf8.encode(jsonEncode(jsonObj)); + return base64Url.encode(bytes).replaceAll('=', ''); + } + + final header = {'alg': 'none', 'typ': 'JWT'}; + final payload = {'user_id': userId}; + + // Trailing dot = empty signature, which is what alg=none means. + return '${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'; +} + +/// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. +UserToken _generateTestUserToken(String userId) => UserToken(_generateTestJwt(userId)); void main() { group('UserToken.anonymous', () { @@ -14,7 +31,7 @@ void main() { }); test('carries an optional raw value for restricted access', () { - final restricted = generateTestJwt(UserToken.anonymousUserId); + final restricted = _generateTestJwt(UserToken.anonymousUserId); final token = UserToken.anonymous(rawValue: restricted); expect(token.userId, '!anon'); @@ -27,7 +44,7 @@ void main() { () { // An anonymous token must not be able to stand in for someone else. expect( - () => UserToken.anonymous(rawValue: generateTestJwt('alice')), + () => UserToken.anonymous(rawValue: _generateTestJwt('alice')), throwsArgumentError, ); }, @@ -51,14 +68,14 @@ void main() { group('StaticTokenProvider', () { test('returns the token when the user ID matches', () async { - final token = generateTestUserToken('user-1'); + final token = _generateTestUserToken('user-1'); final provider = TokenProvider.static(token); expect(await provider.loadToken('user-1'), token); }); test('throws when the user ID does not match', () { - final token = generateTestUserToken('user-1'); + final token = _generateTestUserToken('user-1'); final provider = TokenProvider.static(token); expect(() => provider.loadToken('user-2'), throwsArgumentError); @@ -68,7 +85,7 @@ void main() { group('DynamicTokenProvider', () { test('returns JWT tokens from the loader', () async { final provider = TokenProvider.dynamic( - (userId) async => generateTestUserToken(userId), + (userId) async => _generateTestUserToken(userId), ); final token = await provider.loadToken('user-1'); @@ -95,7 +112,7 @@ void main() { () { // Caching it would authenticate every later request as that user. final provider = TokenProvider.dynamic( - (_) async => generateTestUserToken('someone-else'), + (_) async => _generateTestUserToken('someone-else'), ); expect(() => provider.loadToken('user-1'), throwsArgumentError); From 8f834a4989ea25757b9d14b4024686f958be0c48 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 12:08:24 +0200 Subject: [PATCH 11/97] test(llc): share one JWT builder, and document the pattern Restores test/helpers/user_token.dart as the single definition for the three token test files, and amends STYLE_GUIDE's "Make each test entirely self-contained" to say what it already meant: the rule is about shared state, not pure construction, so a stateless fixture builder may be shared. Written down rather than improvised, because the repo had no precedent for cross-test-file imports and the guide read as forbidding them. The motivating evidence is in the amendment: of the three copies this replaces, two claimed alg HS256 while attaching something that was not a signature. Co-Authored-By: Claude Opus 5 (1M context) --- STYLE_GUIDE.md | 9 +++ .../interceptors/auth_interceptor_test.dart | 32 +++----- .../stream_core/test/helpers/user_token.dart | 25 ++++++ .../test/user/token_manager_test.dart | 79 ++++++++----------- .../test/user/token_provider_test.dart | 31 ++------ 5 files changed, 84 insertions(+), 92 deletions(-) create mode 100644 packages/stream_core/test/helpers/user_token.dart diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 4efd5389..8bb50c0c 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -842,6 +842,15 @@ debugging, and refactoring significantly harder. Instead of `setUp`, use local helper functions called inside each test block. For cleanup, prefer `addTearDown` over the global `tearDown` callback. +The rule targets shared state, not pure construction. A deterministic fixture +builder that holds no state — a signed token, an encoded payload, a fixed +timestamp — may live under `test/helpers/` and be imported by several test files, +so one correct definition serves all of them. Copies of a fixture builder tend to +drift: `stream_core` carried three JWT builders, two of which claimed `alg: HS256` +while attaching something that was not a signature. Anything that holds state +between tests, or that arranges a scenario rather than building a value, stays +local to the test file. + ### Prefer more test files, avoid long test files Organize tests into smaller files grouped by feature, widget, or behavior. Split diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index 36715163..5dece376 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -4,6 +4,8 @@ import 'dart:convert'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; +import '../../helpers/user_token.dart'; + // A minimal HttpClientAdapter that captures the outgoing RequestOptions and // always responds with an empty successful response. class _CapturingHttpClientAdapter implements HttpClientAdapter { @@ -69,20 +71,6 @@ class _TokenExpiredHttpClientAdapter implements HttpClientAdapter { void close({bool force = false}) {} } -/// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. -UserToken _generateTestUserToken(String userId) { - String b64UrlNoPad(Object jsonObj) { - final bytes = utf8.encode(jsonEncode(jsonObj)); - return base64Url.encode(bytes).replaceAll('=', ''); - } - - final header = {'alg': 'none', 'typ': 'JWT'}; - final payload = {'user_id': userId}; - - // Trailing dot = empty signature, which is what alg=none means. - return UserToken('${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'); -} - void main() { group('AuthInterceptor', () { test( @@ -92,7 +80,7 @@ void main() { final tokenManager = TokenManager( userId: 'user-123', tokenProvider: TokenProvider.static( - _generateTestUserToken('user-123'), + generateTestUserToken('user-123'), ), ); @@ -136,7 +124,7 @@ void main() { tokenManager.setTokenProvider( serverId, - tokenProvider: TokenProvider.static(_generateTestUserToken(serverId)), + tokenProvider: TokenProvider.static(generateTestUserToken(serverId)), ); await dio.get('/test'); @@ -180,7 +168,7 @@ void main() { test( 'sends a restricted anonymous token as the Authorization header', () async { - final restricted = _generateTestUserToken(UserToken.anonymousUserId); + final restricted = generateTestUserToken(UserToken.anonymousUserId); final tokenManager = TokenManager( userId: UserToken.anonymousUserId, tokenProvider: TokenProvider.static( @@ -224,12 +212,12 @@ void main() { await pumpEventQueue(); // The load is already running for user-1 when the manager moves on. - final userTwoToken = _generateTestUserToken('user-2'); + final userTwoToken = generateTestUserToken('user-2'); tokenManager.setTokenProvider( 'user-2', tokenProvider: TokenProvider.static(userTwoToken), ); - slowLoad.complete(_generateTestUserToken('user-1')); + slowLoad.complete(generateTestUserToken('user-1')); await pending; expect(adapter.lastRequest?.queryParameters['user_id'], 'user-2'); @@ -247,7 +235,7 @@ void main() { () async { final tokenManager = TokenManager( userId: 'guest-1', - tokenProvider: TokenProvider.static(_generateTestUserToken('guest-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('guest-1')), ); final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); @@ -278,7 +266,7 @@ void main() { final tokenManager = TokenManager( userId: 'requested-id', tokenProvider: TokenProvider.dynamic( - (_) async => _generateTestUserToken('requested-id'), + (_) async => generateTestUserToken('requested-id'), ), ); @@ -288,7 +276,7 @@ void main() { tokenManager.setTokenProvider( 'server-assigned-id', tokenProvider: TokenProvider.static( - _generateTestUserToken('server-assigned-id'), + generateTestUserToken('server-assigned-id'), ), ); }, diff --git a/packages/stream_core/test/helpers/user_token.dart b/packages/stream_core/test/helpers/user_token.dart new file mode 100644 index 00000000..fb32afd6 --- /dev/null +++ b/packages/stream_core/test/helpers/user_token.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; + +import 'package:stream_core/stream_core.dart'; + +/// Builds an unsigned JWT carrying [userId] as its 'user_id' claim. +/// +/// Sufficient for [UserToken]'s unverified parsing — nothing in these tests +/// checks a signature. +String generateTestJwt(String userId) { + String b64UrlNoPad(Object jsonObj) { + final bytes = utf8.encode(jsonEncode(jsonObj)); + return base64Url.encode(bytes).replaceAll('=', ''); + } + + final header = {'alg': 'none', 'typ': 'JWT'}; + final payload = {'user_id': userId}; + + // Trailing dot = empty signature, which is what alg=none means. + return '${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'; +} + +/// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. +UserToken generateTestUserToken(String userId) { + return UserToken(generateTestJwt(userId)); +} diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index d3058b9c..80197c2b 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -1,9 +1,10 @@ import 'dart:async'; -import 'dart:convert'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; +import '../helpers/user_token.dart'; + /// A token provider that counts loads and delegates to a configurable loader. class _CountingProvider implements TokenProvider { _CountingProvider(this._load); @@ -20,25 +21,11 @@ class _CountingProvider implements TokenProvider { } } -/// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. -UserToken _generateTestUserToken(String userId) { - String b64UrlNoPad(Object jsonObj) { - final bytes = utf8.encode(jsonEncode(jsonObj)); - return base64Url.encode(bytes).replaceAll('=', ''); - } - - final header = {'alg': 'none', 'typ': 'JWT'}; - final payload = {'user_id': userId}; - - // Trailing dot = empty signature, which is what alg=none means. - return UserToken('${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'); -} - void main() { group('TokenManager', () { group('getToken', () { test('loads from the provider and caches the result', () async { - final provider = _CountingProvider((_) async => _generateTestUserToken('token-1')); + final provider = _CountingProvider((_) async => generateTestUserToken('token-1')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, @@ -47,17 +34,17 @@ void main() { final first = await manager.getToken(); final second = await manager.getToken(); - expect(first, _generateTestUserToken('token-1')); - expect(second, _generateTestUserToken('token-1')); + expect(first, generateTestUserToken('token-1')); + expect(second, generateTestUserToken('token-1')); expect(provider.loadCount, 1); - expect(manager.peekToken(), _generateTestUserToken('token-1')); + expect(manager.peekToken(), generateTestUserToken('token-1')); }); test('passes the manager userId to the provider', () async { String? requestedUserId; final provider = _CountingProvider((userId) async { requestedUserId = userId; - return _generateTestUserToken('token-1'); + return generateTestUserToken('token-1'); }); final manager = TokenManager( userId: 'user-1', @@ -78,10 +65,10 @@ void main() { ); final futures = [manager.getToken(), manager.getToken()]; - completer.complete(_generateTestUserToken('token-1')); + completer.complete(generateTestUserToken('token-1')); final tokens = await Future.wait(futures); - expect(tokens, everyElement(_generateTestUserToken('token-1'))); + expect(tokens, everyElement(generateTestUserToken('token-1'))); expect(provider.loadCount, 1); }); @@ -90,7 +77,7 @@ void main() { final provider = _CountingProvider((_) async { attempts++; if (attempts == 1) throw StateError('load failed'); - return _generateTestUserToken('token-2'); + return generateTestUserToken('token-2'); }); final manager = TokenManager( userId: 'user-1', @@ -101,7 +88,7 @@ void main() { expect(manager.peekToken(), isNull); final token = await manager.getToken(); - expect(token, _generateTestUserToken('token-2')); + expect(token, generateTestUserToken('token-2')); expect(provider.loadCount, 2); }); }); @@ -109,18 +96,18 @@ void main() { group('expireToken', () { test('clears the cache and forces a reload', () async { var version = 0; - final provider = _CountingProvider((_) async => _generateTestUserToken('v${++version}')); + final provider = _CountingProvider((_) async => generateTestUserToken('v${++version}')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, ); - expect(await manager.getToken(), _generateTestUserToken('v1')); + expect(await manager.getToken(), generateTestUserToken('v1')); manager.expireToken(); expect(manager.peekToken(), isNull); - expect(await manager.getToken(), _generateTestUserToken('v2')); + expect(await manager.getToken(), generateTestUserToken('v2')); expect(provider.loadCount, 2); }); @@ -136,7 +123,7 @@ void main() { final pending = manager.getToken(); manager.expireToken(); - slowLoad.complete(_generateTestUserToken('user-1')); + slowLoad.complete(generateTestUserToken('user-1')); await pending; expect(manager.peekToken(), isNull); @@ -148,14 +135,14 @@ void main() { test('points the manager at another user', () async { final manager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(_generateTestUserToken('user-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), ); expect((await manager.getToken()).userId, 'user-1'); manager.setTokenProvider( 'user-2', - tokenProvider: TokenProvider.static(_generateTestUserToken('user-2')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), ); expect(manager.userId, 'user-2'); @@ -165,15 +152,15 @@ void main() { test('expires the token cached for the previous user', () async { final manager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(_generateTestUserToken('user-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), ); await manager.getToken(); - expect(manager.peekToken(), _generateTestUserToken('user-1')); + expect(manager.peekToken(), generateTestUserToken('user-1')); manager.setTokenProvider( 'user-2', - tokenProvider: TokenProvider.static(_generateTestUserToken('user-2')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), ); expect(manager.peekToken(), isNull); @@ -195,7 +182,7 @@ void main() { manager.setTokenProvider( serverId, - tokenProvider: TokenProvider.static(_generateTestUserToken(serverId)), + tokenProvider: TokenProvider.static(generateTestUserToken(serverId)), ); final guest = await manager.getToken(); @@ -216,9 +203,9 @@ void main() { manager.setTokenProvider( 'user-2', - tokenProvider: TokenProvider.static(_generateTestUserToken('user-2')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), ); - slowLoad.complete(_generateTestUserToken('user-1')); + slowLoad.complete(generateTestUserToken('user-1')); await pending; // user-1's token must not be waiting in the cache for user-2 to send. @@ -241,9 +228,9 @@ void main() { // replaced provider's token through. manager.setTokenProvider( 'user-1', - tokenProvider: TokenProvider.static(_generateTestUserToken('user-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), ); - slowLoad.complete(_generateTestUserToken('user-1')); + slowLoad.complete(generateTestUserToken('user-1')); await pending; expect(manager.peekToken(), isNull); @@ -253,14 +240,14 @@ void main() { test('usesStaticProvider reflects the new provider', () { final manager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(_generateTestUserToken('user-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), ); expect(manager.usesStaticProvider, isTrue); manager.setTokenProvider( 'user-1', - tokenProvider: _CountingProvider((_) async => _generateTestUserToken('user-1')), + tokenProvider: _CountingProvider((_) async => generateTestUserToken('user-1')), ); expect(manager.usesStaticProvider, isFalse); @@ -271,11 +258,11 @@ void main() { test('reflects the provider type', () { final staticManager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(_generateTestUserToken('user-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), ); final dynamicManager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => _generateTestUserToken('t')), + tokenProvider: _CountingProvider((_) async => generateTestUserToken('t')), ); expect(staticManager.usesStaticProvider, isTrue); @@ -287,7 +274,7 @@ void main() { test('fires once per load with the loaded token', () async { final updates = []; var version = 0; - final provider = _CountingProvider((_) async => _generateTestUserToken('v${++version}')); + final provider = _CountingProvider((_) async => generateTestUserToken('v${++version}')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, @@ -300,14 +287,14 @@ void main() { manager.expireToken(); await manager.getToken(); - expect(updates, [_generateTestUserToken('v1'), _generateTestUserToken('v2')]); + expect(updates, [generateTestUserToken('v1'), generateTestUserToken('v2')]); }); test('is invoked before the token is returned', () async { UserToken? notified; final manager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => _generateTestUserToken('token-1')), + tokenProvider: _CountingProvider((_) async => generateTestUserToken('token-1')), onTokenUpdated: (token) => notified = token, ); @@ -321,7 +308,7 @@ void main() { Future? reentrantCall; manager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => _generateTestUserToken('token-1')), + tokenProvider: _CountingProvider((_) async => generateTestUserToken('token-1')), onTokenUpdated: (_) { reentrantCall = manager.getToken(); }, diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index 85724c47..fed37963 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -1,24 +1,7 @@ -import 'dart:convert'; - import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; -/// Builds an unsigned JWT carrying [userId] as its 'user_id' claim. -String _generateTestJwt(String userId) { - String b64UrlNoPad(Object jsonObj) { - final bytes = utf8.encode(jsonEncode(jsonObj)); - return base64Url.encode(bytes).replaceAll('=', ''); - } - - final header = {'alg': 'none', 'typ': 'JWT'}; - final payload = {'user_id': userId}; - - // Trailing dot = empty signature, which is what alg=none means. - return '${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'; -} - -/// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. -UserToken _generateTestUserToken(String userId) => UserToken(_generateTestJwt(userId)); +import '../helpers/user_token.dart'; void main() { group('UserToken.anonymous', () { @@ -31,7 +14,7 @@ void main() { }); test('carries an optional raw value for restricted access', () { - final restricted = _generateTestJwt(UserToken.anonymousUserId); + final restricted = generateTestJwt(UserToken.anonymousUserId); final token = UserToken.anonymous(rawValue: restricted); expect(token.userId, '!anon'); @@ -44,7 +27,7 @@ void main() { () { // An anonymous token must not be able to stand in for someone else. expect( - () => UserToken.anonymous(rawValue: _generateTestJwt('alice')), + () => UserToken.anonymous(rawValue: generateTestJwt('alice')), throwsArgumentError, ); }, @@ -68,14 +51,14 @@ void main() { group('StaticTokenProvider', () { test('returns the token when the user ID matches', () async { - final token = _generateTestUserToken('user-1'); + final token = generateTestUserToken('user-1'); final provider = TokenProvider.static(token); expect(await provider.loadToken('user-1'), token); }); test('throws when the user ID does not match', () { - final token = _generateTestUserToken('user-1'); + final token = generateTestUserToken('user-1'); final provider = TokenProvider.static(token); expect(() => provider.loadToken('user-2'), throwsArgumentError); @@ -85,7 +68,7 @@ void main() { group('DynamicTokenProvider', () { test('returns JWT tokens from the loader', () async { final provider = TokenProvider.dynamic( - (userId) async => _generateTestUserToken(userId), + (userId) async => generateTestUserToken(userId), ); final token = await provider.loadToken('user-1'); @@ -112,7 +95,7 @@ void main() { () { // Caching it would authenticate every later request as that user. final provider = TokenProvider.dynamic( - (_) async => _generateTestUserToken('someone-else'), + (_) async => generateTestUserToken('someone-else'), ); expect(() => provider.loadToken('user-1'), throwsArgumentError); From 500beeed1cf505d75fa72734d4fe0926be93cdc5 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 12:09:07 +0200 Subject: [PATCH 12/97] docs(repo): drop the incident detail from the test-fixture rule A style guide outlives the change that prompted it, so the rule keeps the general reason and the specific case stays in the PR that found it. Co-Authored-By: Claude Opus 5 (1M context) --- STYLE_GUIDE.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 8bb50c0c..e56ff027 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -846,10 +846,9 @@ The rule targets shared state, not pure construction. A deterministic fixture builder that holds no state — a signed token, an encoded payload, a fixed timestamp — may live under `test/helpers/` and be imported by several test files, so one correct definition serves all of them. Copies of a fixture builder tend to -drift: `stream_core` carried three JWT builders, two of which claimed `alg: HS256` -while attaching something that was not a signature. Anything that holds state -between tests, or that arranges a scenario rather than building a value, stays -local to the test file. +drift, and a subtly wrong fixture is harder to spot than a shared one. Anything +that holds state between tests, or that arranges a scenario rather than building a +value, stays local to the test file. ### Prefer more test files, avoid long test files From 5215424ac2d2c216e7ee12d1e5739685901cafd2 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 12:13:38 +0200 Subject: [PATCH 13/97] refactor(llc): restore the original token mismatch messages Shortening them was scope creep: the ask was only that tests stop matching the message text. `ArgumentError`'s two-arg form sets `name` while leaving the message verbatim, so the test keeps its structural handle and the wording is unchanged. It also avoids `ArgumentError.value` repeating the value after the message. The only wording change left is the argument order in `StaticTokenProvider`, which review asked for so both providers read the same way. Co-Authored-By: Claude Opus 5 (1M context) --- .../stream_core/lib/src/user/token_provider.dart | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index 8525412e..8aa4d641 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -74,10 +74,9 @@ class StaticTokenProvider implements TokenProvider { Future loadToken(String userId) async { // Validate that the token's user_id matches the requested userId if (_rawToken.userId != userId) { - throw ArgumentError.value( - _rawToken.userId, + throw ArgumentError( + 'User ID mismatch: expected "$userId", got "${_rawToken.userId}"', 'userId', - 'Expected "$userId"', ); } @@ -113,19 +112,17 @@ class DynamicTokenProvider implements TokenProvider { // Checked before the user id: an anonymous token carries a user id that can // never match, so it would otherwise be reported as the wrong user. if (token.authType != AuthType.jwt) { - throw ArgumentError.value( - token.authType.headerValue, + throw ArgumentError( + 'Token type mismatch: expected ${AuthType.jwt.headerValue}, got ${token.authType.headerValue}', 'authType', - 'Expected ${AuthType.jwt.headerValue}', ); } // Validate that the token's user_id matches the requested userId if (token.userId != userId) { - throw ArgumentError.value( - token.userId, + throw ArgumentError( + 'User ID mismatch: expected "$userId", got "${token.userId}"', 'userId', - 'Expected "$userId"', ); } From df2d4f941fc21c78e6d181d6e47859ab73dc8307 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 12:15:34 +0200 Subject: [PATCH 14/97] refactor(llc): drop the ArgumentError name argument Plain `ArgumentError(message)`, as before. The check-order test loses its structural handle and goes back to `throwsArgumentError`; ordering the type check first still gives a human a better message, and the comment records why, but nothing asserts it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/lib/src/user/token_provider.dart | 3 --- packages/stream_core/test/user/token_provider_test.dart | 7 +------ 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index 8aa4d641..4d5b6d27 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -76,7 +76,6 @@ class StaticTokenProvider implements TokenProvider { if (_rawToken.userId != userId) { throw ArgumentError( 'User ID mismatch: expected "$userId", got "${_rawToken.userId}"', - 'userId', ); } @@ -114,7 +113,6 @@ class DynamicTokenProvider implements TokenProvider { if (token.authType != AuthType.jwt) { throw ArgumentError( 'Token type mismatch: expected ${AuthType.jwt.headerValue}, got ${token.authType.headerValue}', - 'authType', ); } @@ -122,7 +120,6 @@ class DynamicTokenProvider implements TokenProvider { if (token.userId != userId) { throw ArgumentError( 'User ID mismatch: expected "$userId", got "${token.userId}"', - 'userId', ); } diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index fed37963..21079e45 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -82,12 +82,7 @@ void main() { (_) async => UserToken.anonymous(), ); - // Reported as the wrong type, not the wrong user: an anonymous token - // also carries a user id that cannot match the one requested. - expect( - () => provider.loadToken('user-1'), - throwsA(isArgumentError.having((it) => it.name, 'name', 'authType')), - ); + expect(() => provider.loadToken('user-1'), throwsArgumentError); }); test( From 6c5eeca9f46c9ebf95729df427f69ff83cc61376 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 12:16:45 +0200 Subject: [PATCH 15/97] docs(llc): drop an inaccurate comment on the token type check It claimed an anonymous token's user id "can never match" the requested one. It can: an anonymous TokenManager requests `!anon`, which is exactly what an anonymous token carries. Checking the type before the identity needs no comment anyway, so restore the file's existing one. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/lib/src/user/token_provider.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index 4d5b6d27..e8dfa4d3 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -108,8 +108,7 @@ class DynamicTokenProvider implements TokenProvider { Future loadToken(String userId) async { final token = await _loader.call(userId); - // Checked before the user id: an anonymous token carries a user id that can - // never match, so it would otherwise be reported as the wrong user. + // Validate that the returned token is a JWT token if (token.authType != AuthType.jwt) { throw ArgumentError( 'Token type mismatch: expected ${AuthType.jwt.headerValue}, got ${token.authType.headerValue}', From a6bb26c16c5c2a7ffc732db7ee6b8f08eeb9cde9 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 12:18:00 +0200 Subject: [PATCH 16/97] test(llc): reach the type check now that the user id is validated first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the user id checked first, the non-JWT test was requesting "user-1" for an anonymous token, so it threw on the id check and the type check had no coverage at all. Requesting `!anon` — the id an anonymous token carries — passes the id check and reaches the type check. Verified by deleting the type check: the test now fails, where before it still passed. Co-Authored-By: Claude Opus 5 (1M context) --- .../stream_core/lib/src/user/token_provider.dart | 12 ++++++------ .../stream_core/test/user/token_provider_test.dart | 7 ++++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index e8dfa4d3..53bb63ac 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -108,17 +108,17 @@ class DynamicTokenProvider implements TokenProvider { Future loadToken(String userId) async { final token = await _loader.call(userId); - // Validate that the returned token is a JWT token - if (token.authType != AuthType.jwt) { + // Validate that the token's user_id matches the requested userId + if (token.userId != userId) { throw ArgumentError( - 'Token type mismatch: expected ${AuthType.jwt.headerValue}, got ${token.authType.headerValue}', + 'User ID mismatch: expected "$userId", got "${token.userId}"', ); } - // Validate that the token's user_id matches the requested userId - if (token.userId != userId) { + // Validate that the returned token is a JWT token + if (token.authType != AuthType.jwt) { throw ArgumentError( - 'User ID mismatch: expected "$userId", got "${token.userId}"', + 'Token type mismatch: expected ${AuthType.jwt.headerValue}, got ${token.authType.headerValue}', ); } diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index 21079e45..3e068f4c 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -82,7 +82,12 @@ void main() { (_) async => UserToken.anonymous(), ); - expect(() => provider.loadToken('user-1'), throwsArgumentError); + // Requested id matches the one an anonymous token carries, so this + // reaches the type check rather than failing the user id check first. + expect( + () => provider.loadToken(UserToken.anonymousUserId), + throwsArgumentError, + ); }); test( From 1d50f2c53b0ace0dfd6b689c853fb95b0029daea Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 13:02:17 +0200 Subject: [PATCH 17/97] feat(llc): let a TokenManager exist before its user does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TokenManager` required a user id and a provider up front, so it could not represent a client that is constructed before anyone signs in — the shape Chat needs, where `connectUser` arrives after the client, and where `disconnectUser` has to return the manager to having no user at all. The user and the provider now live in one nullable field rather than two, so they cannot disagree: a user without a provider cannot load, and a provider without a user has nothing to load for. `userId` is therefore nullable, and `getToken` fails with a `ClientException` while no identity is configured. Adds `TokenManager.unconfigured` for that starting state and `reset` for returning to it, distinct from `expireToken`, which keeps the identity and only drops the cached token. Moves `anonymousUserId` from `UserToken` to `User`: it is a user id, every call site passes it where one is expected, and `User.anonymous` was hardcoding the literal rather than sharing the constant. `User` now asserts that an anonymous user carries it, matching the validation `UserToken.anonymous` already performs on the claim. `AuthInterceptor` sources the `user_id` query parameter from the loaded token instead of the manager, so the parameter and the token always describe the same user and the server cannot reject the pair as a mismatch. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 10 ++- .../api/interceptors/auth_interceptor.dart | 2 +- .../lib/src/user/token_manager.dart | 66 ++++++++++++----- packages/stream_core/lib/src/user/user.dart | 14 +++- .../stream_core/lib/src/user/user_token.dart | 17 ++--- .../interceptors/auth_interceptor_test.dart | 30 ++++---- .../test/user/token_manager_test.dart | 74 ++++++++++++++++++- .../test/user/token_provider_test.dart | 4 +- packages/stream_core/test/user/user_test.dart | 44 +++++++++++ 9 files changed, 212 insertions(+), 49 deletions(-) create mode 100644 packages/stream_core/test/user/user_test.dart diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index de8f5ea1..baeeb305 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -2,15 +2,18 @@ ### 💥 BREAKING CHANGES -- Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `UserToken.anonymousUserId` +- Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `User.anonymousUserId` - Removed the `TokenManager.tokenProvider` setter, use `setTokenProvider` instead +- `TokenManager.userId` is now nullable, and is `null` until an identity is configured ### ✨ Features - Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token - Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load -- Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access, provided its `user_id` claim is `UserToken.anonymousUserId` -- Added `UserToken.anonymousUserId`, the user id used for anonymous authentication +- Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access, provided its `user_id` claim is `User.anonymousUserId` +- Added `User.anonymousUserId`, the id every anonymous user has +- Added `TokenManager.unconfigured`, for a client that exists before its user does +- Added `TokenManager.reset`, which drops the configured identity and its cached token - Added `teams` field to `User` class ### 🐛 Bug Fixes @@ -22,6 +25,7 @@ ### 🔄 Changed - Raised the minimum Dart SDK to `^3.12.0` +- `User` now asserts that a user of type `UserType.anonymous` carries `User.anonymousUserId` as its id ## 0.4.0 diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index ea7a0ed3..f810f44e 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -23,7 +23,7 @@ class AuthInterceptor extends QueuedInterceptor { try { final token = await _tokenManager.getToken(); - options.queryParameters['user_id'] = _tokenManager.userId; + options.queryParameters['user_id'] = token.userId; options.headers['Authorization'] = token.rawValue; options.headers['stream-auth-type'] = token.authType.headerValue; diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index a06c9e3a..1b1ee075 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -1,5 +1,6 @@ import 'package:synchronized/extension.dart'; +import '../errors/client_exception.dart'; import 'token_provider.dart'; import 'user_token.dart'; @@ -42,20 +43,31 @@ class TokenManager { /// An optional `onTokenUpdated` callback is invoked after every successful /// token load. It is not invoked for callers served from the cache. TokenManager({ - required this._userId, - required this._tokenProvider, + required String userId, + required TokenProvider tokenProvider, this._onTokenUpdated, - }); + }) : _identity = (userId: userId, provider: tokenProvider); - /// The unique identifier of the user whose tokens are managed. + /// Creates a [TokenManager] that manages no user yet. + /// + /// [getToken] fails until [setTokenProvider] supplies one. Distinct from a + /// manager holding an anonymous identity, which is a user that can load a + /// token; this one has no user at all. + TokenManager.unconfigured({this._onTokenUpdated}) : _identity = null; + + // The user being managed and the provider that loads their tokens. + // + // A single field rather than two, so the two can never disagree: a user + // without a provider cannot load, and a provider without a user has nothing + // to load for. `null` means no identity is configured. + ({String userId, TokenProvider provider})? _identity; + + /// The unique identifier of the user whose tokens are managed, or `null` when + /// no identity is configured. /// /// Changes when the manager is pointed at another user with - /// [setTokenProvider]. - String get userId => _userId; - String _userId; - - // The provider used to load tokens when needed. - TokenProvider _tokenProvider; + /// [setTokenProvider], and returns to `null` after [reset]. + String? get userId => _identity?.userId; // Invoked after every successful token load. final OnTokenUpdated? _onTokenUpdated; @@ -74,7 +86,7 @@ class TokenManager { /// ```dart /// // Authenticate anonymously while the real identity is being obtained. /// final manager = TokenManager( - /// userId: UserToken.anonymousUserId, + /// userId: User.anonymousUserId, /// tokenProvider: TokenProvider.static(UserToken.anonymous()), /// ); /// @@ -88,14 +100,24 @@ class TokenManager { String userId, { required TokenProvider tokenProvider, }) { - _userId = userId; - _tokenProvider = tokenProvider; + _identity = (userId: userId, provider: tokenProvider); // The cached token belongs to the previous user and provider, so drop it // and let the next `getToken` call load a fresh one. expireToken(); } + /// Drops the configured identity, returning this manager to the state of + /// [TokenManager.unconfigured]. + /// + /// [getToken] fails until [setTokenProvider] supplies an identity again. Use + /// this when the user is going away for good; to keep the identity and only + /// force a reload, use [expireToken]. + void reset() { + _identity = null; + expireToken(); + } + // The currently cached token, if any. UserToken? _cachedToken; @@ -112,8 +134,9 @@ class TokenManager { /// Whether this manager uses a static token provider. /// /// Returns true if the token provider is static (doesn't refresh tokens), - /// false if it's dynamic (fetches fresh tokens on each call). - bool get usesStaticProvider => _tokenProvider is StaticTokenProvider; + /// false if it's dynamic (fetches fresh tokens on each call) or if no + /// identity is configured. + bool get usesStaticProvider => _identity?.provider is StaticTokenProvider; /// Gets a valid token for the user, loading one if necessary. /// @@ -123,6 +146,10 @@ class TokenManager { /// at a time. /// /// Returns a [Future] that resolves to a [UserToken] for the user. + /// + /// Fails with a [ClientException] when no identity is configured, either + /// because the manager was created with [TokenManager.unconfigured] or + /// because [reset] dropped the previous one. Future getToken() { final cached = _cachedToken; if (cached != null) return Future.value(cached); @@ -138,9 +165,14 @@ class TokenManager { // Loads a token from the provider and, unless the cached token was // invalidated while it loaded, caches it and notifies `onTokenUpdated`. Future _loadAndNotify() async { - final loadingFor = _userId; + final identity = _identity; + if (identity == null) { + throw ClientException(message: 'No user is configured, call setTokenProvider before loading a token'); + } + + final loadingFor = identity.userId; final loadingGeneration = _generation; - final updatedToken = await _tokenProvider.loadToken(loadingFor); + final updatedToken = await identity.provider.loadToken(loadingFor); // `setTokenProvider` or `expireToken` may have run while this loaded, in // which case the token is the one the caller asked to stop using. diff --git a/packages/stream_core/lib/src/user/user.dart b/packages/stream_core/lib/src/user/user.dart index 3bd661fe..53eed28d 100644 --- a/packages/stream_core/lib/src/user/user.dart +++ b/packages/stream_core/lib/src/user/user.dart @@ -17,7 +17,11 @@ class User extends Equatable { this.type = UserType.regular, Map? custom, this.teams = const [], - }) : originalName = name, + }) : assert( + type != UserType.anonymous || id == anonymousUserId, + 'An anonymous user must use `User.anonymousUserId` as its id', + ), + originalName = name, custom = custom ?? const {}; /// Creates a guest user with the provided id and an optional display name. @@ -28,7 +32,13 @@ class User extends Equatable { /// Creates an anonymous user. /// - Returns: an anonymous `User`. - const User.anonymous() : this(id: '!anon', type: UserType.anonymous); + const User.anonymous() : this(id: anonymousUserId, type: UserType.anonymous); + + /// The id every anonymous user has. + /// + /// Anonymous users are not distinguishable from one another, so this is the + /// only id a [User] of type [UserType.anonymous] can carry. + static const anonymousUserId = '!anon'; /// The user's id. final String id; diff --git a/packages/stream_core/lib/src/user/user_token.dart b/packages/stream_core/lib/src/user/user_token.dart index 1337c10e..32f773aa 100644 --- a/packages/stream_core/lib/src/user/user_token.dart +++ b/packages/stream_core/lib/src/user/user_token.dart @@ -1,6 +1,8 @@ import 'package:equatable/equatable.dart'; import 'package:jose/jose.dart'; +import 'user.dart'; + /// A function that loads user tokens. /// /// Takes a [userId] and returns a [Future] that resolves to a [UserToken]. @@ -58,7 +60,7 @@ class UserToken extends Equatable { /// Creates an anonymous user token. /// - /// Anonymous tokens always use [anonymousUserId] as their user id. + /// Anonymous tokens always use [User.anonymousUserId] as their user id. /// /// An optional [rawValue] can carry a JWT that is sent along with anonymous /// requests, granting the caller access to the specific resources its claims @@ -68,24 +70,24 @@ class UserToken extends Equatable { /// Returns a [UserToken] configured for anonymous access. /// /// Throws an [ArgumentError] if [rawValue] is given and its 'user_id' claim - /// is not [anonymousUserId], and a [FormatException] if it cannot be parsed + /// is not [User.anonymousUserId], and a [FormatException] if it cannot be parsed /// as a JWT. factory UserToken.anonymous({String rawValue = ''}) { if (rawValue.isNotEmpty) { final jwtBody = JsonWebToken.unverified(rawValue); final claim = jwtBody.claims.getTyped('user_id'); - if (claim != anonymousUserId) { + if (claim != User.anonymousUserId) { throw ArgumentError.value( claim, 'rawValue', - 'Expected a JWT claiming user_id "$anonymousUserId"', + 'Expected a JWT claiming user_id "${User.anonymousUserId}"', ); } } return UserToken._( rawValue: rawValue, - userId: anonymousUserId, + userId: User.anonymousUserId, authType: AuthType.anonymous, ); } @@ -96,9 +98,6 @@ class UserToken extends Equatable { required this.authType, }); - /// The user id used for anonymous authentication. - static const anonymousUserId = '!anon'; - /// The raw token value. /// /// For JWT tokens, contains the complete JWT string. For anonymous tokens, @@ -108,7 +107,7 @@ class UserToken extends Equatable { /// The unique identifier of the user. /// /// For JWT tokens, this value is extracted from the 'user_id' claim. - /// For anonymous tokens, it is always [anonymousUserId]. + /// For anonymous tokens, it is always [User.anonymousUserId]. final String userId; /// The authentication type of this token. diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index 5dece376..deff8dec 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -113,7 +113,7 @@ void main() { const serverId = 'server-assigned-id'; final tokenManager = TokenManager( - userId: UserToken.anonymousUserId, + userId: User.anonymousUserId, tokenProvider: TokenProvider.static(UserToken.anonymous()), ); @@ -142,7 +142,7 @@ void main() { 'anonymous auth type', () async { final tokenManager = TokenManager( - userId: UserToken.anonymousUserId, + userId: User.anonymousUserId, tokenProvider: TokenProvider.static(UserToken.anonymous()), ); @@ -160,7 +160,7 @@ void main() { ); expect( adapter.lastRequest?.queryParameters['user_id'], - UserToken.anonymousUserId, + User.anonymousUserId, ); }, ); @@ -168,9 +168,9 @@ void main() { test( 'sends a restricted anonymous token as the Authorization header', () async { - final restricted = generateTestUserToken(UserToken.anonymousUserId); + final restricted = generateTestUserToken(User.anonymousUserId); final tokenManager = TokenManager( - userId: UserToken.anonymousUserId, + userId: User.anonymousUserId, tokenProvider: TokenProvider.static( UserToken.anonymous(rawValue: restricted.rawValue), ), @@ -192,11 +192,13 @@ void main() { ); test( - 'sends the token manager user id, not the loaded token user id', + 'sends the user id of the token it actually sent', () async { - // The mismatch is deliberate: a request carrying someone else's token - // is rejected, where deriving `user_id` from the token would make it - // self-consistent and silently act as the token's owner. + // The two can disagree: a load already running for one user finishes + // after the manager has moved to another, and that token is still + // handed to the request that triggered it. Deriving `user_id` from the + // token keeps the pair self-consistent, so the request is accepted as + // the token's owner rather than rejected for a mismatch. final slowLoad = Completer(); final tokenManager = TokenManager( userId: 'user-1', @@ -212,18 +214,18 @@ void main() { await pumpEventQueue(); // The load is already running for user-1 when the manager moves on. - final userTwoToken = generateTestUserToken('user-2'); + final userOneToken = generateTestUserToken('user-1'); tokenManager.setTokenProvider( 'user-2', - tokenProvider: TokenProvider.static(userTwoToken), + tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), ); - slowLoad.complete(generateTestUserToken('user-1')); + slowLoad.complete(userOneToken); await pending; - expect(adapter.lastRequest?.queryParameters['user_id'], 'user-2'); + expect(adapter.lastRequest?.queryParameters['user_id'], 'user-1'); expect( adapter.lastRequest?.headers['Authorization'], - isNot(userTwoToken.rawValue), + userOneToken.rawValue, ); }, ); diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 80197c2b..b4d73c3f 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -172,7 +172,7 @@ void main() { const serverId = 'guest-abc-guest-123'; final manager = TokenManager( - userId: UserToken.anonymousUserId, + userId: User.anonymousUserId, tokenProvider: TokenProvider.static(UserToken.anonymous()), ); @@ -254,6 +254,78 @@ void main() { }); }); + group('unconfigured', () { + test('has no user and fails to load a token', () async { + final manager = TokenManager.unconfigured(); + + expect(manager.userId, isNull); + expect(manager.peekToken(), isNull); + expect(manager.usesStaticProvider, isFalse); + await expectLater(manager.getToken(), throwsA(isA())); + }); + + test('loads once an identity is supplied', () async { + final manager = TokenManager.unconfigured(); + + manager.setTokenProvider( + 'user-1', + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), + ); + + expect(manager.userId, 'user-1'); + expect((await manager.getToken()).userId, 'user-1'); + }); + }); + + group('reset', () { + test('drops the identity and the cached token', () async { + final manager = TokenManager( + userId: 'user-1', + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), + ); + + await manager.getToken(); + expect(manager.peekToken(), isNotNull); + + manager.reset(); + + expect(manager.userId, isNull); + expect(manager.peekToken(), isNull); + await expectLater(manager.getToken(), throwsA(isA())); + }); + + test('leaves the manager reusable for another user', () async { + final manager = TokenManager( + userId: 'user-1', + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), + )..reset(); + + manager.setTokenProvider( + 'user-2', + tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), + ); + + expect((await manager.getToken()).userId, 'user-2'); + }); + + test('discards a load already in flight', () async { + final completer = Completer(); + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider((_) => completer.future), + ); + + final inFlight = manager.getToken(); + manager.reset(); + completer.complete(generateTestUserToken('user-1')); + + // The caller that started the load is still served, but its token is + // not cached over the reset. + expect((await inFlight).userId, 'user-1'); + expect(manager.peekToken(), isNull); + }); + }); + group('usesStaticProvider', () { test('reflects the provider type', () { final staticManager = TokenManager( diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index 3e068f4c..c79669f3 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -14,7 +14,7 @@ void main() { }); test('carries an optional raw value for restricted access', () { - final restricted = generateTestJwt(UserToken.anonymousUserId); + final restricted = generateTestJwt(User.anonymousUserId); final token = UserToken.anonymous(rawValue: restricted); expect(token.userId, '!anon'); @@ -85,7 +85,7 @@ void main() { // Requested id matches the one an anonymous token carries, so this // reaches the type check rather than failing the user id check first. expect( - () => provider.loadToken(UserToken.anonymousUserId), + () => provider.loadToken(User.anonymousUserId), throwsArgumentError, ); }); diff --git a/packages/stream_core/test/user/user_test.dart b/packages/stream_core/test/user/user_test.dart new file mode 100644 index 00000000..1e0e87c2 --- /dev/null +++ b/packages/stream_core/test/user/user_test.dart @@ -0,0 +1,44 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('User.anonymous', () { + test('carries the id every anonymous user has', () { + const user = User.anonymous(); + + expect(user.id, User.anonymousUserId); + expect(user.type, UserType.anonymous); + }); + }); + + group('User', () { + test('rejects an anonymous user built with any other id', () { + // Not `const`: a const context evaluates the assert at compile time. + expect( + () => User(id: 'someone-else', type: UserType.anonymous), + throwsA(isA()), + ); + }); + + test('allows the anonymous id for a user of another type', () { + // The invariant runs one way: anonymous implies the id, not the reverse. + const user = User(id: User.anonymousUserId); + + expect(user.type, UserType.regular); + }); + + test('reports the id as the name when none was given', () { + const user = User.guest('bob'); + + expect(user.originalName, isNull); + expect(user.name, 'bob'); + }); + + test('keeps the name it was given', () { + const user = User.guest('bob', name: 'Bob'); + + expect(user.originalName, 'Bob'); + expect(user.name, 'Bob'); + }); + }); +} From 1abd7d4c79348b8c9b8f3a645dd64b10da32f1c3 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 13:32:08 +0200 Subject: [PATCH 18/97] feat(llc)!: bound and authenticate a connection attempt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- packages/stream_core/CHANGELOG.md | 9 + .../user/connect_user_details_request.dart | 14 + .../ws/client/engine/web_socket_options.dart | 13 +- .../ws/client/stream_web_socket_client.dart | 91 ++++- .../client/web_socket_connection_state.dart | 40 ++ packages/stream_core/pubspec.yaml | 1 + .../connect_user_details_request_test.dart | 59 +++ .../client/stream_web_socket_client_test.dart | 377 ++++++++++++++++++ .../web_socket_connection_state_test.dart | 53 +++ 9 files changed, 639 insertions(+), 18 deletions(-) create mode 100644 packages/stream_core/test/user/connect_user_details_request_test.dart create mode 100644 packages/stream_core/test/ws/client/stream_web_socket_client_test.dart diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index baeeb305..0db4bf92 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -4,6 +4,9 @@ - Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `User.anonymousUserId` - Removed the `TokenManager.tokenProvider` setter, use `setTokenProvider` instead +- `StreamWebSocketClient` now takes an `optionsBuilder` instead of `options`, and calls it for every connection attempt +- Renamed `StreamWebSocketClient.onConnectionEstablished` to `onAuthenticate`, which is what it is called for and when +- `StreamWebSocketClient.onAuthenticate` is now a `WebSocketAuthenticator`: it is handed a `WsSender` and returns a `Result`, so a failure to authenticate can be observed - `TokenManager.userId` is now nullable, and is `null` until an identity is configured ### ✨ Features @@ -14,6 +17,11 @@ - Added `User.anonymousUserId`, the id every anonymous user has - Added `TokenManager.unconfigured`, for a client that exists before its user does - Added `TokenManager.reset`, which drops the configured identity and its cached token +- Added `DisconnectionSource.connectTimeout`, reported when a connection attempt is abandoned before the connection is established +- Added `DisconnectionSource.authenticationFailed`, reported with its cause when a connection opens but cannot be authenticated +- `StreamWebSocketClient` now honours `WebSocketOptions.connectTimeout`, which is no longer nullable and defaults to `WebSocketOptions.defaultConnectTimeout` +- Added `WsSender`, the send capability handed to a `WebSocketAuthenticator` +- Added `ConnectUserDetailsRequest.fromUser`, which builds the details a client may send from a `User` - Added `teams` field to `User` class ### 🐛 Bug Fixes @@ -21,6 +29,7 @@ - Fixed `TokenManager.getToken()` contacting the `TokenProvider` on every call instead of returning the cached token - Fixed `DynamicTokenProvider` accepting a token issued for a different user than the one requested - Fixed `TokenManager` caching a token that finished loading after `expireToken` or `setTokenProvider` had invalidated it +- Fixed a health check arriving while disconnecting reporting the connection as established again, which replaced the disconnection source and could turn a deliberate disconnect into an automatic reconnect ### 🔄 Changed diff --git a/packages/stream_core/lib/src/user/connect_user_details_request.dart b/packages/stream_core/lib/src/user/connect_user_details_request.dart index 553ba9d2..fea5a7ef 100644 --- a/packages/stream_core/lib/src/user/connect_user_details_request.dart +++ b/packages/stream_core/lib/src/user/connect_user_details_request.dart @@ -1,5 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; +import 'user.dart'; + part 'connect_user_details_request.g.dart'; @JsonSerializable(createFactory: false) @@ -13,6 +15,18 @@ class ConnectUserDetailsRequest { this.custom, }); + factory ConnectUserDetailsRequest.fromUser( + User user, { + bool includeDetails = true, + }) { + return ConnectUserDetailsRequest( + id: user.id, + name: includeDetails ? user.originalName : null, + image: includeDetails ? user.image : null, + custom: includeDetails ? user.custom : null, + ); + } + final String id; final String? image; final bool? invisible; diff --git a/packages/stream_core/lib/src/ws/client/engine/web_socket_options.dart b/packages/stream_core/lib/src/ws/client/engine/web_socket_options.dart index 0d2f86c0..f4295085 100644 --- a/packages/stream_core/lib/src/ws/client/engine/web_socket_options.dart +++ b/packages/stream_core/lib/src/ws/client/engine/web_socket_options.dart @@ -22,7 +22,7 @@ class WebSocketOptions { /// Creates a new instance of [WebSocketOptions]. const WebSocketOptions({ required this.url, - this.connectTimeout, + this.connectTimeout = defaultConnectTimeout, this.protocols, this.queryParameters, }); @@ -35,9 +35,14 @@ class WebSocketOptions { /// Maximum time allowed for establishing the WebSocket connection. /// - /// When specified, the connection attempt will timeout if not completed - /// within this duration. If `null`, uses the platform default timeout. - final Duration? connectTimeout; + /// Covers the whole attempt, not just opening the socket: a connection that + /// opens but is never established is abandoned once this elapses. + /// + /// Defaults to [defaultConnectTimeout]. + final Duration connectTimeout; + + /// The [connectTimeout] used when none is given. + static const defaultConnectTimeout = Duration(seconds: 15); /// WebSocket sub-protocols to negotiate during the handshake. /// diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index 3e6c4ec4..8a306dff 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -18,6 +18,32 @@ WsRequest _defaultPingRequestBuilder([HealthCheckInfo? info]) { return HealthCheckPingEvent(connectionId: info?.connectionId); } +/// A function that builds the options for a connection attempt. +/// +/// Called once per attempt, so the options may carry values that change over the +/// client's lifetime. +/// +/// Returns the [WebSocketOptions] to open the connection with. +typedef WebSocketOptionsBuilder = WebSocketOptions Function(); + +/// A function that sends a request over a connection that is not usable yet. +/// +/// Handed to a [WebSocketAuthenticator], which runs while the connection is +/// still being established and so cannot be given the client itself. +/// +/// Returns a [Result] indicating whether the request was sent. +typedef WsSender = Result Function(WsRequest request); + +/// A function that authenticates a newly opened connection. +/// +/// Called once the socket is open, while the state is [Authenticating]. Sending +/// the credentials the server expects is this function's job. +/// +/// Returns a [Future] that completes when the credentials have been sent, and +/// fails if they could not be — in which case the connection is closed with +/// [AuthenticationFailed] rather than left waiting for a reply that never comes. +typedef WebSocketAuthenticator = Future> Function(WsSender send); + /// A WebSocket client with connection management and event handling. /// /// The primary interface for WebSocket connections in the Stream Core SDK that provides @@ -31,11 +57,9 @@ WsRequest _defaultPingRequestBuilder([HealthCheckInfo? info]) { /// ## Example /// ```dart /// final client = StreamWebSocketClient( -/// options: WebSocketOptions(url: 'wss://api.example.com'), +/// optionsBuilder: () => WebSocketOptions(url: 'wss://api.example.com'), /// messageCodec: JsonMessageCodec(), -/// onConnectionEstablished: () { -/// client.send(AuthRequest(token: authToken)); -/// }, +/// onAuthenticate: (send) async => send(AuthRequest(token: authToken)), /// ); /// /// await client.connect(); @@ -43,8 +67,8 @@ WsRequest _defaultPingRequestBuilder([HealthCheckInfo? info]) { class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineListener { /// Creates a new instance of [StreamWebSocketClient]. StreamWebSocketClient({ - required this.options, - this.onConnectionEstablished, + required this.optionsBuilder, + this.onAuthenticate, WebSocketProvider? wsProvider, this.pingRequestBuilder = _defaultPingRequestBuilder, required WebSocketMessageCodec messageCodec, @@ -58,18 +82,34 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL ); } - /// The WebSocket connection options including URL and configuration. - final WebSocketOptions options; + /// The function used to build the connection options for each attempt. + final WebSocketOptionsBuilder optionsBuilder; /// The function used to build ping requests for health checks. final PingRequestBuilder pingRequestBuilder; - /// Called when the WebSocket connection is established and ready for authentication. - final void Function()? onConnectionEstablished; + /// The function used to authenticate a newly opened connection. + final WebSocketAuthenticator? onAuthenticate; late final StreamWebSocketEngine _engine; late final _healthMonitor = WebSocketHealthMonitor(listener: this); + // Bounds a connection attempt that never reaches 'connected'. + Timer? _connectTimeoutTimer; + + void _startConnectTimeout(Duration timeout) { + _connectTimeoutTimer?.cancel(); + _connectTimeoutTimer = Timer(timeout, () { + const source = DisconnectionSource.connectTimeout(); + unawaited(disconnect(source: source)); + }); + } + + void _cancelConnectTimeout() { + _connectTimeoutTimer?.cancel(); + _connectTimeoutTimer = null; + } + /// The event emitter for WebSocket events. /// /// Use this to listen to incoming WebSocket events with type-safe event handling. @@ -116,7 +156,11 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL // Update the connection state to 'connecting'. _connectionState = const WebSocketConnectionState.connecting(); - // Open the connection using the engine. + // Open the connection using the engine, with options built for this attempt. + final options = optionsBuilder.call(); + + // Time the whole handshake: nothing else watches 'authenticating'. + _startConnectTimeout(options.connectTimeout); final result = await _engine.open(options); // If some failure occurs, disconnect and rethrow the error. @@ -136,6 +180,9 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL // If the connection is already disconnected, do nothing. if (connectionState.value is Disconnected) return; + // Stop the timeout from firing later and replacing this source. + _cancelConnectTimeout(); + // Update the connection state to 'disconnecting'. _connectionState = WebSocketConnectionState.disconnecting(source: source); @@ -148,13 +195,24 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL // Update the connection state to 'authenticating'. _connectionState = const WebSocketConnectionState.authenticating(); - // Notify that the connection has been established and we are ready - // to authenticate. - onConnectionEstablished?.call(); + // The socket is open, so authenticate before the connection is usable. + unawaited(_authenticate()); + } + + Future _authenticate() async { + final result = await onAuthenticate?.call(send); + + // Close the connection rather than wait for a reply that cannot come. + if (result?.exceptionOrNull() case final error?) { + final source = DisconnectionSource.authenticationFailed(error: error); + return disconnect(source: source); + } } @override void onClose([int? closeCode, String? closeReason]) { + _cancelConnectTimeout(); + final source = switch (connectionState.value) { // If we were already disconnecting, keep the caller-provided source. Disconnecting(:final source) => source, @@ -217,6 +275,11 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL void _handleHealthCheckEvent(WsEvent event, HealthCheckInfo info) { print('WebSocketClient: Health check pong received: $info'); + // Ignore a pong that arrives once the connection is on its way down. + if (connectionState.value case Disconnecting() || Disconnected()) return; + + _cancelConnectTimeout(); + // Update the connection state with health check info. _connectionState = WebSocketConnectionState.connected(healthCheck: info); diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index 87a6cac2..586c8d1f 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -115,6 +115,8 @@ sealed class WebSocketConnectionState extends Equatable { UnHealthyConnection() => true, SystemInitiated() => true, UserInitiated() => false, + ConnectTimeout() => false, + AuthenticationFailed() => false, }, _ => false, // No automatic reconnection for other states }; @@ -252,6 +254,18 @@ sealed class DisconnectionSource extends Equatable { /// typically when ping requests do not receive pong responses. const factory DisconnectionSource.unHealthyConnection() = UnHealthyConnection; + /// Creates a [ConnectTimeout] disconnection source. + /// + /// Indicates that the connection never became usable within the allotted + /// time, so it was abandoned before it was ever established. + const factory DisconnectionSource.connectTimeout() = ConnectTimeout; + + /// Creates an [AuthenticationFailed] disconnection source. + /// + /// Indicates that the connection opened but could not be authenticated, so it + /// was closed without ever being usable. + const factory DisconnectionSource.authenticationFailed({Object? error}) = AuthenticationFailed; + /// A human-readable description of the disconnection source. /// /// Provides a descriptive string that explains why the connection was closed. @@ -264,6 +278,8 @@ sealed class DisconnectionSource extends Equatable { ServerInitiated() => 'Server initiated disconnection', SystemInitiated() => 'System initiated disconnection', UnHealthyConnection() => 'Unhealthy connection (no pong received)', + ConnectTimeout() => 'Timed out before the connection was established', + AuthenticationFailed() => 'Authentication failed', }; } @@ -320,3 +336,27 @@ final class UnHealthyConnection extends DisconnectionSource { /// Creates an [UnHealthyConnection] disconnection source. const UnHealthyConnection(); } + +/// A disconnection caused by the connection not becoming usable in time. +/// +/// This source indicates that the connection was abandoned while it was still +/// being established, so it was never usable. +final class ConnectTimeout extends DisconnectionSource { + /// Creates a [ConnectTimeout] disconnection source. + const ConnectTimeout(); +} + +/// A disconnection caused by the connection failing to authenticate. +/// +/// This source indicates that the socket opened but authentication did not +/// complete, so the connection was never usable. +final class AuthenticationFailed extends DisconnectionSource { + /// Creates an [AuthenticationFailed] disconnection source. + const AuthenticationFailed({this.error}); + + /// The error that prevented the connection from authenticating. + final Object? error; + + @override + List get props => [error]; +} diff --git a/packages/stream_core/pubspec.yaml b/packages/stream_core/pubspec.yaml index c28d1a61..7fa4b4fa 100644 --- a/packages/stream_core/pubspec.yaml +++ b/packages/stream_core/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: dev_dependencies: build_runner: ^2.10.5 + fake_async: ^1.3.3 json_serializable: ^6.9.5 mocktail: ^1.0.4 test: ^1.26.2 diff --git a/packages/stream_core/test/user/connect_user_details_request_test.dart b/packages/stream_core/test/user/connect_user_details_request_test.dart new file mode 100644 index 00000000..2ff4858a --- /dev/null +++ b/packages/stream_core/test/user/connect_user_details_request_test.dart @@ -0,0 +1,59 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('ConnectUserDetailsRequest.fromUser', () { + test('carries the fields the server accepts from a client', () { + const user = User( + id: 'user-1', + name: 'Bob', + image: 'https://example.com/bob.png', + custom: {'plan': 'pro'}, + ); + + final details = ConnectUserDetailsRequest.fromUser(user); + + expect(details.id, 'user-1'); + expect(details.name, 'Bob'); + expect(details.image, 'https://example.com/bob.png'); + expect(details.custom, {'plan': 'pro'}); + }); + + test('leaves out the fields the server decides itself', () { + const user = User(id: 'user-1', role: 'admin', teams: ['red']); + + final json = ConnectUserDetailsRequest.fromUser(user).toJson(); + + // Sending either is pointless: the server ignores both from a client. + expect(json, isNot(contains('role'))); + expect(json, isNot(contains('teams'))); + }); + + test('sends the id alone when details are excluded', () { + const user = User( + id: 'user-1', + name: 'Bob', + image: 'https://example.com/bob.png', + custom: {'plan': 'pro'}, + ); + + final details = ConnectUserDetailsRequest.fromUser(user, includeDetails: false); + + expect(details.id, 'user-1'); + expect(details.name, isNull); + expect(details.image, isNull); + expect(details.custom, isNull); + }); + + test('reports the name the user was created with, not the id fallback', () { + // `User.name` falls back to the id; the wire form must not, or a user + // with no name would be given the id as one. + const user = User(id: 'user-1'); + + final details = ConnectUserDetailsRequest.fromUser(user); + + expect(user.name, 'user-1'); + expect(details.name, isNull); + }); + }); +} diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart new file mode 100644 index 00000000..e458a599 --- /dev/null +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -0,0 +1,377 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class _MockWebSocketChannel extends Mock implements WebSocketChannel {} + +class _MockWebSocketSink extends Mock implements WebSocketSink {} + +/// A codec that is never exercised: these tests drive the client through its +/// engine listener callbacks rather than through encoded frames. +class _NoopCodec implements WebSocketMessageCodec { + const _NoopCodec(); + + @override + Object encode(WsRequest message) => ''; + + @override + WsEvent decode(Object message) => const _HealthCheckEvent(); +} + +final class _HealthCheckEvent extends WsEvent { + const _HealthCheckEvent({this.connectionId = 'connection-id'}); + + final String? connectionId; + + @override + HealthCheckInfo? get healthCheckInfo { + return HealthCheckInfo(connectionId: connectionId); + } +} + +final class _PingRequest extends WsRequest { + const _PingRequest(); + + @override + Map toJson() => const {}; + + @override + List get props => const []; +} + +/// Builds a client whose socket opens successfully but sends nothing, so the +/// handshake only progresses when a test drives it. +({ + StreamWebSocketClient client, + StreamController incoming, + int Function() optionsBuilt, + WebSocketSink sink, +}) +_client({ + Duration connectTimeout = WebSocketOptions.defaultConnectTimeout, + WebSocketAuthenticator? onAuthenticate, +}) { + final incoming = StreamController.broadcast(); + addTearDown(incoming.close); + + final channel = _MockWebSocketChannel(); + when(() => channel.ready).thenAnswer((_) async {}); + when(() => channel.stream).thenAnswer((_) => incoming.stream); + final sink = _MockWebSocketSink(); + when(() => channel.sink).thenReturn(sink); + + var built = 0; + final client = StreamWebSocketClient( + optionsBuilder: () { + built++; + return WebSocketOptions( + url: 'wss://example.com', + connectTimeout: connectTimeout, + ); + }, + onAuthenticate: onAuthenticate, + wsProvider: (_) => channel, + pingRequestBuilder: ([_]) => const _PingRequest(), + messageCodec: const _NoopCodec(), + ); + + return (client: client, incoming: incoming, optionsBuilt: () => built, sink: sink); +} + +void main() { + group('StreamWebSocketClient.optionsBuilder', () { + test('is called for every connection attempt, not once per client', () async { + final (:client, :incoming, :optionsBuilt, sink: _) = _client(); + + await client.connect(); + expect(optionsBuilt(), 1); + + await client.disconnect(); + client.onClose(); + + await client.connect(); + expect(optionsBuilt(), 2); + }); + }); + + group('StreamWebSocketClient.onAuthenticate', () { + test('is called once the socket is open, while authenticating', () async { + WebSocketConnectionState? stateWhenCalled; + late StreamWebSocketClient client; + final built = _client( + onAuthenticate: (_) async { + stateWhenCalled = client.connectionState.value; + return const Result.success(null); + }, + ); + client = built.client; + + await client.connect(); + await pumpEventQueue(); + + expect(stateWhenCalled, isA()); + }); + + test('is called once per connection attempt', () async { + var calls = 0; + final (:client, :incoming, optionsBuilt: _, sink: _) = _client( + onAuthenticate: (_) async { + calls++; + return const Result.success(null); + }, + ); + + await client.connect(); + await pumpEventQueue(); + expect(calls, 1); + + await client.disconnect(); + client.onClose(); + + await client.connect(); + await pumpEventQueue(); + expect(calls, 2); + }); + + test('is handed a sender that puts the request on the socket', () async { + Result? sent; + final (:client, incoming: _, optionsBuilt: _, :sink) = _client( + onAuthenticate: (send) async => sent = send(const _PingRequest()), + ); + + await client.connect(); + await pumpEventQueue(); + + // The sender is only useful if it reaches the socket: an authenticator + // that cannot send has nothing to report but failure. + expect(sent, isA>()); + verify(() => sink.add(any())).called(1); + }); + + test('leaves the connection authenticating when it succeeds', () async { + final (:client, :incoming, optionsBuilt: _, sink: _) = _client( + onAuthenticate: (_) async => const Result.success(null), + ); + + await client.connect(); + await pumpEventQueue(); + + // Sending the credentials does not establish the connection; the server + // answering does. + expect(client.connectionState.value, isA()); + + client.onMessage(const _HealthCheckEvent()); + expect(client.connectionState.value, isA()); + }); + + test('leaves the connection authenticating when there is no authenticator', () async { + // A socket that has nothing to send before it is usable, such as one whose + // protocol authenticates elsewhere. + final (:client, :incoming, optionsBuilt: _, sink: _) = _client(); + + await client.connect(); + await pumpEventQueue(); + + expect(client.connectionState.value, isA()); + + client.onMessage(const _HealthCheckEvent()); + expect(client.connectionState.value, isA()); + }); + }); + + group('StreamWebSocketClient authentication failure', () { + test('closes the connection instead of waiting for a reply', () async { + final (:client, :incoming, optionsBuilt: _, sink: _) = _client( + onAuthenticate: (_) async => Result.failure(StateError('no token')), + ); + + await client.connect(); + await pumpEventQueue(); + + final state = client.connectionState.value; + expect( + state, + isA().having( + (it) => it.source, + 'source', + isA().having((it) => it.error, 'error', isStateError), + ), + ); + }); + + test('is not retried, since the same credentials would fail again', () async { + final (:client, :incoming, optionsBuilt: _, sink: _) = _client( + onAuthenticate: (_) async => Result.failure(StateError('no token')), + ); + + await client.connect(); + await pumpEventQueue(); + client.onClose(); + + final state = client.connectionState.value; + expect(state, isA()); + expect(state.isAutomaticReconnectionEnabled, isFalse); + }); + }); + + group('StreamWebSocketClient connect timeout', () { + test('abandons an attempt that never becomes connected', () { + fakeAsync((async) { + final (:client, :incoming, optionsBuilt: _, sink: _) = _client(); + + client.connect().ignore(); + async.flushMicrotasks(); + + // The socket opened, so the client is authenticating with nothing else + // watching it. + expect(client.connectionState.value, isA()); + + // Still waiting a tick before the timeout is due. + async.elapse(WebSocketOptions.defaultConnectTimeout - const Duration(seconds: 1)); + expect(client.connectionState.value, isA()); + + async.elapse(const Duration(seconds: 1)); + expect( + client.connectionState.value, + isA().having((it) => it.source, 'source', isA()), + ); + }); + }); + + test('abandons an attempt whose authenticator never returns', () { + fakeAsync((async) { + // The realistic hang: an authenticator awaiting something that never + // resolves. Nothing else watches 'authenticating', so only this fires. + final (:client, incoming: _, optionsBuilt: _, sink: _) = _client( + onAuthenticate: (_) => Completer>().future, + ); + + client.connect().ignore(); + async.flushMicrotasks(); + expect(client.connectionState.value, isA()); + + async.elapse(WebSocketOptions.defaultConnectTimeout); + + expect( + client.connectionState.value, + isA().having((it) => it.source, 'source', isA()), + ); + }); + }); + + test('is armed again for a later attempt', () { + fakeAsync((async) { + final (:client, incoming: _, optionsBuilt: _, sink: _) = _client(); + + client.connect().ignore(); + async.flushMicrotasks(); + async.elapse(WebSocketOptions.defaultConnectTimeout); + client.onClose(); + expect(client.connectionState.value, isA()); + + client.connect().ignore(); + async.flushMicrotasks(); + async.elapse(WebSocketOptions.defaultConnectTimeout); + + expect( + client.connectionState.value, + isA().having((it) => it.source, 'source', isA()), + ); + }); + }); + + test('honours a timeout given in the options', () { + fakeAsync((async) { + final (:client, :incoming, optionsBuilt: _, sink: _) = _client( + connectTimeout: const Duration(seconds: 2), + ); + + client.connect().ignore(); + async.flushMicrotasks(); + + async.elapse(const Duration(seconds: 2)); + + expect( + client.connectionState.value, + isA().having((it) => it.source, 'source', isA()), + ); + }); + }); + + test('does not fire once the connection is established', () { + fakeAsync((async) { + final (:client, :incoming, optionsBuilt: _, sink: _) = _client(); + + client.connect().ignore(); + async.flushMicrotasks(); + client.onMessage(const _HealthCheckEvent()); + expect(client.connectionState.value, isA()); + + // Past when the timeout would have fired, but before the health + // monitor's first ping is due. + async.elapse(WebSocketOptions.defaultConnectTimeout + const Duration(seconds: 1)); + + expect(client.connectionState.value, isA()); + }); + }); + + test('does not replace the source of a disconnect that came first', () { + fakeAsync((async) { + final (:client, :incoming, optionsBuilt: _, sink: _) = _client(); + + client.connect().ignore(); + async.flushMicrotasks(); + client.disconnect().ignore(); + async.flushMicrotasks(); + + async.elapse(WebSocketOptions.defaultConnectTimeout * 2); + + // The timeout would otherwise report this deliberate disconnect as a + // timed-out attempt, which reconnects differently. + expect( + client.connectionState.value, + isA().having((it) => it.source, 'source', isA()), + ); + }); + }); + }); + + group('StreamWebSocketClient health check while disconnecting', () { + test('does not report the connection as established again', () async { + final (:client, :incoming, optionsBuilt: _, sink: _) = _client(); + + await client.connect(); + client.onMessage(const _HealthCheckEvent()); + expect(client.connectionState.value, isA()); + + await client.disconnect(); + expect(client.connectionState.value, isA()); + + // Arrives before the socket finished closing. + client.onMessage(const _HealthCheckEvent(connectionId: 'late')); + + expect(client.connectionState.value, isA()); + }); + + test('leaves the disconnection source intact once the socket closes', () async { + final (:client, :incoming, optionsBuilt: _, sink: _) = _client(); + + await client.connect(); + client.onMessage(const _HealthCheckEvent()); + await client.disconnect(); + client.onMessage(const _HealthCheckEvent(connectionId: 'late')); + client.onClose(); + + // Without the guard the late health check moves the state back to + // connected, and `onClose` then reports a server-initiated disconnect, + // which is eligible for an automatic reconnect. + final state = client.connectionState.value; + expect(state, isA().having((it) => it.source, 'source', isA())); + expect(state.isAutomaticReconnectionEnabled, isFalse); + }); + }); +} diff --git a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart index c8d9a6b9..59d26f43 100644 --- a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart @@ -40,5 +40,58 @@ void main() { expect(state.isAutomaticReconnectionEnabled, isTrue); }); + + test( + 'is disabled when a connection attempt timed out, so a handshake that ' + 'never completes is not retried forever', + () { + const state = Disconnected(source: ConnectTimeout()); + + expect(state.isAutomaticReconnectionEnabled, isFalse); + }, + ); + + test( + 'is disabled when a connection could not be authenticated, since the ' + 'same credentials would fail again', + () { + const state = Disconnected(source: AuthenticationFailed(error: 'no token')); + + expect(state.isAutomaticReconnectionEnabled, isFalse); + }, + ); + + test('is enabled when a connected socket stops answering health checks', () { + const state = Disconnected(source: UnHealthyConnection()); + + expect(state.isAutomaticReconnectionEnabled, isTrue); + }); + }); + + group('DisconnectionSource.closeReason', () { + test('reads differently for every source', () { + const sources = [ + UserInitiated(), + ServerInitiated(), + SystemInitiated(), + UnHealthyConnection(), + ConnectTimeout(), + AuthenticationFailed(error: 'no token'), + ]; + + final reasons = sources.map((it) => it.closeReason).toSet(); + + // A shared reason would report two different outcomes identically. + expect(reasons, hasLength(sources.length)); + }); + }); + + group('WebSocketOptions.defaultConnectTimeout', () { + test('is the timeout used when the options do not say', () { + const options = WebSocketOptions(url: 'wss://example.com'); + + expect(options.connectTimeout, WebSocketOptions.defaultConnectTimeout); + expect(WebSocketOptions.defaultConnectTimeout, const Duration(seconds: 15)); + }); }); } From 06747e081ea1d99a61ab105357a794f5645a9926 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 16:59:04 +0200 Subject: [PATCH 19/97] fix(llc): report a wrong-type token as such, not as a wrong user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DynamicTokenProvider` checked the identity before the type, so a loader returning an anonymous token for a real user reported "User ID mismatch" — the id an anonymous token carries rather than the reason it was rejected. The test had to request `User.anonymousUserId` to reach the type check at all, which is how the ordering surfaced in review. Checking the type first reports what is actually wrong. The identity check still runs for tokens of the right type, which is the case that matters for security. Co-Authored-By: Claude Opus 5 (1M context) --- .../stream_core/lib/src/user/token_provider.dart | 13 +++++++------ .../stream_core/test/user/token_provider_test.dart | 14 ++++++++++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index 53bb63ac..0488a098 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -108,17 +108,18 @@ class DynamicTokenProvider implements TokenProvider { Future loadToken(String userId) async { final token = await _loader.call(userId); - // Validate that the token's user_id matches the requested userId - if (token.userId != userId) { + // Validate the type before the identity: an anonymous token carries an id + // of its own, so checking the id first would report the wrong problem. + if (token.authType != AuthType.jwt) { throw ArgumentError( - 'User ID mismatch: expected "$userId", got "${token.userId}"', + 'Token type mismatch: expected ${AuthType.jwt.headerValue}, got ${token.authType.headerValue}', ); } - // Validate that the returned token is a JWT token - if (token.authType != AuthType.jwt) { + // Validate that the token's user_id matches the requested userId + if (token.userId != userId) { throw ArgumentError( - 'Token type mismatch: expected ${AuthType.jwt.headerValue}, got ${token.authType.headerValue}', + 'User ID mismatch: expected "$userId", got "${token.userId}"', ); } diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index c79669f3..78b2e4ae 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -82,11 +82,17 @@ void main() { (_) async => UserToken.anonymous(), ); - // Requested id matches the one an anonymous token carries, so this - // reaches the type check rather than failing the user id check first. + // The type is checked first, so this reports the wrong type rather than + // the id an anonymous token happens to carry. expect( - () => provider.loadToken(User.anonymousUserId), - throwsArgumentError, + () => provider.loadToken('user-1'), + throwsA( + isA().having( + (it) => it.message, + 'message', + contains('Token type mismatch'), + ), + ), ); }); From 1ed67182135d91395121630dcda5a77d37977cce Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 16:59:04 +0200 Subject: [PATCH 20/97] fix(llc): stop serving a token for a user the manager has dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things `setTokenProvider` and `reset` made reachable. A load that finishes after `reset` handed its token to the caller. `reset` is a logout: the request that started as that user should not go out as them. It now fails with a `ClientException`, which `AuthInterceptor.onRequest` already turns into a rejected request. A `setTokenProvider` during a load still serves the caller that started it — that request began as the previous user and finishing as them is the defensible reading, and a test pins it. The manager now rejects a token whose `user_id` is not the user it was loading for. Both built-in providers check this, but `TokenProvider` is an `abstract interface class`, so a custom one is under no obligation to — and caching another user's token authenticates every later request as them. `setTokenProvider` no longer expires the cached token when handed the identity it already has, restoring the old setter's no-op. A reconnect or resume path that defensively re-sets the same provider was otherwise hitting the token endpoint every time. Providers compare by identity, so this only applies when the same instance is passed again, which is that case. Also documents that loads are serialised, so a provider that never returns blocks every later caller, including one for a different user configured in the meantime. Bounding that needs a timeout policy the SDK has nowhere to configure yet, so for now it is written down rather than fixed. The test fixtures issued tokens whose `user_id` was a version marker rather than the user being managed — something no real provider could return, and which the new check rejects. They now issue tokens for the user under test and tell two loads apart with a `nonce` claim. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/user/token_manager.dart | 36 ++++++++- .../stream_core/test/helpers/user_token.dart | 10 +-- .../test/user/token_manager_test.dart | 75 ++++++++++++++----- 3 files changed, 94 insertions(+), 27 deletions(-) diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index 1b1ee075..a9abf140 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -96,11 +96,18 @@ class TokenManager { /// tokenProvider: TokenProvider.static(UserToken(rawToken)), /// ); /// ``` + /// Re-setting the identity this manager already has does nothing: expiring + /// the cached token would send the next caller to the provider for no reason. + /// Note that a [TokenProvider] compares by identity, so this only applies + /// when the same instance is passed again. void setTokenProvider( String userId, { required TokenProvider tokenProvider, }) { - _identity = (userId: userId, provider: tokenProvider); + final identity = (userId: userId, provider: tokenProvider); + if (_identity == identity) return; + + _identity = identity; // The cached token belongs to the previous user and provider, so drop it // and let the next `getToken` call load a fresh one. @@ -149,7 +156,12 @@ class TokenManager { /// /// Fails with a [ClientException] when no identity is configured, either /// because the manager was created with [TokenManager.unconfigured] or - /// because [reset] dropped the previous one. + /// because [reset] dropped the previous one, and when [reset] runs while the + /// token is loading. + /// + /// Loads are serialised, so a provider that never returns blocks every later + /// caller — including one for a different user configured by + /// [setTokenProvider] in the meantime. Future getToken() { final cached = _cachedToken; if (cached != null) return Future.value(cached); @@ -174,9 +186,27 @@ class TokenManager { final loadingGeneration = _generation; final updatedToken = await identity.provider.loadToken(loadingFor); + // Both built-in providers check this, but a custom one is under no + // obligation to, and caching a token for another user would authenticate + // every later request as them. + if (updatedToken.userId != loadingFor) { + throw ArgumentError( + 'User ID mismatch: expected "$loadingFor", got "${updatedToken.userId}"', + ); + } + // `setTokenProvider` or `expireToken` may have run while this loaded, in // which case the token is the one the caller asked to stop using. - if (loadingGeneration != _generation) return updatedToken; + if (loadingGeneration != _generation) { + // A `reset` means the user is gone, so nothing may go out as them. A + // switch is different: the request that started as this user may finish + // as them. + if (_identity == null) { + throw ClientException(message: 'The user was reset while its token was loading'); + } + + return updatedToken; + } _cachedToken = updatedToken; _onTokenUpdated?.call(updatedToken); diff --git a/packages/stream_core/test/helpers/user_token.dart b/packages/stream_core/test/helpers/user_token.dart index fb32afd6..457776b4 100644 --- a/packages/stream_core/test/helpers/user_token.dart +++ b/packages/stream_core/test/helpers/user_token.dart @@ -5,21 +5,21 @@ import 'package:stream_core/stream_core.dart'; /// Builds an unsigned JWT carrying [userId] as its 'user_id' claim. /// /// Sufficient for [UserToken]'s unverified parsing — nothing in these tests -/// checks a signature. -String generateTestJwt(String userId) { +/// checks a signature. Pass [nonce] to tell two tokens for the same user apart. +String generateTestJwt(String userId, {String? nonce}) { String b64UrlNoPad(Object jsonObj) { final bytes = utf8.encode(jsonEncode(jsonObj)); return base64Url.encode(bytes).replaceAll('=', ''); } final header = {'alg': 'none', 'typ': 'JWT'}; - final payload = {'user_id': userId}; + final payload = {'user_id': userId, 'nonce': ?nonce}; // Trailing dot = empty signature, which is what alg=none means. return '${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'; } /// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. -UserToken generateTestUserToken(String userId) { - return UserToken(generateTestJwt(userId)); +UserToken generateTestUserToken(String userId, {String? nonce}) { + return UserToken(generateTestJwt(userId, nonce: nonce)); } diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index b4d73c3f..cdebedd5 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -25,7 +25,7 @@ void main() { group('TokenManager', () { group('getToken', () { test('loads from the provider and caches the result', () async { - final provider = _CountingProvider((_) async => generateTestUserToken('token-1')); + final provider = _CountingProvider((_) async => generateTestUserToken('user-1')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, @@ -34,17 +34,17 @@ void main() { final first = await manager.getToken(); final second = await manager.getToken(); - expect(first, generateTestUserToken('token-1')); - expect(second, generateTestUserToken('token-1')); + expect(first, generateTestUserToken('user-1')); + expect(second, generateTestUserToken('user-1')); expect(provider.loadCount, 1); - expect(manager.peekToken(), generateTestUserToken('token-1')); + expect(manager.peekToken(), generateTestUserToken('user-1')); }); test('passes the manager userId to the provider', () async { String? requestedUserId; final provider = _CountingProvider((userId) async { requestedUserId = userId; - return generateTestUserToken('token-1'); + return generateTestUserToken(userId); }); final manager = TokenManager( userId: 'user-1', @@ -65,10 +65,10 @@ void main() { ); final futures = [manager.getToken(), manager.getToken()]; - completer.complete(generateTestUserToken('token-1')); + completer.complete(generateTestUserToken('user-1')); final tokens = await Future.wait(futures); - expect(tokens, everyElement(generateTestUserToken('token-1'))); + expect(tokens, everyElement(generateTestUserToken('user-1'))); expect(provider.loadCount, 1); }); @@ -77,7 +77,7 @@ void main() { final provider = _CountingProvider((_) async { attempts++; if (attempts == 1) throw StateError('load failed'); - return generateTestUserToken('token-2'); + return generateTestUserToken('user-1'); }); final manager = TokenManager( userId: 'user-1', @@ -88,7 +88,7 @@ void main() { expect(manager.peekToken(), isNull); final token = await manager.getToken(); - expect(token, generateTestUserToken('token-2')); + expect(token, generateTestUserToken('user-1')); expect(provider.loadCount, 2); }); }); @@ -96,18 +96,20 @@ void main() { group('expireToken', () { test('clears the cache and forces a reload', () async { var version = 0; - final provider = _CountingProvider((_) async => generateTestUserToken('v${++version}')); + final provider = _CountingProvider( + (userId) async => generateTestUserToken(userId, nonce: 'v${++version}'), + ); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, ); - expect(await manager.getToken(), generateTestUserToken('v1')); + expect(await manager.getToken(), generateTestUserToken('user-1', nonce: 'v1')); manager.expireToken(); expect(manager.peekToken(), isNull); - expect(await manager.getToken(), generateTestUserToken('v2')); + expect(await manager.getToken(), generateTestUserToken('user-1', nonce: 'v2')); expect(provider.loadCount, 2); }); @@ -319,9 +321,39 @@ void main() { manager.reset(); completer.complete(generateTestUserToken('user-1')); - // The caller that started the load is still served, but its token is - // not cached over the reset. - expect((await inFlight).userId, 'user-1'); + // A reset is a logout: the token is neither cached nor handed to the + // caller, so no request goes out as a user the manager no longer has. + await expectLater(inFlight, throwsA(isA())); + expect(manager.peekToken(), isNull); + }); + }); + + group('setTokenProvider', () { + test('keeps the cached token when re-set with the same identity', () async { + final provider = _CountingProvider((userId) async => generateTestUserToken(userId)); + final manager = TokenManager(userId: 'user-1', tokenProvider: provider); + await manager.getToken(); + + manager.setTokenProvider('user-1', tokenProvider: provider); + + // Expiring here would send a caller to the provider for an identity it + // already has, which a defensive re-set on reconnect does routinely. + expect(manager.peekToken(), isNotNull); + await manager.getToken(); + expect(provider.loadCount, 1); + }); + }); + + group('_loadAndNotify', () { + test('rejects a token a custom provider issued for another user', () async { + // Neither built-in provider can do this, but `TokenProvider` is an + // interface: caching it would authenticate later requests as them. + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider((_) async => generateTestUserToken('someone-else')), + ); + + await expectLater(manager.getToken(), throwsArgumentError); expect(manager.peekToken(), isNull); }); }); @@ -346,7 +378,9 @@ void main() { test('fires once per load with the loaded token', () async { final updates = []; var version = 0; - final provider = _CountingProvider((_) async => generateTestUserToken('v${++version}')); + final provider = _CountingProvider( + (userId) async => generateTestUserToken(userId, nonce: 'v${++version}'), + ); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, @@ -359,14 +393,17 @@ void main() { manager.expireToken(); await manager.getToken(); - expect(updates, [generateTestUserToken('v1'), generateTestUserToken('v2')]); + expect(updates, [ + generateTestUserToken('user-1', nonce: 'v1'), + generateTestUserToken('user-1', nonce: 'v2'), + ]); }); test('is invoked before the token is returned', () async { UserToken? notified; final manager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => generateTestUserToken('token-1')), + tokenProvider: _CountingProvider((userId) async => generateTestUserToken(userId)), onTokenUpdated: (token) => notified = token, ); @@ -380,7 +417,7 @@ void main() { Future? reentrantCall; manager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => generateTestUserToken('token-1')), + tokenProvider: _CountingProvider((userId) async => generateTestUserToken(userId)), onTokenUpdated: (_) { reentrantCall = manager.getToken(); }, From 75411444610adae020f3f0667e1cf411eb24a6c7 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 16:59:04 +0200 Subject: [PATCH 21/97] fix(llc): keep the token-expired error when there is no user to refresh for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AuthInterceptor.onError` asked `usesStaticProvider` to decide whether a token-expired error was worth retrying. On a manager that has been `reset` that is `false` — correct for the name, wrong for the question — so the interceptor expired the token and retried, the retry's `getToken` failed for want of an identity, and the caller was handed "Failed to load auth token" in place of the token-expired error the server actually sent. It now asks what it means: there must be a user to load a token for, and a provider capable of returning a different one. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/interceptors/auth_interceptor.dart | 6 ++-- .../interceptors/auth_interceptor_test.dart | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index f810f44e..ba19df0f 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -57,8 +57,10 @@ class AuthInterceptor extends QueuedInterceptor { final error = StreamApiError.fromJson(data); if (error.isTokenExpiredError) { - // Don't try to refresh the token if we're using a static provider - if (_tokenManager.usesStaticProvider) return handler.next(err); + // Don't try to refresh the token when there is no user to load one for, + // or when the provider would return the same token again. + final canRefresh = _tokenManager.userId != null && !_tokenManager.usesStaticProvider; + if (!canRefresh) return handler.next(err); // Otherwise, mark the current token as expired. _tokenManager.expireToken(); diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index deff8dec..5117099b 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -256,6 +256,39 @@ void main() { }, ); + test( + 'forwards a token-expired error without retrying when the manager has no ' + 'identity left to load a token for', + () async { + final tokenManager = TokenManager( + userId: 'user-123', + tokenProvider: TokenProvider.dynamic( + (userId) async => generateTestUserToken(userId), + ), + ); + + final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); + final adapter = _TokenExpiredHttpClientAdapter(onFetch: tokenManager.reset); + dio.httpClientAdapter = adapter; + dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + + await expectLater( + dio.get('/test'), + throwsA( + // Retrying would replace this with the failure to load a token for + // a user the manager no longer has, which says less. + isA().having( + (it) => (it.response?.data as Map?)?['code'], + 'the original token-expired error', + 40, + ), + ), + ); + + expect(adapter.requestCount, 1); + }, + ); + test( 'forwards a token-expired error without retrying when the token manager ' 'is pointed at a static provider after the request was dispatched ' From 49f63f379c60b05a7fa4c33711d20953188f182f Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 16:59:04 +0200 Subject: [PATCH 22/97] docs(llc): record the behaviour changes raised in review The anonymous `user_id=!anon` query parameter is wire-visible and was not in the changelog: the value used to come from the `TokenManager`, so it was whatever the caller configured. The server requires the token's claim to be `!anon` and derives the anonymous session itself, so sending it is consistent rather than merely harmless. Adds the entries for this round of review fixes, and makes the `!anon` claim requirement on `UserToken.anonymous(rawValue:)` explicit. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index baeeb305..d3458136 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -10,7 +10,7 @@ - Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token - Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load -- Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access, provided its `user_id` claim is `User.anonymousUserId` +- Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access, provided its `user_id` claim is `User.anonymousUserId` (`!anon`), which the server also requires - Added `User.anonymousUserId`, the id every anonymous user has - Added `TokenManager.unconfigured`, for a client that exists before its user does - Added `TokenManager.reset`, which drops the configured identity and its cached token @@ -26,6 +26,12 @@ - Raised the minimum Dart SDK to `^3.12.0` - `User` now asserts that a user of type `UserType.anonymous` carries `User.anonymousUserId` as its id +- Anonymous requests now always send `user_id=!anon`. The value previously came from the `TokenManager`, so it was whatever the caller configured; the server requires the claim to be `!anon` and derives the anonymous session itself, so the parameter now matches +- `DynamicTokenProvider` checks the token type before its user id, so a token of the wrong type is reported as such instead of as a mismatched user +- `TokenManager.setTokenProvider` does nothing when handed the identity it already has, instead of expiring the cached token +- `TokenManager.getToken` fails when `reset` runs while the token is loading, instead of returning a token for a user the manager no longer has. A `setTokenProvider` during a load still serves the caller that started it +- `TokenManager.getToken` rejects a token whose `user_id` is not the user it was loading for, which a custom `TokenProvider` is not obliged to check itself +- `AuthInterceptor` no longer attempts a token refresh when the manager has no identity, so the original token-expired error is surfaced rather than a failure to load a token ## 0.4.0 From 64c7af78dce040650ea35cc210d6f670d4caae7a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 17:18:28 +0200 Subject: [PATCH 23/97] fix(llc)!: return the result's own type from Result's failure-side helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getOrElse`, `getOrDefault`, `recover` and `recoverCatching` each declared a type parameter of their own and then cast the success value into it — `Success(: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) --- .../stream_core/lib/src/utils/result.dart | 28 ++--- .../stream_core/test/utils/result_test.dart | 110 ++++++++++++++++++ 2 files changed, 124 insertions(+), 14 deletions(-) create mode 100644 packages/stream_core/test/utils/result_test.dart diff --git a/packages/stream_core/lib/src/utils/result.dart b/packages/stream_core/lib/src/utils/result.dart index 07c66ca7..e29afdce 100644 --- a/packages/stream_core/lib/src/utils/result.dart +++ b/packages/stream_core/lib/src/utils/result.dart @@ -58,8 +58,8 @@ extension PatternMatching on Result { /// Returns the encapsulated value if this instance represents [Success] or `null` /// if it is [Failure]. /// - /// This function is a shorthand for `getOrElse(() => null)` or - /// `fold(onSuccess: (it) => it, onFailure: (_) => null)`. + /// This function is a shorthand for + /// `fold(onSuccess: (it) => it, onFailure: (_, _) => null)`. T? getOrNull() { return switch (this) { Success(:final data) => data, @@ -90,7 +90,7 @@ extension PatternMatching on Result { /// Returns the encapsulated value if this instance represents [Success] or throws the encapsulated error /// if it is [Failure]. /// - /// This function is a shorthand for `getOrElse((error) => throw error)`. + /// This function is a shorthand for `getOrElse((error, _) => throw error)`. T getOrThrow() { return switch (this) { Success(:final data) => data, @@ -107,9 +107,9 @@ extension PatternMatching on Result { /// Note, that this function rethrows any error thrown by [onFailure] function. /// /// This function is a shorthand for `fold(onSuccess: (it) => it, onFailure: onFailure)`. - R getOrElse(R Function(Object error, StackTrace? stackTrace) onFailure) { + T getOrElse(T Function(Object error, StackTrace? stackTrace) onFailure) { return switch (this) { - Success(:final data) => data as R, + Success(:final data) => data, Failure(:final error, :final stackTrace) => onFailure(error, stackTrace), }; } @@ -117,10 +117,10 @@ extension PatternMatching on Result { /// Returns the encapsulated value if this instance represents [Success] or the /// [defaultValue] if it is [Failure]. /// - /// This function is a shorthand for `getOrElse((_) => defaultValue)`. - R getOrDefault(R defaultValue) { + /// This function is a shorthand for `getOrElse((_, _) => defaultValue)`. + T getOrDefault(T defaultValue) { return switch (this) { - Success(:final data) => data as R, + Success(:final data) => data, Failure() => defaultValue, }; } @@ -180,11 +180,11 @@ extension PatternMatching on Result { /// /// Note, that this function rethrows any error thrown by [transform] function. /// See [recoverCatching] for an alternative that encapsulates errors. - Result recover( - R Function(Object error, StackTrace? stackTrace) transform, + Result recover( + T Function(Object error, StackTrace? stackTrace) transform, ) { return switch (this) { - Success(:final data) => Result.success(data as R), + Success() => this, Failure(:final error, :final stackTrace) => Result.success( transform(error, stackTrace), ), @@ -196,11 +196,11 @@ extension PatternMatching on Result { /// /// This function catches any error thrown by [transform] function and encapsulates it as a failure. /// See [recover] for an alternative that rethrows errors. - Result recoverCatching( - R Function(Object error, StackTrace? stackTrace) transform, + Result recoverCatching( + T Function(Object error, StackTrace? stackTrace) transform, ) { return switch (this) { - Success(:final data) => Result.success(data as R), + Success() => this, Failure(:final error, :final stackTrace) => runSafelySync( () => transform(error, stackTrace), ), diff --git a/packages/stream_core/test/utils/result_test.dart b/packages/stream_core/test/utils/result_test.dart new file mode 100644 index 00000000..fb789b81 --- /dev/null +++ b/packages/stream_core/test/utils/result_test.dart @@ -0,0 +1,110 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('Result.getOrElse', () { + test('returns the value when the fallback only throws', () { + const result = Result.success(42); + + // The value used to be cast to the fallback's return type, here `Never`. + final value = result.getOrElse((_, _) => throw StateError('unreachable')); + + expect(value, 42); + }); + + test('throws what the fallback throws, so an error can be reworded', () { + final result = Result.failure(Exception('original')); + + expect( + () => result.getOrElse((error, _) => throw StateError('$error')), + throwsA(isA()), + ); + }); + + test('returns what the fallback returns', () { + final result = Result.failure(Exception('original')); + + expect(result.getOrElse((_, _) => 0), 0); + }); + + test('hands the fallback the error and its stack trace', () { + final stackTrace = StackTrace.current; + final error = Exception('original'); + final result = Result.failure(error, stackTrace); + + Object? seenError; + StackTrace? seenStackTrace; + result.getOrElse((error, stackTrace) { + seenError = error; + seenStackTrace = stackTrace; + return 0; + }); + + expect(seenError, error); + expect(seenStackTrace, stackTrace); + }); + }); + + group('Result.getOrDefault', () { + test('returns the value when there is one', () { + const result = Result.success(42); + + expect(result.getOrDefault(0), 42); + }); + + test('returns the default when there is not', () { + final result = Result.failure(Exception('failed')); + + expect(result.getOrDefault(0), 0); + }); + }); + + group('Result.recover', () { + test('keeps the value when the transform only throws', () { + const result = Result.success(42); + + // Same cast as `getOrElse`. + final recovered = result.recover((_, _) => throw StateError('unreachable')); + + expect(recovered.getOrNull(), 42); + }); + + test('turns a failure into the value the transform returns', () { + final result = Result.failure(Exception('failed')); + + expect(result.recover((_, _) => 0).getOrNull(), 0); + }); + + test('rethrows an error from the transform', () { + final result = Result.failure(Exception('failed')); + + expect( + () => result.recover((_, _) => throw StateError('while recovering')), + throwsA(isA()), + ); + }); + }); + + group('Result.recoverCatching', () { + test('keeps the value when the transform only throws', () { + const result = Result.success(42); + + final recovered = result.recoverCatching( + (_, _) => throw StateError('unreachable'), + ); + + expect(recovered.getOrNull(), 42); + }); + + test('reports an error from the transform as a failure', () { + final result = Result.failure(Exception('failed')); + + final recovered = result.recoverCatching( + (_, _) => throw StateError('while recovering'), + ); + + // Unlike `recover`, the error replaces the original rather than escaping. + expect(recovered.exceptionOrNull(), isA()); + }); + }); +} From 705123f5f407d8563223420b2c71860d8cd5516d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 17:18:28 +0200 Subject: [PATCH 24/97] fix(llc): make a connection going down report why, once, and stay down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ws/client/stream_web_socket_client.dart | 71 ++++++-- .../client/web_socket_connection_state.dart | 7 +- .../client/stream_web_socket_client_test.dart | 167 +++++++++++++++++- .../web_socket_connection_state_test.dart | 6 +- 4 files changed, 227 insertions(+), 24 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index 8a306dff..6ed3f3ba 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -39,9 +39,9 @@ typedef WsSender = Result Function(WsRequest request); /// Called once the socket is open, while the state is [Authenticating]. Sending /// the credentials the server expects is this function's job. /// -/// Returns a [Future] that completes when the credentials have been sent, and -/// fails if they could not be — in which case the connection is closed with -/// [AuthenticationFailed] rather than left waiting for a reply that never comes. +/// Returns a [Result] that fails when the credentials could not be sent, in +/// which case the connection is closed with [AuthenticationFailed] rather than +/// left waiting for a reply that never comes. typedef WebSocketAuthenticator = Future> Function(WsSender send); /// A WebSocket client with connection management and event handling. @@ -64,7 +64,7 @@ typedef WebSocketAuthenticator = Future> Function(WsSender send); /// /// await client.connect(); /// ``` -class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineListener { +class StreamWebSocketClient with Disposable implements WebSocketHealthListener, WebSocketEngineListener { /// Creates a new instance of [StreamWebSocketClient]. StreamWebSocketClient({ required this.optionsBuilder, @@ -120,11 +120,12 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL /// /// Emits state changes as the WebSocket transitions through different connection states. ConnectionStateEmitter get connectionState => _connectionStateEmitter; - late final _connectionStateEmitter = MutableConnectionStateEmitter( - const WebSocketConnectionState.initialized(), - ); + late final _connectionStateEmitter = MutableConnectionStateEmitter(const .initialized()); set _connectionState(WebSocketConnectionState connectionState) { + // Return early if the emitter is closed. + if (_connectionStateEmitter.isClosed) return; + // Return early if the state hasn't changed. if (_connectionStateEmitter.value == connectionState) return; @@ -144,9 +145,15 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL /// /// The connection state can be monitored through [connectionState] for real-time updates. /// If the connection is already established or in progress, this method returns immediately. + /// It also does nothing once [dispose] has been called. /// - /// Returns a [Future] that completes when the connection attempt finishes. + /// Returns a [Future] that completes once the socket is open — before the + /// connection is authenticated, and well before it is [Connected]. Watch + /// [connectionState] to know when it is usable. Future connect() async { + assert(!isDisposed, 'Cannot connect a disposed StreamWebSocketClient'); + if (isDisposed) return; + // If the connection is already established or in the process of connecting, // do not initiate a new connection. if (connectionState.value is Connecting) return; @@ -177,8 +184,11 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL CloseCode closeCode = CloseCode.normalClosure, DisconnectionSource source = const UserInitiated(), }) async { - // If the connection is already disconnected, do nothing. - if (connectionState.value is Disconnected) return; + // A connection already going down keeps the source it started going down + // with: whoever asked first described why. Without this the connect + // timeout could replace a `ServerInitiated` closure, which is + // reconnectable, with one that is not. + if (connectionState.value case Disconnected() || Disconnecting()) return; // Stop the timeout from firing later and replacing this source. _cancelConnectTimeout(); @@ -186,8 +196,33 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL // Update the connection state to 'disconnecting'. _connectionState = WebSocketConnectionState.disconnecting(source: source); - // Close the connection using the engine. - unawaited(_engine.close(closeCode, source.closeReason)); + // Awaited so the connection is closed, rather than merely closing, once this + // returns: a reconnect straight afterwards would otherwise race the close + // and see the connection go down again. + final result = await _engine.close(closeCode, source.closeReason); + + // The engine reports a failed close rather than throwing, and does not + // notify its listener on that path. The connection is unusable either way, + // so report it closed rather than leave it disconnecting for good. + if (result.isFailure) onClose(closeCode, source.closeReason); + } + + /// Releases every resource held by this client. + /// + /// Closes the connection along with [events] and [connectionState], after which + /// this client cannot be connected again. Use [disconnect] for a connection that + /// may be opened again. + /// + /// Returns a [Future] that completes once everything has been released. + @override + Future dispose() async { + await disconnect(); + _healthMonitor.stop(); + + await _events.close(); + await _connectionStateEmitter.close(); + + return super.dispose(); } @override @@ -200,10 +235,18 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL } Future _authenticate() async { - final result = await onAuthenticate?.call(send); + final authenticate = onAuthenticate; + if (authenticate == null) return; + + // Guarded rather than awaited directly: an authenticator that awaits a + // token throws rather than returning a failure, and nothing observes this + // future, so the error would escape and leave the connection + // authenticating until the timeout reported a cause it does not know. + final outcome = await runSafely(() => authenticate(send)); + final result = outcome.flatten(); // Close the connection rather than wait for a reply that cannot come. - if (result?.exceptionOrNull() case final error?) { + if (result.exceptionOrNull() case final error?) { final source = DisconnectionSource.authenticationFailed(error: error); return disconnect(source: source); } diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index 586c8d1f..d0624095 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -115,7 +115,12 @@ sealed class WebSocketConnectionState extends Equatable { UnHealthyConnection() => true, SystemInitiated() => true, UserInitiated() => false, - ConnectTimeout() => false, + // A handshake that did not complete in time is the same failure as a + // connection that stops answering health checks, at an earlier moment. + ConnectTimeout() => true, + // Not the server refusing the credentials, which arrives as an error + // frame: this is the client failing to load or send them, and it will + // fail the same way on a retry. AuthenticationFailed() => false, }, _ => false, // No automatic reconnection for other states diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index e458a599..09724557 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -63,6 +63,8 @@ _client({ when(() => channel.stream).thenAnswer((_) => incoming.stream); final sink = _MockWebSocketSink(); when(() => channel.sink).thenReturn(sink); + // A socket that closes cleanly; tests that need otherwise re-stub this. + when(() => sink.close(any(), any())).thenAnswer((_) async {}); var built = 0; final client = StreamWebSocketClient( @@ -83,6 +85,87 @@ _client({ } void main() { + group('StreamWebSocketClient.disconnect', () { + test('leaves the connection closed, not closing, once it returns', () async { + final (:client, :sink, incoming: _, optionsBuilt: _) = _client(); + await client.connect(); + + await client.disconnect(); + + // A caller that reconnects straight away would otherwise race the close + // and see the connection go down again. + expect(client.connectionState.value, isA()); + }); + + test('reports the connection closed even when the socket close fails', () async { + final (:client, :sink, incoming: _, optionsBuilt: _) = _client(); + when(() => sink.close(any(), any())).thenThrow(Exception('close failed')); + await client.connect(); + + await client.disconnect(); + + // The engine swallows the failure and never notifies its listener, which + // used to leave the connection disconnecting for good. + expect( + client.connectionState.value, + isA().having((it) => it.source, 'source', isA()), + ); + }); + + test('can be followed by another connect', () async { + final (:client, :sink, incoming: _, :optionsBuilt) = _client(); + await client.connect(); + await client.disconnect(); + + await client.connect(); + + expect(client.connectionState.value, isA()); + expect(optionsBuilt(), 2); + }); + }); + + group('StreamWebSocketClient.dispose', () { + test('closes the connection and both emitters', () async { + final (:client, :sink, incoming: _, optionsBuilt: _) = _client(); + await client.connect(); + + await client.dispose(); + + expect(client.isDisposed, isTrue); + expect(client.events.isClosed, isTrue); + expect(client.connectionState.isClosed, isTrue); + }); + + test('does nothing when called again', () async { + final (:client, :sink, incoming: _, optionsBuilt: _) = _client(); + await client.connect(); + await client.dispose(); + + await expectLater(client.dispose(), completes); + }); + + test('refuses to connect again', () async { + final (:client, :sink, incoming: _, :optionsBuilt) = _client(); + await client.connect(); + await client.dispose(); + + // Asserts rather than throws: a reconnect can come from the recovery + // handler, which does not await it and cannot report an error. + await expectLater(client.connect(), throwsA(isA())); + expect(optionsBuilt(), 1); + }); + + test('ignores a socket event arriving after it', () async { + final (:client, :sink, incoming: _, optionsBuilt: _) = _client(); + await client.connect(); + await client.dispose(); + + // The state emitter is closed, so a late event must not be reported into + // it rather than throwing. + expect(() => client.onClose(1000, 'late'), returnsNormally); + }); + }); + group('StreamWebSocketClient.optionsBuilder', () { test('is called for every connection attempt, not once per client', () async { final (:client, :incoming, :optionsBuilt, sink: _) = _client(); @@ -195,7 +278,28 @@ void main() { final state = client.connectionState.value; expect( state, - isA().having( + isA().having( + (it) => it.source, + 'source', + isA().having((it) => it.error, 'error', isStateError), + ), + ); + }); + + test('closes the connection when the authenticator throws', () async { + // The natural authenticator awaits a token, and loading one throws rather + // than returning a failure. Left unguarded the error escapes unhandled and + // the connection waits for the timeout, which knows no cause. + final (:client, :sink, incoming: _, optionsBuilt: _) = _client( + onAuthenticate: (_) async => throw StateError('token load failed'), + ); + + await client.connect(); + await pumpEventQueue(); + + expect( + client.connectionState.value, + isA().having( (it) => it.source, 'source', isA().having((it) => it.error, 'error', isStateError), @@ -235,6 +339,10 @@ void main() { expect(client.connectionState.value, isA()); async.elapse(const Duration(seconds: 1)); + // Only the source can be observed here: `disconnect` awaits the + // subscription cancel, which `fakeAsync` never completes, so the state + // settles at 'disconnecting'. The reached state is covered by the + // `StreamWebSocketClient.disconnect` group. expect( client.connectionState.value, isA().having((it) => it.source, 'source', isA()), @@ -256,6 +364,10 @@ void main() { async.elapse(WebSocketOptions.defaultConnectTimeout); + // Only the source can be observed here: `disconnect` awaits the + // subscription cancel, which `fakeAsync` never completes, so the state + // settles at 'disconnecting'. The reached state is covered by the + // `StreamWebSocketClient.disconnect` group. expect( client.connectionState.value, isA().having((it) => it.source, 'source', isA()), @@ -277,6 +389,10 @@ void main() { async.flushMicrotasks(); async.elapse(WebSocketOptions.defaultConnectTimeout); + // Only the source can be observed here: `disconnect` awaits the + // subscription cancel, which `fakeAsync` never completes, so the state + // settles at 'disconnecting'. The reached state is covered by the + // `StreamWebSocketClient.disconnect` group. expect( client.connectionState.value, isA().having((it) => it.source, 'source', isA()), @@ -295,6 +411,10 @@ void main() { async.elapse(const Duration(seconds: 2)); + // Only the source can be observed here: `disconnect` awaits the + // subscription cancel, which `fakeAsync` never completes, so the state + // settles at 'disconnecting'. The reached state is covered by the + // `StreamWebSocketClient.disconnect` group. expect( client.connectionState.value, isA().having((it) => it.source, 'source', isA()), @@ -319,6 +439,29 @@ void main() { }); }); + test('does not replace the source of a socket error that came first', () { + fakeAsync((async) { + final (:client, incoming: _, optionsBuilt: _, sink: _) = _client(); + + client.connect().ignore(); + async.flushMicrotasks(); + + // A socket error closes the connection without cancelling the timer. + client.onError(StateError('socket died')); + expect(client.connectionState.value, isA()); + + async.elapse(WebSocketOptions.defaultConnectTimeout * 2); + + // Replacing this with `ConnectTimeout` would have made a reconnectable + // failure permanent, since `ServerInitiated` is eligible and the + // timeout used not to be. + expect( + client.connectionState.value, + isA().having((it) => it.source, 'source', isA()), + ); + }); + }); + test('does not replace the source of a disconnect that came first', () { fakeAsync((async) { final (:client, :incoming, optionsBuilt: _, sink: _) = _client(); @@ -332,6 +475,10 @@ void main() { // The timeout would otherwise report this deliberate disconnect as a // timed-out attempt, which reconnects differently. + // Only the source can be observed here: `disconnect` awaits the + // subscription cancel, which `fakeAsync` never completes, so the state + // settles at 'disconnecting'. The reached state is covered by the + // `StreamWebSocketClient.disconnect` group. expect( client.connectionState.value, isA().having((it) => it.source, 'source', isA()), @@ -342,29 +489,37 @@ void main() { group('StreamWebSocketClient health check while disconnecting', () { test('does not report the connection as established again', () async { - final (:client, :incoming, optionsBuilt: _, sink: _) = _client(); + final (:client, :sink, incoming: _, optionsBuilt: _) = _client(); + // Held open so the connection is still closing when the pong arrives. + final closing = Completer(); + when(() => sink.close(any(), any())).thenAnswer((_) => closing.future); await client.connect(); client.onMessage(const _HealthCheckEvent()); expect(client.connectionState.value, isA()); - await client.disconnect(); + client.disconnect().ignore(); expect(client.connectionState.value, isA()); // Arrives before the socket finished closing. client.onMessage(const _HealthCheckEvent(connectionId: 'late')); expect(client.connectionState.value, isA()); + closing.complete(); }); test('leaves the disconnection source intact once the socket closes', () async { - final (:client, :incoming, optionsBuilt: _, sink: _) = _client(); + final (:client, :sink, incoming: _, optionsBuilt: _) = _client(); + final closing = Completer(); + when(() => sink.close(any(), any())).thenAnswer((_) => closing.future); await client.connect(); client.onMessage(const _HealthCheckEvent()); - await client.disconnect(); + client.disconnect().ignore(); client.onMessage(const _HealthCheckEvent(connectionId: 'late')); - client.onClose(); + + closing.complete(); + await pumpEventQueue(); // Without the guard the late health check moves the state back to // connected, and `onClose` then reports a server-initiated disconnect, diff --git a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart index 59d26f43..1ff6bdd9 100644 --- a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart @@ -42,12 +42,12 @@ void main() { }); test( - 'is disabled when a connection attempt timed out, so a handshake that ' - 'never completes is not retried forever', + 'is enabled when a connection attempt timed out, since a handshake that ' + 'did not complete in time is the same failure as one that stopped', () { const state = Disconnected(source: ConnectTimeout()); - expect(state.isAutomaticReconnectionEnabled, isFalse); + expect(state.isAutomaticReconnectionEnabled, isTrue); }, ); From ba79c6a805691e53101b8a2d347d08458cb9dbcb Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 17:18:28 +0200 Subject: [PATCH 25/97] docs(llc): document fromUser and record this round of changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- packages/stream_core/CHANGELOG.md | 10 ++++++++-- .../lib/src/user/connect_user_details_request.dart | 6 ++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 35863dcf..16b7766d 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -8,6 +8,7 @@ - Renamed `StreamWebSocketClient.onConnectionEstablished` to `onAuthenticate`, which is what it is called for and when - `StreamWebSocketClient.onAuthenticate` is now a `WebSocketAuthenticator`: it is handed a `WsSender` and returns a `Result`, so a failure to authenticate can be observed - `TokenManager.userId` is now nullable, and is `null` until an identity is configured +- `Result.getOrElse`, `getOrDefault`, `recover` and `recoverCatching` no longer take a type parameter of their own and return the result's own type. They previously cast the value to the callback's type, which failed on a successful result — most visibly for a callback that only throws. Use `fold` where the return type has to differ ### ✨ Features @@ -17,11 +18,12 @@ - Added `User.anonymousUserId`, the id every anonymous user has - Added `TokenManager.unconfigured`, for a client that exists before its user does - Added `TokenManager.reset`, which drops the configured identity and its cached token -- Added `DisconnectionSource.connectTimeout`, reported when a connection attempt is abandoned before the connection is established +- Added `DisconnectionSource.connectTimeout`, reported when a connection attempt is abandoned before the connection is established; it is eligible for automatic reconnection, since a handshake that did not complete in time is the same failure as a connection that stops answering health checks - Added `DisconnectionSource.authenticationFailed`, reported with its cause when a connection opens but cannot be authenticated -- `StreamWebSocketClient` now honours `WebSocketOptions.connectTimeout`, which is no longer nullable and defaults to `WebSocketOptions.defaultConnectTimeout` +- `StreamWebSocketClient` now honours `WebSocketOptions.connectTimeout`, which is no longer nullable and defaults to `WebSocketOptions.defaultConnectTimeout`. This is a behaviour change as well as an API one: a connection previously waited indefinitely for its first health check, and is now abandoned — and reconnected — after 15 seconds - Added `WsSender`, the send capability handed to a `WebSocketAuthenticator` - Added `ConnectUserDetailsRequest.fromUser`, which builds the details a client may send from a `User` +- Added `StreamWebSocketClient.dispose`, which closes the connection along with `events` and `connectionState`; the client is `Disposable`, so `isDisposed` reports whether it has been called - Added `teams` field to `User` class ### 🐛 Bug Fixes @@ -29,6 +31,10 @@ - Fixed `TokenManager.getToken()` contacting the `TokenProvider` on every call instead of returning the cached token - Fixed `DynamicTokenProvider` accepting a token issued for a different user than the one requested - Fixed `TokenManager` caching a token that finished loading after `expireToken` or `setTokenProvider` had invalidated it +- Fixed `StreamWebSocketClient.disconnect` completing before the socket was closed, so a `connect` straight afterwards raced the closure and saw the connection go down again +- Fixed a failure to close the socket leaving `StreamWebSocketClient` reporting itself as disconnecting for good, since the engine reports such a failure rather than notifying its listener +- Fixed a `WebSocketAuthenticator` that throws, rather than returning a failure, escaping as an unhandled error and leaving the connection authenticating until it timed out — losing the cause, which the timeout does not carry. The natural authenticator throws, since loading a token does +- Fixed `StreamWebSocketClient.disconnect` replacing the source of a closure already under way, which could turn a reconnectable `ServerInitiated` error into a permanent `ConnectTimeout` - Fixed a health check arriving while disconnecting reporting the connection as established again, which replaced the disconnection source and could turn a deliberate disconnect into an automatic reconnect ### 🔄 Changed diff --git a/packages/stream_core/lib/src/user/connect_user_details_request.dart b/packages/stream_core/lib/src/user/connect_user_details_request.dart index fea5a7ef..7e27d170 100644 --- a/packages/stream_core/lib/src/user/connect_user_details_request.dart +++ b/packages/stream_core/lib/src/user/connect_user_details_request.dart @@ -15,6 +15,12 @@ class ConnectUserDetailsRequest { this.custom, }); + /// Creates the details a client may send when connecting as [user]. + /// + /// A user's role and teams are left out: the server assigns both and does not + /// accept them from a client. + /// + /// Pass [includeDetails] as `false` to send the id alone. factory ConnectUserDetailsRequest.fromUser( User user, { bool includeDetails = true, From bc46b99f461d61ceae1bc08362f222cc16e4c3bf Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 17:24:59 +0200 Subject: [PATCH 26/97] docs(llc): show how to widen a Result now that the helpers do not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kotlin's `getOrElse`, `getOrDefault`, `recover` and `recoverCatching` widen through a second type parameter bounded by the receiver's — `` — which is what makes their `value as T` sound. Dart has upper bounds only, so the bound cannot be stated and the previous `` 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 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) --- packages/stream_core/CHANGELOG.md | 2 +- packages/stream_core/lib/src/utils/result.dart | 7 +++++++ packages/stream_core/test/utils/result_test.dart | 13 +++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 16b7766d..cf897343 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -8,7 +8,7 @@ - Renamed `StreamWebSocketClient.onConnectionEstablished` to `onAuthenticate`, which is what it is called for and when - `StreamWebSocketClient.onAuthenticate` is now a `WebSocketAuthenticator`: it is handed a `WsSender` and returns a `Result`, so a failure to authenticate can be observed - `TokenManager.userId` is now nullable, and is `null` until an identity is configured -- `Result.getOrElse`, `getOrDefault`, `recover` and `recoverCatching` no longer take a type parameter of their own and return the result's own type. They previously cast the value to the callback's type, which failed on a successful result — most visibly for a callback that only throws. Use `fold` where the return type has to differ +- `Result.getOrElse`, `getOrDefault`, `recover` and `recoverCatching` no longer take a type parameter of their own and return the result's own type. They previously cast the value to the callback's type, which failed on a successful result — most visibly for a callback that only throws. Kotlin's equivalents widen through a `` bound that Dart cannot express; to widen here, name the wider type on the result (`Result widened = intResult`), which works because `Result` is covariant, or use `fold` ### ✨ Features diff --git a/packages/stream_core/lib/src/utils/result.dart b/packages/stream_core/lib/src/utils/result.dart index e29afdce..7f33c313 100644 --- a/packages/stream_core/lib/src/utils/result.dart +++ b/packages/stream_core/lib/src/utils/result.dart @@ -107,6 +107,10 @@ extension PatternMatching on Result { /// Note, that this function rethrows any error thrown by [onFailure] function. /// /// This function is a shorthand for `fold(onSuccess: (it) => it, onFailure: onFailure)`. + /// + /// [onFailure] returns this result's own type. To fall back to a supertype, + /// widen the result first — `Result widened = intResult` — or use [fold], + /// which takes its return type from both branches. T getOrElse(T Function(Object error, StackTrace? stackTrace) onFailure) { return switch (this) { Success(:final data) => data, @@ -118,6 +122,9 @@ extension PatternMatching on Result { /// [defaultValue] if it is [Failure]. /// /// This function is a shorthand for `getOrElse((_, _) => defaultValue)`. + /// + /// [defaultValue] is of this result's own type; widen the result to fall back + /// to a supertype. T getOrDefault(T defaultValue) { return switch (this) { Success(:final data) => data, diff --git a/packages/stream_core/test/utils/result_test.dart b/packages/stream_core/test/utils/result_test.dart index fb789b81..92234e36 100644 --- a/packages/stream_core/test/utils/result_test.dart +++ b/packages/stream_core/test/utils/result_test.dart @@ -45,6 +45,19 @@ void main() { }); }); + group('Result widening', () { + test('falls back to a supertype when the result is widened', () { + // Kotlin widens through a `` bound Dart has no equivalent for. + // Naming the wider type on the result gets there instead, since `Result` + // is covariant. + final Result widened = Result.failure(Exception('failed')); + + expect(widened.getOrElse((_, _) => 0.5), 0.5); + expect(widened.getOrDefault(0.5), 0.5); + expect(widened.recover((_, _) => 0.5).getOrNull(), 0.5); + }); + }); + group('Result.getOrDefault', () { test('returns the value when there is one', () { const result = Result.success(42); From 39952e56de91426562e95469506e63c52ae27d1b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 17:27:32 +0200 Subject: [PATCH 27/97] docs(llc): note where widening happens for recover `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` there is nothing left to widen. Kotlin's returns `Result` 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) --- packages/stream_core/lib/src/utils/result.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/stream_core/lib/src/utils/result.dart b/packages/stream_core/lib/src/utils/result.dart index 7f33c313..0032e597 100644 --- a/packages/stream_core/lib/src/utils/result.dart +++ b/packages/stream_core/lib/src/utils/result.dart @@ -187,6 +187,9 @@ extension PatternMatching on Result { /// /// Note, that this function rethrows any error thrown by [transform] function. /// See [recoverCatching] for an alternative that encapsulates errors. + /// + /// [transform] returns this result's own type, so widening is done on the way + /// in rather than on the way out: widen the result first, then recover. Result recover( T Function(Object error, StackTrace? stackTrace) transform, ) { @@ -203,6 +206,9 @@ extension PatternMatching on Result { /// /// This function catches any error thrown by [transform] function and encapsulates it as a failure. /// See [recover] for an alternative that rethrows errors. + /// + /// [transform] returns this result's own type, so widening is done on the way + /// in rather than on the way out: widen the result first, then recover. Result recoverCatching( T Function(Object error, StackTrace? stackTrace) transform, ) { From c03f7fd16be23f871aa8ba9699b8727be4cca167 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 17:33:41 +0200 Subject: [PATCH 28/97] fix(llc): compare a replacement provider by instance, not by equality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The no-op guard added for a defensive re-set compared the whole identity record, which delegates the provider to `TokenProvider.operator ==`. A provider defines its own equality — `TokenProvider` is an interface, so one may well compare by value — and a replacement that calls itself equal to the outgoing provider would be dropped along with the cache invalidation it was meant to trigger, leaving the manager serving the previous provider's token. The user id is still compared by value; the provider now by instance, which is the case the guard exists for: the same instance handed back on a reconnect. Erring the other way costs a token load that was not needed; erring this way authenticates as the wrong provider's token. Also lists the `User` anonymous-id invariant as a breaking change rather than a behavioural note. Its constructor is `const`, so a mismatch in a const context does not throw in debug mode — it fails to compile: error - Evaluation of this constant expression throws an exception const_eval_throws_exception Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- .../lib/src/user/token_manager.dart | 13 ++++--- .../test/user/token_manager_test.dart | 38 +++++++++++++++++++ 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index d3458136..9983e74f 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -5,6 +5,7 @@ - Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `User.anonymousUserId` - Removed the `TokenManager.tokenProvider` setter, use `setTokenProvider` instead - `TokenManager.userId` is now nullable, and is `null` until an identity is configured +- `User` now requires a user of type `UserType.anonymous` to carry `User.anonymousUserId` as its id. The constructor is `const`, so a mismatch in a const context fails to compile rather than throwing in debug mode ### ✨ Features @@ -25,7 +26,6 @@ ### 🔄 Changed - Raised the minimum Dart SDK to `^3.12.0` -- `User` now asserts that a user of type `UserType.anonymous` carries `User.anonymousUserId` as its id - Anonymous requests now always send `user_id=!anon`. The value previously came from the `TokenManager`, so it was whatever the caller configured; the server requires the claim to be `!anon` and derives the anonymous session itself, so the parameter now matches - `DynamicTokenProvider` checks the token type before its user id, so a token of the wrong type is reported as such instead of as a mismatched user - `TokenManager.setTokenProvider` does nothing when handed the identity it already has, instead of expiring the cached token diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index a9abf140..efff2064 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -98,16 +98,19 @@ class TokenManager { /// ``` /// Re-setting the identity this manager already has does nothing: expiring /// the cached token would send the next caller to the provider for no reason. - /// Note that a [TokenProvider] compares by identity, so this only applies - /// when the same instance is passed again. + /// The provider is compared by instance, so this only applies when the same + /// one is passed again. void setTokenProvider( String userId, { required TokenProvider tokenProvider, }) { - final identity = (userId: userId, provider: tokenProvider); - if (_identity == identity) return; + // Compared with `identical` rather than `==`: a provider defines its own + // equality, and one that calls itself equal to another would keep the + // provider and the cached token this call means to replace. + final unchanged = userId == this.userId && identical(tokenProvider, _identity?.provider); + if (unchanged) return; - _identity = identity; + _identity = (userId: userId, provider: tokenProvider); // The cached token belongs to the previous user and provider, so drop it // and let the next `getToken` call load a fresh one. diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index cdebedd5..36624d50 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -1,10 +1,29 @@ import 'dart:async'; +import 'package:meta/meta.dart'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; import '../helpers/user_token.dart'; +/// A token provider that reports itself equal to any other of its kind, the way +/// a provider with value equality can. +@immutable +class _EquatableProvider implements TokenProvider { + const _EquatableProvider(this._token); + + final UserToken _token; + + @override + Future loadToken(String userId) async => _token; + + @override + bool operator ==(Object other) => other is _EquatableProvider; + + @override + int get hashCode => 0; +} + /// A token provider that counts loads and delegates to a configurable loader. class _CountingProvider implements TokenProvider { _CountingProvider(this._load); @@ -342,6 +361,25 @@ void main() { await manager.getToken(); expect(provider.loadCount, 1); }); + + test('replaces a provider that merely compares equal to the previous one', () async { + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _EquatableProvider(generateTestUserToken('user-1', nonce: 'first')), + ); + expect(await manager.getToken(), generateTestUserToken('user-1', nonce: 'first')); + + manager.setTokenProvider( + 'user-1', + tokenProvider: _EquatableProvider(generateTestUserToken('user-1', nonce: 'second')), + ); + + // A provider defines its own equality, so keeping the cached token + // because the replacement called itself equal would serve a token the + // previous provider issued. + expect(manager.peekToken(), isNull); + expect(await manager.getToken(), generateTestUserToken('user-1', nonce: 'second')); + }); }); group('_loadAndNotify', () { From b0f94d1ce18aa2010b81534580b55414ae99da80 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 17:39:53 +0200 Subject: [PATCH 29/97] fix(llc): do not open a socket while the previous one is still closing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `connect` guarded `connecting`, `authenticating` and `connected` but not `disconnecting`, so it proceeded while an old socket was on its way out. The old socket's close event then reported the new connection as `Disconnected(ServerInitiated)` — and, since `onClose` cancels the connect timeout, disarmed the timer watching the new attempt, leaving it authenticating with nothing to bound it. Awaiting the engine's close made the sequential case safe; this covers the caller that does not await. Raised in review of #160 as pre-existing. Co-Authored-By: Claude Opus 5 (1M context) --- .../ws/client/stream_web_socket_client.dart | 9 +++++++-- .../client/stream_web_socket_client_test.dart | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index 6ed3f3ba..4c1d5ff4 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -144,8 +144,8 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, /// Establishes a WebSocket connection. /// /// The connection state can be monitored through [connectionState] for real-time updates. - /// If the connection is already established or in progress, this method returns immediately. - /// It also does nothing once [dispose] has been called. + /// If the connection is already established or in progress, this method returns immediately, + /// as it does while a previous connection is still closing, and once [dispose] has been called. /// /// Returns a [Future] that completes once the socket is open — before the /// connection is authenticated, and well before it is [Connected]. Watch @@ -160,6 +160,11 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, if (connectionState.value is Authenticating) return; if (connectionState.value is Connected) return; + // Nor while a previous connection is still closing: the socket it opened + // would be brought down by the old one's close event, which would also + // disarm the new attempt's timeout and leave it authenticating unwatched. + if (connectionState.value is Disconnecting) return; + // Update the connection state to 'connecting'. _connectionState = const WebSocketConnectionState.connecting(); diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index 09724557..5d53277f 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -112,6 +112,24 @@ void main() { ); }); + test('does not open a socket while the previous one is still closing', () async { + final (:client, :sink, incoming: _, :optionsBuilt) = _client(); + // Held open so the connection is still closing when connect is called. + final closing = Completer(); + when(() => sink.close(any(), any())).thenAnswer((_) => closing.future); + await client.connect(); + + client.disconnect().ignore(); + expect(client.connectionState.value, isA()); + await client.connect(); + + // The old socket's close event would otherwise bring the new connection + // down and disarm the timeout meant to be watching it. + expect(optionsBuilt(), 1); + expect(client.connectionState.value, isA()); + closing.complete(); + }); + test('can be followed by another connect', () async { final (:client, :sink, incoming: _, :optionsBuilt) = _client(); await client.connect(); From 0605c016356b2a45c987569a78702529c796798a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 17:41:31 +0200 Subject: [PATCH 30/97] feat(llc): bound a token load so one provider cannot block the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getToken` serialises loads through `synchronized`, so a provider that never returns held the lock for good: every later caller waited with it, including one for a different user that `setTokenProvider` had since configured. The thing that hangs is usually a customer's own token endpoint, so it is not an exotic case — it predates this PR, but `setTokenProvider` is what makes it reachable for a user who has nothing to do with the hung request. A load now fails with a `ClientException` after `loadTimeout`, ten seconds by default and configurable per manager. Dart cannot cancel the provider, so a slow one keeps running; what changes is that it no longer holds the lock, and the cache is invalidated as it gives up so whatever the abandoned load eventually returns is discarded rather than served to a later caller. Adds `fake_async` as a dev dependency, so the timeout tests do not spend ten seconds each. Co-Authored-By: Claude Opus 5 (1M context) --- melos.yaml | 1 + packages/stream_core/CHANGELOG.md | 1 + .../lib/src/user/token_manager.dart | 39 +++++++++++-- packages/stream_core/pubspec.yaml | 1 + .../test/user/token_manager_test.dart | 58 +++++++++++++++++++ 5 files changed, 95 insertions(+), 5 deletions(-) diff --git a/melos.yaml b/melos.yaml index adda26e6..b2641fe8 100644 --- a/melos.yaml +++ b/melos.yaml @@ -53,6 +53,7 @@ command: dev_dependencies: alchemist: ^0.13.0 build_runner: ^2.10.5 + fake_async: ^1.3.3 json_serializable: ^6.9.5 melos: ^6.2.0 mocktail: ^1.0.4 diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 9983e74f..8e8b20aa 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -15,6 +15,7 @@ - Added `User.anonymousUserId`, the id every anonymous user has - Added `TokenManager.unconfigured`, for a client that exists before its user does - Added `TokenManager.reset`, which drops the configured identity and its cached token +- Added `TokenManager.loadTimeout`, which bounds a single token load and defaults to `TokenManager.defaultLoadTimeout`. Loads are serialised, so a provider that never returned used to block every later caller indefinitely - Added `teams` field to `User` class ### 🐛 Bug Fixes diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index efff2064..0f215bb3 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -42,9 +42,12 @@ class TokenManager { /// /// An optional `onTokenUpdated` callback is invoked after every successful /// token load. It is not invoked for callers served from the cache. + /// + /// `loadTimeout` bounds a single load; see [getToken]. TokenManager({ required String userId, required TokenProvider tokenProvider, + this.loadTimeout = defaultLoadTimeout, this._onTokenUpdated, }) : _identity = (userId: userId, provider: tokenProvider); @@ -53,7 +56,18 @@ class TokenManager { /// [getToken] fails until [setTokenProvider] supplies one. Distinct from a /// manager holding an anonymous identity, which is a user that can load a /// token; this one has no user at all. - TokenManager.unconfigured({this._onTokenUpdated}) : _identity = null; + TokenManager.unconfigured({ + this.loadTimeout = defaultLoadTimeout, + this._onTokenUpdated, + }) : _identity = null; + + /// How long a single token load may take before it fails. + /// + /// Loads are serialised, so an unbounded one would block every later caller. + final Duration loadTimeout; + + /// The [loadTimeout] used when none is given. + static const defaultLoadTimeout = Duration(seconds: 10); // The user being managed and the provider that loads their tokens. // @@ -162,9 +176,11 @@ class TokenManager { /// because [reset] dropped the previous one, and when [reset] runs while the /// token is loading. /// - /// Loads are serialised, so a provider that never returns blocks every later - /// caller — including one for a different user configured by - /// [setTokenProvider] in the meantime. + /// Loads are serialised, so a load is given [loadTimeout] to finish and fails + /// with a [ClientException] once it elapses. Dart cannot cancel the provider, + /// so a slow one keeps running — but it no longer holds up the callers behind + /// it, including one for a different user configured by [setTokenProvider] in + /// the meantime, and whatever it eventually returns is discarded. Future getToken() { final cached = _cachedToken; if (cached != null) return Future.value(cached); @@ -187,7 +203,20 @@ class TokenManager { final loadingFor = identity.userId; final loadingGeneration = _generation; - final updatedToken = await identity.provider.loadToken(loadingFor); + final updatedToken = await identity.provider + .loadToken(loadingFor) + .timeout( + loadTimeout, + onTimeout: () { + // Invalidates the cache as well as failing, so the load this gave up on + // cannot cache a token later on. + expireToken(); + + throw ClientException( + message: 'Timed out after $loadTimeout loading a token for "$loadingFor"', + ); + }, + ); // Both built-in providers check this, but a custom one is under no // obligation to, and caching a token for another user would authenticate diff --git a/packages/stream_core/pubspec.yaml b/packages/stream_core/pubspec.yaml index c28d1a61..7fa4b4fa 100644 --- a/packages/stream_core/pubspec.yaml +++ b/packages/stream_core/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: dev_dependencies: build_runner: ^2.10.5 + fake_async: ^1.3.3 json_serializable: ^6.9.5 mocktail: ^1.0.4 test: ^1.26.2 diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 36624d50..c3497001 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:fake_async/fake_async.dart'; import 'package:meta/meta.dart'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; @@ -382,6 +383,63 @@ void main() { }); }); + group('loadTimeout', () { + test('fails a load that never returns and lets later callers through', () { + fakeAsync((async) { + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider((_) => Completer().future), + loadTimeout: const Duration(seconds: 5), + ); + + Object? error; + manager.getToken().onError((it, _) { + error = it; + return generateTestUserToken('user-1'); + }); + + async.elapse(const Duration(seconds: 5)); + async.flushMicrotasks(); + + expect(error, isA()); + + // The point of failing rather than waiting: the lock is free, so a + // working provider can serve the next caller. + UserToken? served; + manager.setTokenProvider( + 'user-1', + tokenProvider: _CountingProvider((userId) async => generateTestUserToken(userId)), + ); + manager.getToken().then((it) => served = it); + async.flushMicrotasks(); + + expect(served, generateTestUserToken('user-1')); + }); + }); + + test('discards a token the load it gave up on returns later', () { + fakeAsync((async) { + final slow = Completer(); + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider((_) => slow.future), + loadTimeout: const Duration(seconds: 5), + ); + + manager.getToken().ignore(); + async.elapse(const Duration(seconds: 5)); + async.flushMicrotasks(); + + slow.complete(generateTestUserToken('user-1')); + async.flushMicrotasks(); + + // Caching it would hand a caller a token from a load already reported + // as failed. + expect(manager.peekToken(), isNull); + }); + }); + }); + group('_loadAndNotify', () { test('rejects a token a custom provider issued for another user', () async { // Neither built-in provider can do this, but `TokenProvider` is an From 19a1a91b024e370e6c64bbfc13d042afa6efc64c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 17:54:46 +0200 Subject: [PATCH 31/97] Revert "feat(llc): bound a token load so one provider cannot block the rest" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 0605c01, keeping the `fake_async` dev dependency it added since #160 uses it. The timeout was the wrong instrument. The failure it was meant to address is a load for the user who is gone blocking the user who replaced them, and its cause is that `getToken` serialises across identities — not that a load takes too long. A timeout papers over that by failing everyone once it elapses, including the caller who did nothing wrong, and imposes a default on a token endpoint whose timeout the customer already owns: shorter than theirs, and it silently fails loads that would have succeeded. The serialisation remains documented on `getToken`, which was what review asked for as a minimum. The targeted fix, if we want one, is a lock per identity, so a hang for the departed user cannot hold up the one that replaced them. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 1 - .../lib/src/user/token_manager.dart | 39 ++----------- .../test/user/token_manager_test.dart | 58 ------------------- 3 files changed, 5 insertions(+), 93 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 8e8b20aa..9983e74f 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -15,7 +15,6 @@ - Added `User.anonymousUserId`, the id every anonymous user has - Added `TokenManager.unconfigured`, for a client that exists before its user does - Added `TokenManager.reset`, which drops the configured identity and its cached token -- Added `TokenManager.loadTimeout`, which bounds a single token load and defaults to `TokenManager.defaultLoadTimeout`. Loads are serialised, so a provider that never returned used to block every later caller indefinitely - Added `teams` field to `User` class ### 🐛 Bug Fixes diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index 0f215bb3..efff2064 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -42,12 +42,9 @@ class TokenManager { /// /// An optional `onTokenUpdated` callback is invoked after every successful /// token load. It is not invoked for callers served from the cache. - /// - /// `loadTimeout` bounds a single load; see [getToken]. TokenManager({ required String userId, required TokenProvider tokenProvider, - this.loadTimeout = defaultLoadTimeout, this._onTokenUpdated, }) : _identity = (userId: userId, provider: tokenProvider); @@ -56,18 +53,7 @@ class TokenManager { /// [getToken] fails until [setTokenProvider] supplies one. Distinct from a /// manager holding an anonymous identity, which is a user that can load a /// token; this one has no user at all. - TokenManager.unconfigured({ - this.loadTimeout = defaultLoadTimeout, - this._onTokenUpdated, - }) : _identity = null; - - /// How long a single token load may take before it fails. - /// - /// Loads are serialised, so an unbounded one would block every later caller. - final Duration loadTimeout; - - /// The [loadTimeout] used when none is given. - static const defaultLoadTimeout = Duration(seconds: 10); + TokenManager.unconfigured({this._onTokenUpdated}) : _identity = null; // The user being managed and the provider that loads their tokens. // @@ -176,11 +162,9 @@ class TokenManager { /// because [reset] dropped the previous one, and when [reset] runs while the /// token is loading. /// - /// Loads are serialised, so a load is given [loadTimeout] to finish and fails - /// with a [ClientException] once it elapses. Dart cannot cancel the provider, - /// so a slow one keeps running — but it no longer holds up the callers behind - /// it, including one for a different user configured by [setTokenProvider] in - /// the meantime, and whatever it eventually returns is discarded. + /// Loads are serialised, so a provider that never returns blocks every later + /// caller — including one for a different user configured by + /// [setTokenProvider] in the meantime. Future getToken() { final cached = _cachedToken; if (cached != null) return Future.value(cached); @@ -203,20 +187,7 @@ class TokenManager { final loadingFor = identity.userId; final loadingGeneration = _generation; - final updatedToken = await identity.provider - .loadToken(loadingFor) - .timeout( - loadTimeout, - onTimeout: () { - // Invalidates the cache as well as failing, so the load this gave up on - // cannot cache a token later on. - expireToken(); - - throw ClientException( - message: 'Timed out after $loadTimeout loading a token for "$loadingFor"', - ); - }, - ); + final updatedToken = await identity.provider.loadToken(loadingFor); // Both built-in providers check this, but a custom one is under no // obligation to, and caching a token for another user would authenticate diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index c3497001..36624d50 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:fake_async/fake_async.dart'; import 'package:meta/meta.dart'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; @@ -383,63 +382,6 @@ void main() { }); }); - group('loadTimeout', () { - test('fails a load that never returns and lets later callers through', () { - fakeAsync((async) { - final manager = TokenManager( - userId: 'user-1', - tokenProvider: _CountingProvider((_) => Completer().future), - loadTimeout: const Duration(seconds: 5), - ); - - Object? error; - manager.getToken().onError((it, _) { - error = it; - return generateTestUserToken('user-1'); - }); - - async.elapse(const Duration(seconds: 5)); - async.flushMicrotasks(); - - expect(error, isA()); - - // The point of failing rather than waiting: the lock is free, so a - // working provider can serve the next caller. - UserToken? served; - manager.setTokenProvider( - 'user-1', - tokenProvider: _CountingProvider((userId) async => generateTestUserToken(userId)), - ); - manager.getToken().then((it) => served = it); - async.flushMicrotasks(); - - expect(served, generateTestUserToken('user-1')); - }); - }); - - test('discards a token the load it gave up on returns later', () { - fakeAsync((async) { - final slow = Completer(); - final manager = TokenManager( - userId: 'user-1', - tokenProvider: _CountingProvider((_) => slow.future), - loadTimeout: const Duration(seconds: 5), - ); - - manager.getToken().ignore(); - async.elapse(const Duration(seconds: 5)); - async.flushMicrotasks(); - - slow.complete(generateTestUserToken('user-1')); - async.flushMicrotasks(); - - // Caching it would hand a caller a token from a load already reported - // as failed. - expect(manager.peekToken(), isNull); - }); - }); - }); - group('_loadAndNotify', () { test('rejects a token a custom provider issued for another user', () async { // Neither built-in provider can do this, but `TokenProvider` is an From e3d700bfc4385849eb3ef4c28342185b2f2be5f4 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 17:56:50 +0200 Subject: [PATCH 32/97] fix(llc): allow 30 seconds for a connection to establish, not 15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default I picked for `connectTimeout` matched neither sibling SDK: Swift feeds waits 30 seconds for the same handshake (`FeedsClient+Connection.swift:62`) and Android core defaults to 10 (`StreamSocketConfig.kt:96`). Fifteen was a number, not a decision. Aligning with the more forgiving of the two is the right way round here, since this timeout went from absent to mandatory in this PR: the customer it can hurt is the one whose backend is slow to send its first health check, and review raised exactly that case. It is per-attempt and configurable, so anyone wanting Android's stricter bound can set it. One test now supplies its own five-second timeout rather than using the default: the default outlives the health monitor's first missed pong (25s + 3s), so elapsing past it on an established connection reports an unhealthy connection instead. That ordering is fine in production — the monitor only runs once a connection is established, and this timeout only bounds getting there — but it leaves no window for a test that wants to elapse past one and not the other. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- .../lib/src/ws/client/engine/web_socket_options.dart | 5 ++++- .../test/ws/client/stream_web_socket_client_test.dart | 11 +++++++---- .../ws/client/web_socket_connection_state_test.dart | 2 +- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 5db81fe9..ec78ef12 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -21,7 +21,7 @@ - Added `TokenManager.reset`, which drops the configured identity and its cached token - Added `DisconnectionSource.connectTimeout`, reported when a connection attempt is abandoned before the connection is established; it is eligible for automatic reconnection, since a handshake that did not complete in time is the same failure as a connection that stops answering health checks - Added `DisconnectionSource.authenticationFailed`, reported with its cause when a connection opens but cannot be authenticated -- `StreamWebSocketClient` now honours `WebSocketOptions.connectTimeout`, which is no longer nullable and defaults to `WebSocketOptions.defaultConnectTimeout`. This is a behaviour change as well as an API one: a connection previously waited indefinitely for its first health check, and is now abandoned — and reconnected — after 15 seconds +- `StreamWebSocketClient` now honours `WebSocketOptions.connectTimeout`, which is no longer nullable and defaults to `WebSocketOptions.defaultConnectTimeout`. This is a behaviour change as well as an API one: a connection previously waited indefinitely for its first health check, and is now abandoned — and reconnected — after 30 seconds, matching the wait the Swift SDK allows for the same handshake - Added `WsSender`, the send capability handed to a `WebSocketAuthenticator` - Added `ConnectUserDetailsRequest.fromUser`, which builds the details a client may send from a `User` - Added `StreamWebSocketClient.dispose`, which closes the connection along with `events` and `connectionState`; the client is `Disposable`, so `isDisposed` reports whether it has been called diff --git a/packages/stream_core/lib/src/ws/client/engine/web_socket_options.dart b/packages/stream_core/lib/src/ws/client/engine/web_socket_options.dart index f4295085..e2bd5be9 100644 --- a/packages/stream_core/lib/src/ws/client/engine/web_socket_options.dart +++ b/packages/stream_core/lib/src/ws/client/engine/web_socket_options.dart @@ -42,7 +42,10 @@ class WebSocketOptions { final Duration connectTimeout; /// The [connectTimeout] used when none is given. - static const defaultConnectTimeout = Duration(seconds: 15); + /// + /// Matches the wait the Swift SDK allows for the same handshake; the Android + /// one is stricter at ten seconds. + static const defaultConnectTimeout = Duration(seconds: 30); /// WebSocket sub-protocols to negotiate during the handshake. /// diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index 5d53277f..441d5212 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -442,16 +442,19 @@ void main() { test('does not fire once the connection is established', () { fakeAsync((async) { - final (:client, :incoming, optionsBuilt: _, sink: _) = _client(); + // A timeout of its own, well short of the health monitor: the default + // one outlives the monitor's first missed pong, so elapsing past it + // would report an unhealthy connection instead. + final (:client, :incoming, optionsBuilt: _, sink: _) = _client( + connectTimeout: const Duration(seconds: 5), + ); client.connect().ignore(); async.flushMicrotasks(); client.onMessage(const _HealthCheckEvent()); expect(client.connectionState.value, isA()); - // Past when the timeout would have fired, but before the health - // monitor's first ping is due. - async.elapse(WebSocketOptions.defaultConnectTimeout + const Duration(seconds: 1)); + async.elapse(const Duration(seconds: 6)); expect(client.connectionState.value, isA()); }); diff --git a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart index 1ff6bdd9..17a414f4 100644 --- a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart @@ -91,7 +91,7 @@ void main() { const options = WebSocketOptions(url: 'wss://example.com'); expect(options.connectTimeout, WebSocketOptions.defaultConnectTimeout); - expect(WebSocketOptions.defaultConnectTimeout, const Duration(seconds: 15)); + expect(WebSocketOptions.defaultConnectTimeout, const Duration(seconds: 30)); }); }); } From 7c772d58c6fb243fcdd5205f02e02a83f1c3c2a4 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 18:23:11 +0200 Subject: [PATCH 33/97] test(llc): keep a connection alive the way production does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test proving the connect timeout stops mattering once a connection is established sized its window from `defaultConnectTimeout` — a number it does not care about — and so depended on that default staying under the health monitor's 28 seconds. Raising the default to 30 broke it, not because the timeout fired but because the wider window now reached the monitor's first unanswered ping. Fixing it by giving the test a five-second timeout of its own traded one problem for a worse one: a configuration that ships nowhere. It now runs the default and answers the pings, which is what an established connection actually does and the reason the monitor stays quiet in production. The answer is delivered on a timer rather than inside the send, because a pong crosses the wire — delivered synchronously it would register before the monitor arms the timeout it is meant to cancel, and the connection would be called unhealthy anyway. Verified by deleting the `_cancelConnectTimeout()` call the test exists to cover: it fails. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/stream_web_socket_client_test.dart | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index 441d5212..e4b05b49 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -442,19 +442,25 @@ void main() { test('does not fire once the connection is established', () { fakeAsync((async) { - // A timeout of its own, well short of the health monitor: the default - // one outlives the monitor's first missed pong, so elapsing past it - // would report an unhealthy connection instead. - final (:client, :incoming, optionsBuilt: _, sink: _) = _client( - connectTimeout: const Duration(seconds: 5), - ); + final (:client, :sink, incoming: _, optionsBuilt: _) = _client(); + + // A live connection answers its pings, which is what keeps the health + // monitor quiet across a connection that outlives this timeout. The + // answer arrives over the wire, so it lands after the monitor has armed + // its pong timeout rather than inside the send that triggered it. + when(() => sink.add(any())).thenAnswer((_) { + Timer(const Duration(milliseconds: 50), () { + client.onMessage(const _HealthCheckEvent()); + }); + }); client.connect().ignore(); async.flushMicrotasks(); client.onMessage(const _HealthCheckEvent()); expect(client.connectionState.value, isA()); - async.elapse(const Duration(seconds: 6)); + // Past the timeout, and past a ping cycle with it. + async.elapse(WebSocketOptions.defaultConnectTimeout + const Duration(seconds: 10)); expect(client.connectionState.value, isA()); }); From 68cedda77cc66af94e4702f4cc12b9f9b30cd925 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 18:27:01 +0200 Subject: [PATCH 34/97] test(llc): let the connect-timeout tests reach the state they are about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four timeout tests all stopped at `Disconnecting(ConnectTimeout)` with a comment excusing it: `disconnect` awaits the engine's subscription cancel, and a `StreamController`'s cancel completes on the event loop, which `fakeAsync` never drives. So the close stalled halfway and the tests could only ever see the transition, never the outcome — which is the part that matters, since a disconnection's source decides whether it is retried. The harness now hands the client a stream whose subscription cancels without the event loop, so a close under `fakeAsync` finishes as it does in production. All four assert `Disconnected(ConnectTimeout)`, one of them now also pinning that an abandoned attempt is eligible for reconnection, and the test that had to deliver `onClose` by hand to make progress no longer does. Same treatment for the socket-error test, which now closes as the peer would and asserts the whole consequence: a recoverable server error stays recoverable, which is what the source guard is for. Three smaller things: two tests called `onClose` after an awaited `disconnect` had already delivered it, which is a double close production cannot produce; a failed socket close is stubbed as a rejected future rather than a synchronous throw, since that is how a real sink reports one; and the assertion that a disposed client refuses to connect now says that it pins debug behaviour, with the untouched builder count covering release. Verified by removing `_startConnectTimeout`: four tests fail. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/stream_web_socket_client_test.dart | 122 ++++++++++++------ 1 file changed, 83 insertions(+), 39 deletions(-) diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index e4b05b49..3b073e3c 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -43,6 +43,62 @@ final class _PingRequest extends WsRequest { List get props => const []; } +/// A stream whose subscription cancels without going through the event loop. +/// +/// `StreamController`'s cancel completes on the event loop, which `fakeAsync` +/// never drives, so a client awaiting one hangs in a test where it would not in +/// production — leaving the close half finished. +class _CancellableStream extends Stream { + _CancellableStream(this._source); + + final Stream _source; + + @override + StreamSubscription listen( + void Function(T event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return _CancellableSubscription( + _source.listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError), + ); + } +} + +class _CancellableSubscription implements StreamSubscription { + _CancellableSubscription(this._delegate); + + final StreamSubscription _delegate; + + @override + Future cancel() { + _delegate.cancel().ignore(); + return Future.value(); + } + + @override + void onData(void Function(T data)? handleData) => _delegate.onData(handleData); + + @override + void onError(Function? handleError) => _delegate.onError(handleError); + + @override + void onDone(void Function()? handleDone) => _delegate.onDone(handleDone); + + @override + void pause([Future? resumeSignal]) => _delegate.pause(resumeSignal); + + @override + void resume() => _delegate.resume(); + + @override + bool get isPaused => _delegate.isPaused; + + @override + Future asFuture([E? futureValue]) => _delegate.asFuture(futureValue); +} + /// Builds a client whose socket opens successfully but sends nothing, so the /// handshake only progresses when a test drives it. ({ @@ -60,7 +116,7 @@ _client({ final channel = _MockWebSocketChannel(); when(() => channel.ready).thenAnswer((_) async {}); - when(() => channel.stream).thenAnswer((_) => incoming.stream); + when(() => channel.stream).thenAnswer((_) => _CancellableStream(incoming.stream)); final sink = _MockWebSocketSink(); when(() => channel.sink).thenReturn(sink); // A socket that closes cleanly; tests that need otherwise re-stub this. @@ -99,7 +155,7 @@ void main() { test('reports the connection closed even when the socket close fails', () async { final (:client, :sink, incoming: _, optionsBuilt: _) = _client(); - when(() => sink.close(any(), any())).thenThrow(Exception('close failed')); + when(() => sink.close(any(), any())).thenAnswer((_) => Future.error(Exception('close failed'))); await client.connect(); await client.disconnect(); @@ -168,7 +224,9 @@ void main() { await client.dispose(); // Asserts rather than throws: a reconnect can come from the recovery - // handler, which does not await it and cannot report an error. + // handler, which does not await it and cannot report an error. In a + // release build the assert is gone and the call is a no-op, which is what + // the untouched builder count pins. await expectLater(client.connect(), throwsA(isA())); expect(optionsBuilt(), 1); }); @@ -192,7 +250,6 @@ void main() { expect(optionsBuilt(), 1); await client.disconnect(); - client.onClose(); await client.connect(); expect(optionsBuilt(), 2); @@ -231,7 +288,6 @@ void main() { expect(calls, 1); await client.disconnect(); - client.onClose(); await client.connect(); await pumpEventQueue(); @@ -332,7 +388,6 @@ void main() { await client.connect(); await pumpEventQueue(); - client.onClose(); final state = client.connectionState.value; expect(state, isA()); @@ -357,14 +412,15 @@ void main() { expect(client.connectionState.value, isA()); async.elapse(const Duration(seconds: 1)); - // Only the source can be observed here: `disconnect` awaits the - // subscription cancel, which `fakeAsync` never completes, so the state - // settles at 'disconnecting'. The reached state is covered by the - // `StreamWebSocketClient.disconnect` group. + final state = client.connectionState.value; expect( - client.connectionState.value, - isA().having((it) => it.source, 'source', isA()), + state, + isA().having((it) => it.source, 'source', isA()), ); + + // An attempt abandoned here is retried: a first health check that never + // arrives is the same failure as one that stops arriving. + expect(state.isAutomaticReconnectionEnabled, isTrue); }); }); @@ -382,13 +438,9 @@ void main() { async.elapse(WebSocketOptions.defaultConnectTimeout); - // Only the source can be observed here: `disconnect` awaits the - // subscription cancel, which `fakeAsync` never completes, so the state - // settles at 'disconnecting'. The reached state is covered by the - // `StreamWebSocketClient.disconnect` group. expect( client.connectionState.value, - isA().having((it) => it.source, 'source', isA()), + isA().having((it) => it.source, 'source', isA()), ); }); }); @@ -400,20 +452,15 @@ void main() { client.connect().ignore(); async.flushMicrotasks(); async.elapse(WebSocketOptions.defaultConnectTimeout); - client.onClose(); expect(client.connectionState.value, isA()); client.connect().ignore(); async.flushMicrotasks(); async.elapse(WebSocketOptions.defaultConnectTimeout); - // Only the source can be observed here: `disconnect` awaits the - // subscription cancel, which `fakeAsync` never completes, so the state - // settles at 'disconnecting'. The reached state is covered by the - // `StreamWebSocketClient.disconnect` group. expect( client.connectionState.value, - isA().having((it) => it.source, 'source', isA()), + isA().having((it) => it.source, 'source', isA()), ); }); }); @@ -429,13 +476,9 @@ void main() { async.elapse(const Duration(seconds: 2)); - // Only the source can be observed here: `disconnect` awaits the - // subscription cancel, which `fakeAsync` never completes, so the state - // settles at 'disconnecting'. The reached state is covered by the - // `StreamWebSocketClient.disconnect` group. expect( client.connectionState.value, - isA().having((it) => it.source, 'source', isA()), + isA().having((it) => it.source, 'source', isA()), ); }); }); @@ -479,13 +522,18 @@ void main() { async.elapse(WebSocketOptions.defaultConnectTimeout * 2); - // Replacing this with `ConnectTimeout` would have made a reconnectable - // failure permanent, since `ServerInitiated` is eligible and the - // timeout used not to be. + // The peer closes after sending the error, as the socket protocol has + // it, which is what turns the state into a disconnection. + client.onClose(); + + // Replacing this source with `ConnectTimeout` used to make a + // reconnectable failure permanent. + final state = client.connectionState.value; expect( - client.connectionState.value, - isA().having((it) => it.source, 'source', isA()), + state, + isA().having((it) => it.source, 'source', isA()), ); + expect(state.isAutomaticReconnectionEnabled, isTrue); }); }); @@ -501,14 +549,10 @@ void main() { async.elapse(WebSocketOptions.defaultConnectTimeout * 2); // The timeout would otherwise report this deliberate disconnect as a - // timed-out attempt, which reconnects differently. - // Only the source can be observed here: `disconnect` awaits the - // subscription cancel, which `fakeAsync` never completes, so the state - // settles at 'disconnecting'. The reached state is covered by the - // `StreamWebSocketClient.disconnect` group. + // timed-out attempt, which is retried where this is not. expect( client.connectionState.value, - isA().having((it) => it.source, 'source', isA()), + isA().having((it) => it.source, 'source', isA()), ); }); }); From 4253b38fceeed11e0f55f0a143872aa914edc8b9 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 18:40:42 +0200 Subject: [PATCH 35/97] docs(llc): say that a provider's equality is never consulted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `setTokenProvider` compares provider instances, so whether a `TokenProvider` defines `==` makes no difference to it — a provider has no reason to implement equality for the manager's sake, and none can talk it into keeping a token the replacement was meant to supersede. Probing both comparisons showed why that is the right way round. Against every provider that ships the two are indistinguishable, since neither built-in defines `==`. Where they differ, value equality buys one avoided token load in the case where the credentials match anyway, and costs a stale token in the case where they do not: a provider comparing the endpoint it loads from — a perfectly reasonable thing to write — reports itself equal while carrying a refreshed token, and the manager would serve the old one. The test provider is renamed to say what it is for: it claims to equal anything of its kind, which is the statement the rule has to survive. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/user/token_manager.dart | 9 +++---- .../test/user/token_manager_test.dart | 24 ++++++++++--------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index efff2064..1ca9aa24 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -98,14 +98,15 @@ class TokenManager { /// ``` /// Re-setting the identity this manager already has does nothing: expiring /// the cached token would send the next caller to the provider for no reason. - /// The provider is compared by instance, so this only applies when the same - /// one is passed again. + /// This applies only when the same provider instance is passed again — a + /// provider's own `==` is never consulted, so whether it defines equality + /// makes no difference here. void setTokenProvider( String userId, { required TokenProvider tokenProvider, }) { - // Compared with `identical` rather than `==`: a provider defines its own - // equality, and one that calls itself equal to another would keep the + // Compared with `identical` rather than `==`: equality is the provider's own + // to define, and one that called itself equal to another would keep the // provider and the cached token this call means to replace. final unchanged = userId == this.userId && identical(tokenProvider, _identity?.provider); if (unchanged) return; diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 36624d50..1856da0e 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -6,11 +6,14 @@ import 'package:test/test.dart'; import '../helpers/user_token.dart'; -/// A token provider that reports itself equal to any other of its kind, the way -/// a provider with value equality can. +/// A token provider that claims to equal any other of its kind, whatever +/// credentials it carries. +/// +/// Equality is a provider's own to define, and the manager must not take its +/// word for it — that is what this provider is for. @immutable -class _EquatableProvider implements TokenProvider { - const _EquatableProvider(this._token); +class _AlwaysEqualProvider implements TokenProvider { + const _AlwaysEqualProvider(this._token); final UserToken _token; @@ -18,7 +21,7 @@ class _EquatableProvider implements TokenProvider { Future loadToken(String userId) async => _token; @override - bool operator ==(Object other) => other is _EquatableProvider; + bool operator ==(Object other) => other is _AlwaysEqualProvider; @override int get hashCode => 0; @@ -362,21 +365,20 @@ void main() { expect(provider.loadCount, 1); }); - test('replaces a provider that merely compares equal to the previous one', () async { + test('replaces a provider even when it claims to equal the previous one', () async { final manager = TokenManager( userId: 'user-1', - tokenProvider: _EquatableProvider(generateTestUserToken('user-1', nonce: 'first')), + tokenProvider: _AlwaysEqualProvider(generateTestUserToken('user-1', nonce: 'first')), ); expect(await manager.getToken(), generateTestUserToken('user-1', nonce: 'first')); manager.setTokenProvider( 'user-1', - tokenProvider: _EquatableProvider(generateTestUserToken('user-1', nonce: 'second')), + tokenProvider: _AlwaysEqualProvider(generateTestUserToken('user-1', nonce: 'second')), ); - // A provider defines its own equality, so keeping the cached token - // because the replacement called itself equal would serve a token the - // previous provider issued. + // The manager compares instances, not values, so a provider cannot talk + // it into keeping a token the replacement was meant to supersede. expect(manager.peekToken(), isNull); expect(await manager.getToken(), generateTestUserToken('user-1', nonce: 'second')); }); From 7a196746dd30137a482017a07ada911d23eaa4be Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 18:49:42 +0200 Subject: [PATCH 36/97] Revert "fix(llc): compare a replacement provider by instance, not by equality" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the provider half of c03f7fd, keeping its `User` changelog correction. Comparing with `identical` second-guessed a type's own equality contract. `==` means substitutable: a provider that defines it is declaring that a replacement is the same as what it replaces, and honouring that declaration is the correct behaviour rather than a hazard. One that defines nothing gets identity, which is what the record comparison already did. Both branches are right, so there was nothing to protect against. It was also inconsistent. Dart honours a type's `==` everywhere else that type goes — sets, maps, `contains` — so an equality that claims interchangeability where there is none is a bug that surfaces in all of those, not something for this one call site to work around. And the consequence here was bounded anyway: both tokens must belong to the same user or the load throws, so a retained token either still works or is rejected and refreshed on the next request. The test now pins the contract rather than its opposite: a provider that reports itself unchanged keeps the cached token. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- .../stream_core/lib/src/user/token_manager.dart | 15 ++++++--------- .../test/user/token_manager_test.dart | 17 +++++++---------- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 9983e74f..8b86e432 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -28,7 +28,7 @@ - Raised the minimum Dart SDK to `^3.12.0` - Anonymous requests now always send `user_id=!anon`. The value previously came from the `TokenManager`, so it was whatever the caller configured; the server requires the claim to be `!anon` and derives the anonymous session itself, so the parameter now matches - `DynamicTokenProvider` checks the token type before its user id, so a token of the wrong type is reported as such instead of as a mismatched user -- `TokenManager.setTokenProvider` does nothing when handed the identity it already has, instead of expiring the cached token +- `TokenManager.setTokenProvider` does nothing when handed the identity it already has, instead of expiring the cached token. The provider is compared with `==`, so one that defines value equality decides when a replacement counts as the same - `TokenManager.getToken` fails when `reset` runs while the token is loading, instead of returning a token for a user the manager no longer has. A `setTokenProvider` during a load still serves the caller that started it - `TokenManager.getToken` rejects a token whose `user_id` is not the user it was loading for, which a custom `TokenProvider` is not obliged to check itself - `AuthInterceptor` no longer attempts a token refresh when the manager has no identity, so the original token-expired error is surfaced rather than a failure to load a token diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index 1ca9aa24..501e97d6 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -98,20 +98,17 @@ class TokenManager { /// ``` /// Re-setting the identity this manager already has does nothing: expiring /// the cached token would send the next caller to the provider for no reason. - /// This applies only when the same provider instance is passed again — a - /// provider's own `==` is never consulted, so whether it defines equality - /// makes no difference here. + /// The provider is compared with `==`, so a provider that defines value + /// equality decides for itself when a replacement is the same as what it + /// replaces; one that does not is compared by instance. void setTokenProvider( String userId, { required TokenProvider tokenProvider, }) { - // Compared with `identical` rather than `==`: equality is the provider's own - // to define, and one that called itself equal to another would keep the - // provider and the cached token this call means to replace. - final unchanged = userId == this.userId && identical(tokenProvider, _identity?.provider); - if (unchanged) return; + final identity = (userId: userId, provider: tokenProvider); + if (_identity == identity) return; - _identity = (userId: userId, provider: tokenProvider); + _identity = identity; // The cached token belongs to the previous user and provider, so drop it // and let the next `getToken` call load a fresh one. diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 1856da0e..24ef2ca1 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -6,11 +6,8 @@ import 'package:test/test.dart'; import '../helpers/user_token.dart'; -/// A token provider that claims to equal any other of its kind, whatever -/// credentials it carries. -/// -/// Equality is a provider's own to define, and the manager must not take its -/// word for it — that is what this provider is for. +/// A token provider that defines value equality, as an implementation is free +/// to do — here on nothing but its own type. @immutable class _AlwaysEqualProvider implements TokenProvider { const _AlwaysEqualProvider(this._token); @@ -365,7 +362,7 @@ void main() { expect(provider.loadCount, 1); }); - test('replaces a provider even when it claims to equal the previous one', () async { + test('keeps the cached token when the provider says it is unchanged', () async { final manager = TokenManager( userId: 'user-1', tokenProvider: _AlwaysEqualProvider(generateTestUserToken('user-1', nonce: 'first')), @@ -377,10 +374,10 @@ void main() { tokenProvider: _AlwaysEqualProvider(generateTestUserToken('user-1', nonce: 'second')), ); - // The manager compares instances, not values, so a provider cannot talk - // it into keeping a token the replacement was meant to supersede. - expect(manager.peekToken(), isNull); - expect(await manager.getToken(), generateTestUserToken('user-1', nonce: 'second')); + // Equality is a declaration of interchangeability, and it is the + // provider's own to make: this one says the replacement is the same, so + // the cached token stands. + expect(manager.peekToken(), generateTestUserToken('user-1', nonce: 'first')); }); }); From a2e4abedd931fbd6efaf8defa8430952a5b8d743 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 18:54:49 +0200 Subject: [PATCH 37/97] refactor(llc): name the anonymous token's claim as the user id it is `UserToken` calls the same extraction `userId`; `UserToken.anonymous` called it `claim`. Same value, same line of code, two names. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/lib/src/user/user_token.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/stream_core/lib/src/user/user_token.dart b/packages/stream_core/lib/src/user/user_token.dart index 32f773aa..8a425ae8 100644 --- a/packages/stream_core/lib/src/user/user_token.dart +++ b/packages/stream_core/lib/src/user/user_token.dart @@ -75,10 +75,10 @@ class UserToken extends Equatable { factory UserToken.anonymous({String rawValue = ''}) { if (rawValue.isNotEmpty) { final jwtBody = JsonWebToken.unverified(rawValue); - final claim = jwtBody.claims.getTyped('user_id'); - if (claim != User.anonymousUserId) { + final userId = jwtBody.claims.getTyped('user_id'); + if (userId != User.anonymousUserId) { throw ArgumentError.value( - claim, + userId, 'rawValue', 'Expected a JWT claiming user_id "${User.anonymousUserId}"', ); From 300908ce1ee65df5fea9296c814d64998ec2b5ff Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 19:20:30 +0200 Subject: [PATCH 38/97] fix(llc): recover connections that existed, not attempts that never landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ConnectionRecoveryHandler` retried any reconnect-eligible disconnection, including the very first attempt — while `connect`'s caller was being handed that same failure. Making `ConnectTimeout` retryable turned that from a corner into the common case: on a flaky network the caller is told the connection failed, the handler is already re-establishing it, and if the caller does the documented thing and retries, it gets "connection already in progress" from an attempt it did not start. Reconnection now requires a connection to have existed. The first attempt belongs to whoever called `connect` and awaited the outcome; every drop after that is the handler's, timeouts included, so a slow reconnect still keeps retrying with backoff. This is what the Android SDK does — `hasConnectedBefore && isDisconnected && …` in `StreamConnectionRecoveryEvaluatorImpl`, latched on reaching connected — and what the JS SDK arrives at structurally, by only reconnecting from close and health-check handlers and bailing out of `_reconnect` while a caller's attempt is in flight. Swift avoids it a third way, by never turning its initial-connect timeout into a disconnection source at all. The consequence worth knowing: a first attempt that fails is not retried when the network returns either. That is the caller's to handle, and it is documented on the class. Adds the first tests for this handler: a first attempt that times out is not retried, a connection that stops answering health checks is, and having been connected does not override a deliberate disconnect. Verified by removing the gate — one fails. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 1 + .../connection_recovery_handler.dart | 35 +++- .../connection_recovery_handler_test.dart | 184 ++++++++++++++++++ 3 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index e9d0d8d3..2cbb1cdb 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -36,6 +36,7 @@ - Fixed a failure to close the socket leaving `StreamWebSocketClient` reporting itself as disconnecting for good, since the engine reports such a failure rather than notifying its listener - Fixed a `WebSocketAuthenticator` that throws, rather than returning a failure, escaping as an unhandled error and leaving the connection authenticating until it timed out — losing the cause, which the timeout does not carry. The natural authenticator throws, since loading a token does - Fixed `StreamWebSocketClient.disconnect` replacing the source of a closure already under way, which could turn a reconnectable `ServerInitiated` error into a permanent `ConnectTimeout` +- Fixed `ConnectionRecoveryHandler` retrying a first connection attempt, which failed the caller of `connect` and reconnected behind them at the same time — and made the caller's own retry fail with "connection already in progress". It now recovers connections that existed, matching the `hasConnectedBefore` gate in the Android SDK - Fixed a health check arriving while disconnecting reporting the connection as established again, which replaced the disconnection source and could turn a deliberate disconnect into an automatic reconnect ### 🔄 Changed diff --git a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart index 55c4a0af..004984aa 100644 --- a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart +++ b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart @@ -18,6 +18,10 @@ import 'retry_strategy.dart'; /// when reconnection should occur, implementing exponential backoff with jitter for optimal /// retry behavior. /// +/// It recovers connections that existed: until [StreamWebSocketClient.connect] has established +/// one, connecting belongs to whoever called it and was handed the failure. A first attempt that +/// fails is therefore not retried here, including when the network returns. +/// /// ## Built-in Policies /// /// The handler automatically includes several reconnection policies: @@ -117,7 +121,19 @@ class ConnectionRecoveryHandler extends Disposable { _reconnectionTimer = null; } - bool _canBeReconnected() => _policies.every((it) => it.canBeReconnected()); + // Set once a connection has been established, and never unset: it is what + // separates a drop this handler recovers from a first attempt it does not. + var _hasConnected = false; + + bool _canBeReconnected() { + // Until a connection has existed, connecting belongs to whoever called + // `connect` and was handed the failure. Retrying here as well would work + // behind a caller already told the attempt failed — and would race the + // retry that caller makes in response. + if (!_hasConnected) return false; + + return _policies.every((it) => it.canBeReconnected()); + } bool _canBeDisconnected() { return switch (_client.connectionState.value) { @@ -145,13 +161,18 @@ class ConnectionRecoveryHandler extends Disposable { } void _onConnectionStateChanged(WebSocketConnectionState state) { - return switch (state) { - Connecting() => _cancelReconnection(), - Connected() => _reconnectStrategy.resetConsecutiveFailures(), - Disconnected() => _scheduleReconnectionIfNeeded(), + switch (state) { + case Connecting(): + _cancelReconnection(); + case Connected(): + _hasConnected = true; + _reconnectStrategy.resetConsecutiveFailures(); + case Disconnected(): + _scheduleReconnectionIfNeeded(); // These states do not require any action. - Initialized() || Authenticating() || Disconnecting() => () {}, - }; + case Initialized() || Authenticating() || Disconnecting(): + break; + } } @override diff --git a/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart b/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart new file mode 100644 index 00000000..a81ae371 --- /dev/null +++ b/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart @@ -0,0 +1,184 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class _MockWebSocketChannel extends Mock implements WebSocketChannel {} + +class _MockWebSocketSink extends Mock implements WebSocketSink {} + +/// A stream whose subscription cancels without going through the event loop, so +/// a close under `fakeAsync` runs to completion as it does in production. +class _CancellableStream extends Stream { + _CancellableStream(this._source); + + final Stream _source; + + @override + StreamSubscription listen( + void Function(T event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return _CancellableSubscription( + _source.listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError), + ); + } +} + +class _CancellableSubscription implements StreamSubscription { + _CancellableSubscription(this._delegate); + + final StreamSubscription _delegate; + + @override + Future cancel() { + _delegate.cancel().ignore(); + return Future.value(); + } + + @override + void onData(void Function(T data)? handleData) => _delegate.onData(handleData); + + @override + void onError(Function? handleError) => _delegate.onError(handleError); + + @override + void onDone(void Function()? handleDone) => _delegate.onDone(handleDone); + + @override + void pause([Future? resumeSignal]) => _delegate.pause(resumeSignal); + + @override + void resume() => _delegate.resume(); + + @override + bool get isPaused => _delegate.isPaused; + + @override + Future asFuture([E? futureValue]) => _delegate.asFuture(futureValue); +} + +class _NoopCodec implements WebSocketMessageCodec { + const _NoopCodec(); + + @override + Object encode(WsRequest message) => ''; + + @override + WsEvent decode(Object message) => const _HealthCheckEvent(); +} + +final class _HealthCheckEvent extends WsEvent { + const _HealthCheckEvent(); + + @override + HealthCheckInfo? get healthCheckInfo => const HealthCheckInfo(connectionId: 'connection-id'); +} + +final class _PingRequest extends WsRequest { + const _PingRequest(); + + @override + Map toJson() => const {}; + + @override + List get props => const []; +} + +/// A client whose socket opens but answers nothing, with a handler attached and +/// a count of the attempts it has made. +({StreamWebSocketClient client, int Function() attempts}) _client() { + final incoming = StreamController.broadcast(); + addTearDown(incoming.close); + + final channel = _MockWebSocketChannel(); + when(() => channel.ready).thenAnswer((_) async {}); + when(() => channel.stream).thenAnswer((_) => _CancellableStream(incoming.stream)); + final sink = _MockWebSocketSink(); + when(() => channel.sink).thenReturn(sink); + when(() => sink.close(any(), any())).thenAnswer((_) async {}); + + var attempts = 0; + final client = StreamWebSocketClient( + optionsBuilder: () { + attempts++; + return const WebSocketOptions(url: 'wss://example.com'); + }, + wsProvider: (_) => channel, + pingRequestBuilder: ([_]) => const _PingRequest(), + messageCodec: const _NoopCodec(), + ); + + final handler = ConnectionRecoveryHandler(client: client); + addTearDown(handler.dispose); + + return (client: client, attempts: () => attempts); +} + +void main() { + group('ConnectionRecoveryHandler', () { + test('does not retry a first attempt that never connected', () { + fakeAsync((async) { + final (:client, :attempts) = _client(); + + client.connect().ignore(); + async.flushMicrotasks(); + + // The socket opened but the server never answers, so the attempt is + // abandoned — the failure the caller of `connect` is handed. + async.elapse(WebSocketOptions.defaultConnectTimeout); + async.flushMicrotasks(); + expect(client.connectionState.value, isA()); + + // Retrying here would work behind a caller already told it failed, and + // would race the retry that caller makes in response. + async.elapse(const Duration(minutes: 1)); + expect(attempts(), 1); + }); + }); + + test('retries a connection that dropped after being established', () { + fakeAsync((async) { + final (:client, :attempts) = _client(); + + client.connect().ignore(); + async.flushMicrotasks(); + client.onMessage(const _HealthCheckEvent()); + expect(client.connectionState.value, isA()); + + // The connection stops answering health checks — a drop rather than a + // failure to connect, so recovering it is this handler's job. The first + // retry carries no delay, so it is under way by the time this returns. + async.elapse(const Duration(seconds: 29)); + async.flushMicrotasks(); + + expect(attempts(), 2); + expect(client.connectionState.value, isA()); + }); + }); + + test('does not retry a disconnect the caller asked for', () { + fakeAsync((async) { + final (:client, :attempts) = _client(); + + client.connect().ignore(); + async.flushMicrotasks(); + client.onMessage(const _HealthCheckEvent()); + + client.disconnect().ignore(); + async.flushMicrotasks(); + expect(client.connectionState.value, isA()); + + // Having been connected is not enough on its own: the source still says + // this was deliberate. + async.elapse(const Duration(minutes: 1)); + expect(attempts(), 1); + }); + }); + }); +} From 073772686e049c58290810ef72d8516f2f9cc6db Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 19:24:07 +0200 Subject: [PATCH 39/97] fix(llc): hand connecting back to the caller after a deliberate disconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate added in 300908c asked whether a connection had *ever* existed, which leaks across sessions: connect, disconnect, connect again, and a first attempt that fails is retried by the handler because a connection existed before the caller disconnected — the same double ownership, one session later. The question is whether a connection has existed since the caller last asked for one, so a user-initiated disconnection clears it. A system-initiated one does not: backgrounding and network loss are exactly what this handler exists to come back from, and the source already distinguishes the two. Also corrects what 300908c claimed about the Android SDK. The `hasConnectedBefore` latch is borrowed from it, but the retry model around it is not: Android's evaluator connects only on `networkBecameAvailable` or a return to the foreground, and has no failure-driven retry at all — a socket dropping on a healthy foreground network reconnects nothing there. Ours retries failures with a backoff as well, which makes the latch load-bearing here in a way it is not there. (Android does not reset it either, for that reason.) Co-Authored-By: Claude Opus 5 (1M context) --- .../connection_recovery_handler.dart | 13 ++++++++--- .../connection_recovery_handler_test.dart | 23 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart index 004984aa..2aadbeed 100644 --- a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart +++ b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart @@ -121,8 +121,9 @@ class ConnectionRecoveryHandler extends Disposable { _reconnectionTimer = null; } - // Set once a connection has been established, and never unset: it is what - // separates a drop this handler recovers from a first attempt it does not. + // Whether a connection has been established since the caller last asked for + // one: it is what separates a drop this handler recovers from an attempt whose + // outcome the caller is waiting on. var _hasConnected = false; bool _canBeReconnected() { @@ -167,7 +168,13 @@ class ConnectionRecoveryHandler extends Disposable { case Connected(): _hasConnected = true; _reconnectStrategy.resetConsecutiveFailures(); - case Disconnected(): + case Disconnected(:final source): + // A disconnect the caller asked for hands connecting back to them, so + // the next `connect` is a fresh attempt they await rather than a drop to + // recover. A system-initiated one is the opposite: backgrounding and + // network loss are exactly what this handler exists to come back from. + if (source is UserInitiated) _hasConnected = false; + _scheduleReconnectionIfNeeded(); // These states do not require any action. case Initialized() || Authenticating() || Disconnecting(): diff --git a/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart b/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart index a81ae371..234b4846 100644 --- a/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart +++ b/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart @@ -162,6 +162,29 @@ void main() { }); }); + test('hands connecting back after the caller disconnected', () { + fakeAsync((async) { + final (:client, :attempts) = _client(); + + client.connect().ignore(); + async.flushMicrotasks(); + client.onMessage(const _HealthCheckEvent()); + client.disconnect().ignore(); + async.flushMicrotasks(); + + // A fresh attempt, awaited by whoever made it, that never connects. + client.connect().ignore(); + async.flushMicrotasks(); + async.elapse(WebSocketOptions.defaultConnectTimeout); + async.flushMicrotasks(); + + // Having connected in the previous session does not make this failure + // the handler's to retry. + async.elapse(const Duration(minutes: 1)); + expect(attempts(), 2); + }); + }); + test('does not retry a disconnect the caller asked for', () { fakeAsync((async) { final (:client, :attempts) = _client(); From 2ad554c7c784d6d1d63ec99b5bf6ae8911cfb16c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 19:24:30 +0200 Subject: [PATCH 40/97] docs(llc): describe the recovery gate as it ended up The entry still said the handler recovers connections that existed and cited Android's `hasConnectedBefore` as the match. Both were left behind by 0737726: the gate is per-session, cleared when the caller disconnects, and the Android comparison holds for the latch but not for the retry model around it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 2cbb1cdb..2e78397c 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -36,7 +36,7 @@ - Fixed a failure to close the socket leaving `StreamWebSocketClient` reporting itself as disconnecting for good, since the engine reports such a failure rather than notifying its listener - Fixed a `WebSocketAuthenticator` that throws, rather than returning a failure, escaping as an unhandled error and leaving the connection authenticating until it timed out — losing the cause, which the timeout does not carry. The natural authenticator throws, since loading a token does - Fixed `StreamWebSocketClient.disconnect` replacing the source of a closure already under way, which could turn a reconnectable `ServerInitiated` error into a permanent `ConnectTimeout` -- Fixed `ConnectionRecoveryHandler` retrying a first connection attempt, which failed the caller of `connect` and reconnected behind them at the same time — and made the caller's own retry fail with "connection already in progress". It now recovers connections that existed, matching the `hasConnectedBefore` gate in the Android SDK +- Fixed `ConnectionRecoveryHandler` retrying a first connection attempt, which failed the caller of `connect` and reconnected behind them at the same time — and made the caller's own retry fail with "connection already in progress". It now recovers only connections that have existed since the caller last asked for one, so a deliberate `disconnect` hands connecting back and the next `connect` is the caller's attempt again - Fixed a health check arriving while disconnecting reporting the connection as established again, which replaced the disconnection source and could turn a deliberate disconnect into an automatic reconnect ### 🔄 Changed From f4a8f73f3145e98f0bb282ec2b31821fbcaaae06 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 19:27:10 +0200 Subject: [PATCH 41/97] refactor(llc): make the connection-state switch a dispatch, not a body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the four cases had grown bodies — one doing bookkeeping and scheduling together, with the reasoning for both wedged inside the case — so the switch no longer read as what it is: a map from state to response. Each case now names what happened, and the reasoning lives with the method that acts on it. `_hasConnected` moves up with the handler's other state instead of sitting beside one of its three readers. Co-Authored-By: Claude Opus 5 (1M context) --- .../connection_recovery_handler.dart | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart index 2aadbeed..fdd4297f 100644 --- a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart +++ b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart @@ -83,6 +83,11 @@ class ConnectionRecoveryHandler extends Disposable { late final _subscriptions = CompositeSubscription(); + // Whether a connection has been established since the caller last asked for + // one: it is what separates a drop this handler recovers from an attempt whose + // outcome the caller is waiting on. + var _hasConnected = false; + /// Attempts reconnection if policies allow it. /// /// Evaluates all configured policies and initiates reconnection when conditions are met. @@ -121,11 +126,6 @@ class ConnectionRecoveryHandler extends Disposable { _reconnectionTimer = null; } - // Whether a connection has been established since the caller last asked for - // one: it is what separates a drop this handler recovers from an attempt whose - // outcome the caller is waiting on. - var _hasConnected = false; - bool _canBeReconnected() { // Until a connection has existed, connecting belongs to whoever called // `connect` and was handed the failure. Retrying here as well would work @@ -166,22 +166,32 @@ class ConnectionRecoveryHandler extends Disposable { case Connecting(): _cancelReconnection(); case Connected(): - _hasConnected = true; - _reconnectStrategy.resetConsecutiveFailures(); + _onConnectionEstablished(); case Disconnected(:final source): - // A disconnect the caller asked for hands connecting back to them, so - // the next `connect` is a fresh attempt they await rather than a drop to - // recover. A system-initiated one is the opposite: backgrounding and - // network loss are exactly what this handler exists to come back from. - if (source is UserInitiated) _hasConnected = false; - - _scheduleReconnectionIfNeeded(); - // These states do not require any action. + _onConnectionLost(source); + // An attempt on its way up or down decides nothing on its own. case Initialized() || Authenticating() || Disconnecting(): break; } } + // A connection exists, so keeping it is this handler's job from here, and the + // failures the backoff had accumulated are behind us. + void _onConnectionEstablished() { + _hasConnected = true; + _reconnectStrategy.resetConsecutiveFailures(); + } + + // A disconnect the caller asked for hands connecting back to them, so the next + // `connect` is a fresh attempt they await rather than a drop to recover. A + // system-initiated one is the opposite: backgrounding and network loss are what + // this handler exists to come back from. + void _onConnectionLost(DisconnectionSource source) { + if (source is UserInitiated) _hasConnected = false; + + _scheduleReconnectionIfNeeded(); + } + @override Future dispose() async { _cancelReconnection(); From 72b14477f9d88a398b48e3a24d8ef0c2672d2bb6 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 20:52:03 +0200 Subject: [PATCH 42/97] feat(llc): reconnect an expired token only when another one exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A connection the server closed for an expired token was never reconnected, so a token expiring mid-session ended the session: nothing retried, and even the network returning could not revive it, since the policy reads the current state. That is now eligible for reconnection. On its own that would be worse than the disease. Whether a retry is worth making depends on something the connection state does not know — whether the provider can produce a different token — and a static one cannot, so a guest or a fixed JWT would have reconnected with exactly what was refused, forever, at the backoff's ceiling. `TokenRefreshReconnectionPolicy` answers that half: the state says the failure is worth retrying, the policy says whether the credential can change. Both halves are needed, and a product still has to expire the cached token between them; `stream-feeds-flutter` does that from its own connection-state listener, since only it knows there is a token manager at all. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 + .../automatic_reconnection_policy.dart | 39 ++++++++++ .../connection_recovery_handler.dart | 23 +++--- .../client/web_socket_connection_state.dart | 5 +- .../automatic_reconnection_policy_test.dart | 76 +++++++++++++++++++ .../web_socket_connection_state_test.dart | 6 +- 6 files changed, 133 insertions(+), 18 deletions(-) create mode 100644 packages/stream_core/test/ws/client/reconnect/automatic_reconnection_policy_test.dart diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 2e78397c..82126a53 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -19,6 +19,7 @@ - Added `User.anonymousUserId`, the id every anonymous user has - Added `TokenManager.unconfigured`, for a client that exists before its user does - Added `TokenManager.reset`, which drops the configured identity and its cached token +- Added `TokenRefreshReconnectionPolicy`, which stops a reconnection that would present a token the server has already refused. Whether another token exists is a property of the `TokenProvider`, not of the connection, so the connection state cannot decide it alone - Added `DisconnectionSource.connectTimeout`, reported when a connection attempt is abandoned before the connection is established; it is eligible for automatic reconnection, since a handshake that did not complete in time is the same failure as a connection that stops answering health checks - Added `DisconnectionSource.authenticationFailed`, reported with its cause when a connection opens but cannot be authenticated - `StreamWebSocketClient` now honours `WebSocketOptions.connectTimeout`, which is no longer nullable and defaults to `WebSocketOptions.defaultConnectTimeout`. This is a behaviour change as well as an API one: a connection previously waited indefinitely for its first health check, and is now abandoned — and reconnected — after 30 seconds, matching the wait the Swift SDK allows for the same handshake @@ -36,6 +37,7 @@ - Fixed a failure to close the socket leaving `StreamWebSocketClient` reporting itself as disconnecting for good, since the engine reports such a failure rather than notifying its listener - Fixed a `WebSocketAuthenticator` that throws, rather than returning a failure, escaping as an unhandled error and leaving the connection authenticating until it timed out — losing the cause, which the timeout does not carry. The natural authenticator throws, since loading a token does - Fixed `StreamWebSocketClient.disconnect` replacing the source of a closure already under way, which could turn a reconnectable `ServerInitiated` error into a permanent `ConnectTimeout` +- A connection the server closed because the token expired is now eligible for automatic reconnection, so a token expiring mid-session recovers instead of ending the session. Pair it with `TokenRefreshReconnectionPolicy` and something that expires the cached token, or the retry presents the same one - Fixed `ConnectionRecoveryHandler` retrying a first connection attempt, which failed the caller of `connect` and reconnected behind them at the same time — and made the caller's own retry fail with "connection already in progress". It now recovers only connections that have existed since the caller last asked for one, so a deliberate `disconnect` hands connecting back and the next `connect` is the caller's attempt again - Fixed a health check arriving while disconnecting reporting the connection as established again, which replaced the disconnection source and could turn a deliberate disconnect into an automatic reconnect diff --git a/packages/stream_core/lib/src/ws/client/reconnect/automatic_reconnection_policy.dart b/packages/stream_core/lib/src/ws/client/reconnect/automatic_reconnection_policy.dart index e26ee529..74ad603d 100644 --- a/packages/stream_core/lib/src/ws/client/reconnect/automatic_reconnection_policy.dart +++ b/packages/stream_core/lib/src/ws/client/reconnect/automatic_reconnection_policy.dart @@ -1,3 +1,5 @@ +import '../../../errors.dart'; +import '../../../user/token_manager.dart'; import '../../../utils.dart'; import '../web_socket_connection_state.dart'; @@ -95,3 +97,40 @@ class CompositeReconnectionPolicy implements AutomaticReconnectionPolicy { }; } } + +/// A policy that only reconnects when the credential can be replaced. +/// +/// A connection the server closed because the token expired is worth retrying, +/// but only with a different token — and whether one can be obtained is a +/// property of the [TokenProvider] rather than of the connection. A provider +/// that always returns the same token has nothing else to offer, so reconnecting +/// would present exactly what was just refused. +/// +/// Pair it with whatever expires the cached token, so the attempt this permits +/// loads a fresh one. +class TokenRefreshReconnectionPolicy implements AutomaticReconnectionPolicy { + /// Creates a [TokenRefreshReconnectionPolicy]. + const TokenRefreshReconnectionPolicy({ + required this.connectionState, + required this.tokenManager, + }); + + /// The connection state to read the last disconnection from. + final ConnectionStateEmitter connectionState; + + /// The manager whose provider decides whether another token is available. + final TokenManager tokenManager; + + @override + bool canBeReconnected() { + final refusedTheToken = switch (connectionState.value) { + Disconnected(source: ServerInitiated(:final error)) => error?.apiError?.isTokenExpiredError ?? false, + _ => false, + }; + + // Every other disconnection is somebody else's call. + if (!refusedTheToken) return true; + + return !tokenManager.usesStaticProvider; + } +} diff --git a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart index fdd4297f..48df9c97 100644 --- a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart +++ b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart @@ -162,24 +162,20 @@ class ConnectionRecoveryHandler extends Disposable { } void _onConnectionStateChanged(WebSocketConnectionState state) { - switch (state) { - case Connecting(): - _cancelReconnection(); - case Connected(): - _onConnectionEstablished(); - case Disconnected(:final source): - _onConnectionLost(source); - // An attempt on its way up or down decides nothing on its own. - case Initialized() || Authenticating() || Disconnecting(): - break; - } + return switch (state) { + Connecting() => _cancelReconnection(), + Connected() => _onConnectionEstablished(), + Disconnected(:final source) => _onConnectionLost(source), + // These states do not require any action. + Initialized() || Authenticating() || Disconnecting() => () {}, + }; } // A connection exists, so keeping it is this handler's job from here, and the // failures the backoff had accumulated are behind us. void _onConnectionEstablished() { _hasConnected = true; - _reconnectStrategy.resetConsecutiveFailures(); + return _reconnectStrategy.resetConsecutiveFailures(); } // A disconnect the caller asked for hands connecting back to them, so the next @@ -188,8 +184,7 @@ class ConnectionRecoveryHandler extends Disposable { // this handler exists to come back from. void _onConnectionLost(DisconnectionSource source) { if (source is UserInitiated) _hasConnected = false; - - _scheduleReconnectionIfNeeded(); + return _scheduleReconnectionIfNeeded(); } @override diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index d0624095..db463000 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -108,7 +108,10 @@ sealed class WebSocketConnectionState extends Equatable { Disconnected(:final source) => switch (source) { ServerInitiated() => switch (source.error?.apiError) { final error? when error.code == 1000 => false, - final error? when error.isTokenExpiredError => false, + // Worth retrying, but only once the credential has been replaced — + // which is the product's to do, since the token is theirs. A + // reconnect that presents the same one is refused the same way. + final error? when error.isTokenExpiredError => true, final error? when error.isClientError => false, _ => true, // Reconnect on other server initiated disconnections }, diff --git a/packages/stream_core/test/ws/client/reconnect/automatic_reconnection_policy_test.dart b/packages/stream_core/test/ws/client/reconnect/automatic_reconnection_policy_test.dart new file mode 100644 index 00000000..c8a59c51 --- /dev/null +++ b/packages/stream_core/test/ws/client/reconnect/automatic_reconnection_policy_test.dart @@ -0,0 +1,76 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +import '../../../helpers/user_token.dart'; + +StreamApiError _apiError(int code) => StreamApiError( + code: code, + details: const [], + duration: '0ms', + message: 'error $code', + moreInfo: '', + statusCode: 401, +); + +ConnectionStateEmitter _stateOf(WebSocketConnectionState state) { + return MutableConnectionStateEmitter(state); +} + +WebSocketConnectionState _refused(StreamApiError apiError) { + return WebSocketConnectionState.disconnected( + source: DisconnectionSource.serverInitiated( + error: WebSocketEngineException(error: apiError), + ), + ); +} + +void main() { + group('TokenRefreshReconnectionPolicy', () { + test('refuses to reconnect when the provider has only one token', () { + final policy = TokenRefreshReconnectionPolicy( + // Token-invalid error codes are 40..42; 40 = token expired. + connectionState: _stateOf(_refused(_apiError(40))), + tokenManager: TokenManager( + userId: 'user-1', + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), + ), + ); + + // Reconnecting would present the token the server just refused, over and + // over — the loop this policy exists to prevent. + expect(policy.canBeReconnected(), isFalse); + }); + + test('reconnects when the provider can issue another token', () { + final policy = TokenRefreshReconnectionPolicy( + connectionState: _stateOf(_refused(_apiError(40))), + tokenManager: TokenManager( + userId: 'user-1', + tokenProvider: TokenProvider.dynamic( + (userId) async => generateTestUserToken(userId), + ), + ), + ); + + expect(policy.canBeReconnected(), isTrue); + }); + + test('leaves every other disconnection to the other policies', () { + final policy = TokenRefreshReconnectionPolicy( + // A static provider, so this passes only because the disconnection has + // nothing to do with the token. + connectionState: _stateOf( + const WebSocketConnectionState.disconnected( + source: DisconnectionSource.unHealthyConnection(), + ), + ), + tokenManager: TokenManager( + userId: 'user-1', + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), + ), + ); + + expect(policy.canBeReconnected(), isTrue); + }); + }); +} diff --git a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart index 17a414f4..76c9e743 100644 --- a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart @@ -23,13 +23,13 @@ Disconnected _serverDisconnect(StreamApiError apiError) => Disconnected( void main() { group('WebSocketConnectionState.isAutomaticReconnectionEnabled', () { test( - 'is disabled when the server closes with a token-expired error, so an ' - 'expired (e.g. guest) token does not trigger a silent reconnect loop', + 'is enabled when the server closes with a token-expired error, since the ' + 'product replaces the credential before the attempt is made', () { // Token-invalid error codes are 40..42; 40 = token expired. final state = _serverDisconnect(_apiError(40)); - expect(state.isAutomaticReconnectionEnabled, isFalse); + expect(state.isAutomaticReconnectionEnabled, isTrue); }, ); From 1949cbc2093163d2e9f0f8e891728cbb4da5c2ae Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 21:08:01 +0200 Subject: [PATCH 43/97] fix(llc): classify token errors the way the iOS SDK does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rules in `isAutomaticReconnectionEnabled` could never fire, and one meant something broader than its name. The port compared the API error's `code` against 1000 — a WebSocket close code, which `WebSocketEngineException` declares as `stopErrorCode` and carries itself — so a socket the server closed deliberately was reconnected. It also compared `code` against 400..499, a range Stream's error codes never occupy: the backend numbers them -1 through ~102 and puts the HTTP status in a separate field, so every client error was treated as retryable. iOS reads `statusCode` for that, which is why its rule works. `isTokenExpiredError` covered 40..42, so it answered "the token is invalid" while being named for one particular reason. That is a distinction worth having: an expired token (40) is replaced by asking the provider for another, whereas a signature signed with the wrong secret (43), a clock the token is not valid against yet (41, 42), or a wrong API key (2) are configuration problems a fresh token presents again. Those are now `isInvalidTokenError`, and reconnection refuses them while allowing an expired one — the carve-out iOS spells out as "Expired tokens return 401, so it is considered client error". Verified each code against the backend: `monolith/errors/errors.go` defines accessKeyError=2, expiredToken=40, tokenNotValidYet=41, tokenUsedBeforeIAT=42, invalidTokenSignature=43, and returns all four token errors with a 401. The rules move out of a nested switch into a function, since three of them are about one source and read better as prose than as guards. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 3 + .../lib/src/errors/stream_api_error.dart | 28 ++++++-- .../client/web_socket_connection_state.dart | 45 +++++++++---- .../web_socket_connection_state_test.dart | 65 +++++++++++++------ 4 files changed, 102 insertions(+), 39 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 82126a53..ce46fa29 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -9,6 +9,8 @@ - `StreamWebSocketClient.onAuthenticate` is now a `WebSocketAuthenticator`: it is handed a `WsSender` and returns a `Result`, so a failure to authenticate can be observed - `TokenManager.userId` is now nullable, and is `null` until an identity is configured - `User` now requires a user of type `UserType.anonymous` to carry `User.anonymousUserId` as its id. The constructor is `const`, so a mismatch in a const context fails to compile rather than throwing in debug mode +- `StreamApiError.isTokenExpiredError` now means the token expired (code 40) rather than any invalid-token code. The rest — not yet valid, used before issued, wrong signature — are `isInvalidTokenError`, along with a wrong API key, since another token does not fix any of them +- `StreamApiError.isClientError` compares the HTTP `statusCode` against 400..499 rather than the Stream error `code`, which never falls in that range and so never matched - `Result.getOrElse`, `getOrDefault`, `recover` and `recoverCatching` no longer take a type parameter of their own and return the result's own type. They previously cast the value to the callback's type, which failed on a successful result — most visibly for a callback that only throws. Kotlin's equivalents widen through a `` bound that Dart cannot express; to widen here, name the wider type on the result (`Result widened = intResult`), which works because `Result` is covariant, or use `fold` ### ✨ Features @@ -37,6 +39,7 @@ - Fixed a failure to close the socket leaving `StreamWebSocketClient` reporting itself as disconnecting for good, since the engine reports such a failure rather than notifying its listener - Fixed a `WebSocketAuthenticator` that throws, rather than returning a failure, escaping as an unhandled error and leaving the connection authenticating until it timed out — losing the cause, which the timeout does not carry. The natural authenticator throws, since loading a token does - Fixed `StreamWebSocketClient.disconnect` replacing the source of a closure already under way, which could turn a reconnectable `ServerInitiated` error into a permanent `ConnectTimeout` +- Fixed `isAutomaticReconnectionEnabled` neither refusing a deliberate server close nor refusing client errors: it compared the API error's code against the close code 1000, and against a 400..499 range that Stream codes never occupy, so both rules were dead. It now mirrors the iOS SDK — close code 1000, invalid tokens and 4xx all refuse, an expired token does not - A connection the server closed because the token expired is now eligible for automatic reconnection, so a token expiring mid-session recovers instead of ending the session. Pair it with `TokenRefreshReconnectionPolicy` and something that expires the cached token, or the retry presents the same one - Fixed `ConnectionRecoveryHandler` retrying a first connection attempt, which failed the caller of `connect` and reconnected behind them at the same time — and made the caller's own retry fail with "connection already in progress". It now recovers only connections that have existed since the caller last asked for one, so a deliberate `disconnect` hands connecting back and the next `connect` is the caller's attempt again - Fixed a health check arriving while disconnecting reporting the connection as established again, which replaced the disconnection source and could turn a deliberate disconnect into an automatic reconnect diff --git a/packages/stream_core/lib/src/errors/stream_api_error.dart b/packages/stream_core/lib/src/errors/stream_api_error.dart index 19c2fe05..14eeccca 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.dart @@ -69,16 +69,34 @@ class StreamApiError extends Equatable { ]; } -final _tokenInvalidErrorCodes = _range(40, 42); -final _clientErrorCodes = _range(400, 499); +// The token this was issued for has expired; another one is accepted. +const _expiredTokenCode = 40; + +// The token cannot be accepted for a reason another token does not fix: not +// valid yet, used before it was issued, or signed with the wrong secret. +final _invalidTokenCodes = _range(41, 43); + +// The API key itself is wrong, which no token repairs either. +const _accessKeyErrorCode = 2; + +final _clientErrorStatusCodes = _range(400, 499); /// Extension methods for [StreamApiError] to provide convenient error type checks. extension StreamApiErrorExtension on StreamApiError { - /// Whether this error indicates an expired or invalid token. - bool get isTokenExpiredError => _tokenInvalidErrorCodes.contains(code); + /// Whether the token has expired. + /// + /// Distinct from [isInvalidTokenError]: an expired token is replaced by asking + /// the provider for another, where an invalid one is a configuration problem + /// that a fresh token presents again. + bool get isTokenExpiredError => code == _expiredTokenCode; + + /// Whether the token, or the key it was signed with, cannot be accepted. + bool get isInvalidTokenError { + return _invalidTokenCodes.contains(code) || code == _accessKeyErrorCode; + } /// Whether this error is a client-side error (4xx status codes). - bool get isClientError => _clientErrorCodes.contains(code); + bool get isClientError => _clientErrorStatusCodes.contains(statusCode); /// Whether this error indicates rate limiting (429 status code). bool get isRateLimitError => statusCode == 429; diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index db463000..3c15bbbe 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -98,28 +98,23 @@ sealed class WebSocketConnectionState extends Equatable { /// /// ## Reconnection is disabled for: /// - User-initiated disconnections (explicit disconnect calls) - /// - Server errors with code 1000 (normal closure) - /// - Token expired/invalid errors - /// - Client errors (4xx status codes) + /// - A socket the server closed deliberately (close code 1000) + /// - Tokens another token would not fix, and a wrong API key + /// - Client errors (4xx status codes), other than an expired token + /// - A failure to load or send credentials /// /// Returns `true` if automatic reconnection should be attempted. bool get isAutomaticReconnectionEnabled { return switch (this) { Disconnected(:final source) => switch (source) { - ServerInitiated() => switch (source.error?.apiError) { - final error? when error.code == 1000 => false, - // Worth retrying, but only once the credential has been replaced — - // which is the product's to do, since the token is theirs. A - // reconnect that presents the same one is refused the same way. - final error? when error.isTokenExpiredError => true, - final error? when error.isClientError => false, - _ => true, // Reconnect on other server initiated disconnections - }, + ServerInitiated(:final error) => _canReconnectAfter(error), UnHealthyConnection() => true, SystemInitiated() => true, UserInitiated() => false, - // A handshake that did not complete in time is the same failure as a - // connection that stops answering health checks, at an earlier moment. + // A handshake that did not complete in time is the same failure as one + // that stops answering health checks. A caller's own first attempt is + // kept out of this by the recovery handler, which recovers connections + // that existed rather than attempts that never landed. ConnectTimeout() => true, // Not the server refusing the credentials, which arrives as an error // frame: this is the client failing to load or send them, and it will @@ -368,3 +363,25 @@ final class AuthenticationFailed extends DisconnectionSource { @override List get props => [error]; } + +// Whether a connection the server closed is worth opening again. +// +// Mirrors the rules the iOS SDK applies, which the ported version had drifted +// from: it compared the API error's code against the close code 1000 and against +// a 400..499 range that Stream's codes never occupy, so neither rule could fire. +bool _canReconnectAfter(WebSocketEngineException? error) { + // A deliberate stop rather than a failure to recover from. + if (error?.code == WebSocketEngineException.stopErrorCode) return false; + + final apiError = error?.apiError; + if (apiError == null) return true; + + // Another token is refused for the same reason, so asking for one is futile. + if (apiError.isInvalidTokenError) return false; + + // Whatever else the client got wrong is the caller's to fix — except an + // expired token, which is replaced rather than corrected. + if (apiError.isClientError && !apiError.isTokenExpiredError) return false; + + return true; +} diff --git a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart index 76c9e743..927270a5 100644 --- a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart @@ -1,13 +1,13 @@ import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; -StreamApiError _apiError(int code) => StreamApiError( +StreamApiError _apiError(int code, {int statusCode = 401}) => StreamApiError( code: code, details: const [], duration: '0ms', message: 'error $code', moreInfo: '', - statusCode: 401, + statusCode: statusCode, ); Disconnected _serverDisconnect(StreamApiError apiError) => Disconnected( @@ -23,33 +23,58 @@ Disconnected _serverDisconnect(StreamApiError apiError) => Disconnected( void main() { group('WebSocketConnectionState.isAutomaticReconnectionEnabled', () { test( - 'is enabled when the server closes with a token-expired error, since the ' - 'product replaces the credential before the attempt is made', + 'is enabled when the token has expired, which another token replaces', () { - // Token-invalid error codes are 40..42; 40 = token expired. - final state = _serverDisconnect(_apiError(40)); - - expect(state.isAutomaticReconnectionEnabled, isTrue); + // 40 = expired; the server returns 401 with it, so the client-error rule + // has to make room for this one. + expect(_serverDisconnect(_apiError(40)).isAutomaticReconnectionEnabled, isTrue); }, ); - test('is enabled for a generic, retryable server-initiated disconnection', () { - // A server error that is neither a normal closure (1000), a token error - // (40..42), nor a client error (400..499) should still reconnect. - final state = _serverDisconnect(_apiError(43)); + test('is disabled when another token would be refused too', () { + // 41 not valid yet, 42 used before issued, 43 signed with the wrong + // secret, 2 wrong API key — none of which a fresh token repairs. + for (final code in [41, 42, 43, 2]) { + expect( + _serverDisconnect(_apiError(code)).isAutomaticReconnectionEnabled, + isFalse, + reason: 'code $code', + ); + } + }); + + test('is disabled for any other client error', () { + // 17 = not allowed. Nothing about retrying changes the answer. + final state = _serverDisconnect(_apiError(17, statusCode: 403)); + + expect(state.isAutomaticReconnectionEnabled, isFalse); + }); + + test('is enabled for a server-side failure', () { + // Stream error codes never fall in 400..499, so this is classified by the + // status code alone — which is what the ported rule got wrong. + final state = _serverDisconnect(_apiError(9, statusCode: 500)); expect(state.isAutomaticReconnectionEnabled, isTrue); }); - test( - 'is enabled when a connection attempt timed out, since a handshake that ' - 'did not complete in time is the same failure as one that stopped', - () { - const state = Disconnected(source: ConnectTimeout()); + test('is disabled when the socket was closed deliberately', () { + const state = Disconnected( + source: ServerInitiated( + error: WebSocketEngineException( + code: WebSocketEngineException.stopErrorCode, + ), + ), + ); - expect(state.isAutomaticReconnectionEnabled, isTrue); - }, - ); + expect(state.isAutomaticReconnectionEnabled, isFalse); + }); + + test('is enabled when the server closed without saying why', () { + const state = Disconnected(source: ServerInitiated()); + + expect(state.isAutomaticReconnectionEnabled, isTrue); + }); test( 'is disabled when a connection could not be authenticated, since the ' From 0a6d8e6e88448c4131cb22ba7529f6070b91e41f Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 21:13:55 +0200 Subject: [PATCH 44/97] refactor(llc): keep the reconnection rules in the switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rules were the same, but split between a switch and a function, so reading what happens after a server-initiated close meant leaving the one place that lists every source. Two `ServerInitiated` cases — one guarded on the close code, one on what the API error says — keep it whole, and the switch stays exhaustive over the sealed source. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/errors/stream_api_error.dart | 4 +-- .../client/web_socket_connection_state.dart | 34 ++++++------------- 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/packages/stream_core/lib/src/errors/stream_api_error.dart b/packages/stream_core/lib/src/errors/stream_api_error.dart index 14eeccca..f08f5052 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.dart @@ -91,9 +91,7 @@ extension StreamApiErrorExtension on StreamApiError { bool get isTokenExpiredError => code == _expiredTokenCode; /// Whether the token, or the key it was signed with, cannot be accepted. - bool get isInvalidTokenError { - return _invalidTokenCodes.contains(code) || code == _accessKeyErrorCode; - } + bool get isInvalidTokenError => _invalidTokenCodes.contains(code) || code == _accessKeyErrorCode; /// Whether this error is a client-side error (4xx status codes). bool get isClientError => _clientErrorStatusCodes.contains(statusCode); diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index 3c15bbbe..63064401 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -107,7 +107,17 @@ sealed class WebSocketConnectionState extends Equatable { bool get isAutomaticReconnectionEnabled { return switch (this) { Disconnected(:final source) => switch (source) { - ServerInitiated(:final error) => _canReconnectAfter(error), + // A deliberate stop rather than a failure to recover from. + ServerInitiated(:final error) when error?.code == WebSocketEngineException.stopErrorCode => false, + ServerInitiated(:final error) => switch (error?.apiError) { + // Another token is refused for the same reason, so asking the provider + // for one is futile. + final it? when it.isInvalidTokenError => false, + // Whatever else the client got wrong is the caller's to fix — except + // an expired token, which is replaced rather than corrected. + final it? when it.isClientError && !it.isTokenExpiredError => false, + _ => true, // Reconnect on other server initiated disconnections + }, UnHealthyConnection() => true, SystemInitiated() => true, UserInitiated() => false, @@ -363,25 +373,3 @@ final class AuthenticationFailed extends DisconnectionSource { @override List get props => [error]; } - -// Whether a connection the server closed is worth opening again. -// -// Mirrors the rules the iOS SDK applies, which the ported version had drifted -// from: it compared the API error's code against the close code 1000 and against -// a 400..499 range that Stream's codes never occupy, so neither rule could fire. -bool _canReconnectAfter(WebSocketEngineException? error) { - // A deliberate stop rather than a failure to recover from. - if (error?.code == WebSocketEngineException.stopErrorCode) return false; - - final apiError = error?.apiError; - if (apiError == null) return true; - - // Another token is refused for the same reason, so asking for one is futile. - if (apiError.isInvalidTokenError) return false; - - // Whatever else the client got wrong is the caller's to fix — except an - // expired token, which is replaced rather than corrected. - if (apiError.isClientError && !apiError.isTokenExpiredError) return false; - - return true; -} From e4ac1b467bf4ec79e661d02b78a0de0b1c3ea892 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 21:22:15 +0200 Subject: [PATCH 45/97] refactor(llc): name the close code with the type that models close codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `WebSocketEngineException.stopErrorCode` was a second name for 1000, kept from the iOS SDK's `WebSocketEngineError.stopErrorCode`, sitting beside a `CloseCode` extension type that already documents the whole range — and describing a normal closure as a "stop error" besides. Two names for one number in the same library is what let the reconnection rule compare it against an API error's code in the first place: a bare int has no home to be wrong about. `CloseCode.normalClosure` is the value `disconnect` and the engine's `close` already pass, and it is a `CloseCode` implementing `int`, so the comparison is unchanged. Its only readers were that rule and its test. A stale reference survives inside the commented-out block in `client_exception.dart`, left alone with the rest of it. Co-Authored-By: Claude Opus 5 (1M context) --- .../stream_core/lib/src/ws/client/engine/web_socket_engine.dart | 2 -- .../lib/src/ws/client/web_socket_connection_state.dart | 2 +- .../test/ws/client/web_socket_connection_state_test.dart | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart index 7abf7513..d44a0c00 100644 --- a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart @@ -195,8 +195,6 @@ class WebSocketEngineException extends Equatable implements Exception { return null; } - static const stopErrorCode = 1000; - @override List get props => [reason, code, error]; } diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index 63064401..9363ab97 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -108,7 +108,7 @@ sealed class WebSocketConnectionState extends Equatable { return switch (this) { Disconnected(:final source) => switch (source) { // A deliberate stop rather than a failure to recover from. - ServerInitiated(:final error) when error?.code == WebSocketEngineException.stopErrorCode => false, + ServerInitiated(:final error) when error?.code == CloseCode.normalClosure => false, ServerInitiated(:final error) => switch (error?.apiError) { // Another token is refused for the same reason, so asking the provider // for one is futile. diff --git a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart index 927270a5..fd5b76cf 100644 --- a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart @@ -62,7 +62,7 @@ void main() { const state = Disconnected( source: ServerInitiated( error: WebSocketEngineException( - code: WebSocketEngineException.stopErrorCode, + code: CloseCode.normalClosure, ), ), ); From dd48bce8a24234cd066684f5b2c81752b226d0ae Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 21:31:34 +0200 Subject: [PATCH 46/97] fix(llc): reconnect after a rate limit, which clears on its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rate-limited connect was treated like every other 4xx and never retried, so a client that hit the limit stayed down until the app intervened — for a condition that resolves without anyone doing anything. The backend says as much on the websocket path. It calls `SetHeaders` before the check, so the limit, the remainder and the window's `Reset` are on the upgrade response, then closes with `RateLimitError("Too many requests, check response headers for more information.")` over a one-minute window (`monolith/server/base.go:1707`). Of every 4xx it can reject a connect with, this is the only one carrying a reset: `auth_rejected`, `app_disabled` and `validation_failed` carry none, and `too_many_connections` carries a link to the client-instantiation docs — a pointer at the caller's bug, not a time to retry. Our backoff tops out at 25 seconds per attempt, comfortably inside that window. This also gives `isRateLimitError` its first reader; it had none. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 1 + .../client/web_socket_connection_state.dart | 46 +++++++------------ .../web_socket_connection_state_test.dart | 9 ++++ 3 files changed, 26 insertions(+), 30 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index ce46fa29..984caa27 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -40,6 +40,7 @@ - Fixed a `WebSocketAuthenticator` that throws, rather than returning a failure, escaping as an unhandled error and leaving the connection authenticating until it timed out — losing the cause, which the timeout does not carry. The natural authenticator throws, since loading a token does - Fixed `StreamWebSocketClient.disconnect` replacing the source of a closure already under way, which could turn a reconnectable `ServerInitiated` error into a permanent `ConnectTimeout` - Fixed `isAutomaticReconnectionEnabled` neither refusing a deliberate server close nor refusing client errors: it compared the API error's code against the close code 1000, and against a 400..499 range that Stream codes never occupy, so both rules were dead. It now mirrors the iOS SDK — close code 1000, invalid tokens and 4xx all refuse, an expired token does not +- A connection the server closed because the request was rate limited is now eligible for automatic reconnection. The backend closes it with the rate-limit window's reset in the response headers and a one-minute window, so the condition clears on its own — unlike every other 4xx it can close a socket with, which carry no reset - A connection the server closed because the token expired is now eligible for automatic reconnection, so a token expiring mid-session recovers instead of ending the session. Pair it with `TokenRefreshReconnectionPolicy` and something that expires the cached token, or the retry presents the same one - Fixed `ConnectionRecoveryHandler` retrying a first connection attempt, which failed the caller of `connect` and reconnected behind them at the same time — and made the caller's own retry fail with "connection already in progress". It now recovers only connections that have existed since the caller last asked for one, so a deliberate `disconnect` hands connecting back and the next `connect` is the caller's attempt again - Fixed a health check arriving while disconnecting reporting the connection as established again, which replaced the disconnection source and could turn a deliberate disconnect into an automatic reconnect diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index 9363ab97..a46ca53d 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -100,40 +100,26 @@ sealed class WebSocketConnectionState extends Equatable { /// - User-initiated disconnections (explicit disconnect calls) /// - A socket the server closed deliberately (close code 1000) /// - Tokens another token would not fix, and a wrong API key - /// - Client errors (4xx status codes), other than an expired token + /// - Client errors (4xx status codes), other than an expired token or a rate limit /// - A failure to load or send credentials /// /// Returns `true` if automatic reconnection should be attempted. - bool get isAutomaticReconnectionEnabled { - return switch (this) { - Disconnected(:final source) => switch (source) { - // A deliberate stop rather than a failure to recover from. - ServerInitiated(:final error) when error?.code == CloseCode.normalClosure => false, - ServerInitiated(:final error) => switch (error?.apiError) { - // Another token is refused for the same reason, so asking the provider - // for one is futile. - final it? when it.isInvalidTokenError => false, - // Whatever else the client got wrong is the caller's to fix — except - // an expired token, which is replaced rather than corrected. - final it? when it.isClientError && !it.isTokenExpiredError => false, - _ => true, // Reconnect on other server initiated disconnections - }, - UnHealthyConnection() => true, - SystemInitiated() => true, - UserInitiated() => false, - // A handshake that did not complete in time is the same failure as one - // that stops answering health checks. A caller's own first attempt is - // kept out of this by the recovery handler, which recovers connections - // that existed rather than attempts that never landed. - ConnectTimeout() => true, - // Not the server refusing the credentials, which arrives as an error - // frame: this is the client failing to load or send them, and it will - // fail the same way on a retry. - AuthenticationFailed() => false, + bool get isAutomaticReconnectionEnabled => switch (this) { + Disconnected(:final source) => switch (source) { + ServerInitiated(:final error) when error?.code == CloseCode.normalClosure => false, + ServerInitiated(:final error) => switch (error?.apiError) { + final error? when error.isInvalidTokenError => false, + final error? when error.isClientError && !error.isTokenExpiredError && !error.isRateLimitError => false, + _ => true, // Reconnect on other server initiated disconnections }, - _ => false, // No automatic reconnection for other states - }; - } + UnHealthyConnection() => true, + SystemInitiated() => true, + ConnectTimeout() => true, + UserInitiated() => false, + AuthenticationFailed() => false, + }, + _ => false, // No automatic reconnection for other states + }; @override List get props => []; diff --git a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart index fd5b76cf..1b854860 100644 --- a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart @@ -43,6 +43,15 @@ void main() { } }); + test('is enabled when the request was rate limited', () { + // 9 = rate limited, sent as 429. The server closes with the window's reset + // in the response headers, so the condition clears without the caller + // doing anything. + final state = _serverDisconnect(_apiError(9, statusCode: 429)); + + expect(state.isAutomaticReconnectionEnabled, isTrue); + }); + test('is disabled for any other client error', () { // 17 = not allowed. Nothing about retrying changes the answer. final state = _serverDisconnect(_apiError(17, statusCode: 403)); From 0e4b96a9cbac0cdbbc1ed29628568318ce48078e Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 21:39:56 +0200 Subject: [PATCH 47/97] fix(llc): let the caller replace a refused token, and stop trying to help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes the arrangement built up over 72b1447 and dd48bce: a token-expired close was made reconnect-eligible, a `TokenRefreshReconnectionPolicy` vetoed the cases where that would loop, and `stream-feeds-flutter` expired the cached token from a connection-state listener so the retry would present a new one. Three pieces, in two packages, splitting one decision — and the ordering only worked because the recovery handler schedules a timer rather than connecting inline. The Android SDK does it in one place instead, inside the connect operation: socketSession.connect(data).onTokenError { error, code -> tokenManager.invalidate() tokenManager.refresh().flatMap { newToken -> socketSession.connect(data.copy(token = newToken.rawValue)) } } Invalidate, obtain another, attempt once more, and if that fails, fail the connect. Nothing consults reconnection eligibility, because the connect itself resolved it. `StreamFeedsClient.connect` already awaits the whole outcome, so it can do exactly that, and does. So automatic reconnection refuses a token-expired close again — correct for the reason it always was: the recovery handler cannot replace a credential, so a retry it makes presents the same one. `isExpiredTokenDisconnection` stays, as the way a caller that *can* replace it is told to. The cost, stated plainly: a token expiring on a live connection is no longer recovered automatically. The connection closes and waits for the app to connect again, which then refreshes as above. Android accepts the same — its evaluator reconnects only on network and lifecycle transitions — and iOS calls `connect()` explicitly rather than relying on recovery. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 3 +- .../automatic_reconnection_policy.dart | 39 ---------- .../client/web_socket_connection_state.dart | 16 +++- .../automatic_reconnection_policy_test.dart | 76 ------------------- .../web_socket_connection_state_test.dart | 12 ++- 5 files changed, 22 insertions(+), 124 deletions(-) delete mode 100644 packages/stream_core/test/ws/client/reconnect/automatic_reconnection_policy_test.dart diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 984caa27..e0d30d7d 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -21,11 +21,11 @@ - Added `User.anonymousUserId`, the id every anonymous user has - Added `TokenManager.unconfigured`, for a client that exists before its user does - Added `TokenManager.reset`, which drops the configured identity and its cached token -- Added `TokenRefreshReconnectionPolicy`, which stops a reconnection that would present a token the server has already refused. Whether another token exists is a property of the `TokenProvider`, not of the connection, so the connection state cannot decide it alone - Added `DisconnectionSource.connectTimeout`, reported when a connection attempt is abandoned before the connection is established; it is eligible for automatic reconnection, since a handshake that did not complete in time is the same failure as a connection that stops answering health checks - Added `DisconnectionSource.authenticationFailed`, reported with its cause when a connection opens but cannot be authenticated - `StreamWebSocketClient` now honours `WebSocketOptions.connectTimeout`, which is no longer nullable and defaults to `WebSocketOptions.defaultConnectTimeout`. This is a behaviour change as well as an API one: a connection previously waited indefinitely for its first health check, and is now abandoned — and reconnected — after 30 seconds, matching the wait the Swift SDK allows for the same handshake - Added `WsSender`, the send capability handed to a `WebSocketAuthenticator` +- Added `WebSocketConnectionState.isExpiredTokenDisconnection`, so a caller that can replace the token knows when to. Automatic reconnection deliberately refuses this case: whoever retries it would present the token the server just refused, and only the caller can obtain another - Added `ConnectUserDetailsRequest.fromUser`, which builds the details a client may send from a `User` - Added `StreamWebSocketClient.dispose`, which closes the connection along with `events` and `connectionState`; the client is `Disposable`, so `isDisposed` reports whether it has been called - Added `teams` field to `User` class @@ -41,7 +41,6 @@ - Fixed `StreamWebSocketClient.disconnect` replacing the source of a closure already under way, which could turn a reconnectable `ServerInitiated` error into a permanent `ConnectTimeout` - Fixed `isAutomaticReconnectionEnabled` neither refusing a deliberate server close nor refusing client errors: it compared the API error's code against the close code 1000, and against a 400..499 range that Stream codes never occupy, so both rules were dead. It now mirrors the iOS SDK — close code 1000, invalid tokens and 4xx all refuse, an expired token does not - A connection the server closed because the request was rate limited is now eligible for automatic reconnection. The backend closes it with the rate-limit window's reset in the response headers and a one-minute window, so the condition clears on its own — unlike every other 4xx it can close a socket with, which carry no reset -- A connection the server closed because the token expired is now eligible for automatic reconnection, so a token expiring mid-session recovers instead of ending the session. Pair it with `TokenRefreshReconnectionPolicy` and something that expires the cached token, or the retry presents the same one - Fixed `ConnectionRecoveryHandler` retrying a first connection attempt, which failed the caller of `connect` and reconnected behind them at the same time — and made the caller's own retry fail with "connection already in progress". It now recovers only connections that have existed since the caller last asked for one, so a deliberate `disconnect` hands connecting back and the next `connect` is the caller's attempt again - Fixed a health check arriving while disconnecting reporting the connection as established again, which replaced the disconnection source and could turn a deliberate disconnect into an automatic reconnect diff --git a/packages/stream_core/lib/src/ws/client/reconnect/automatic_reconnection_policy.dart b/packages/stream_core/lib/src/ws/client/reconnect/automatic_reconnection_policy.dart index 74ad603d..e26ee529 100644 --- a/packages/stream_core/lib/src/ws/client/reconnect/automatic_reconnection_policy.dart +++ b/packages/stream_core/lib/src/ws/client/reconnect/automatic_reconnection_policy.dart @@ -1,5 +1,3 @@ -import '../../../errors.dart'; -import '../../../user/token_manager.dart'; import '../../../utils.dart'; import '../web_socket_connection_state.dart'; @@ -97,40 +95,3 @@ class CompositeReconnectionPolicy implements AutomaticReconnectionPolicy { }; } } - -/// A policy that only reconnects when the credential can be replaced. -/// -/// A connection the server closed because the token expired is worth retrying, -/// but only with a different token — and whether one can be obtained is a -/// property of the [TokenProvider] rather than of the connection. A provider -/// that always returns the same token has nothing else to offer, so reconnecting -/// would present exactly what was just refused. -/// -/// Pair it with whatever expires the cached token, so the attempt this permits -/// loads a fresh one. -class TokenRefreshReconnectionPolicy implements AutomaticReconnectionPolicy { - /// Creates a [TokenRefreshReconnectionPolicy]. - const TokenRefreshReconnectionPolicy({ - required this.connectionState, - required this.tokenManager, - }); - - /// The connection state to read the last disconnection from. - final ConnectionStateEmitter connectionState; - - /// The manager whose provider decides whether another token is available. - final TokenManager tokenManager; - - @override - bool canBeReconnected() { - final refusedTheToken = switch (connectionState.value) { - Disconnected(source: ServerInitiated(:final error)) => error?.apiError?.isTokenExpiredError ?? false, - _ => false, - }; - - // Every other disconnection is somebody else's call. - if (!refusedTheToken) return true; - - return !tokenManager.usesStaticProvider; - } -} diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index a46ca53d..d18e8cf9 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -100,16 +100,26 @@ sealed class WebSocketConnectionState extends Equatable { /// - User-initiated disconnections (explicit disconnect calls) /// - A socket the server closed deliberately (close code 1000) /// - Tokens another token would not fix, and a wrong API key - /// - Client errors (4xx status codes), other than an expired token or a rate limit + /// - Client errors (4xx status codes), other than a rate limit /// - A failure to load or send credentials /// + /// Whether the server closed this connection because the token had expired. + /// + /// Reported so a caller that can replace the token knows to, since a + /// reconnection cannot: [isAutomaticReconnectionEnabled] refuses this, because + /// whoever retries it here would present the token that was just refused. + bool get isExpiredTokenDisconnection => switch (this) { + Disconnected(source: ServerInitiated(:final error)) => error?.apiError?.isTokenExpiredError ?? false, + _ => false, + }; + /// Returns `true` if automatic reconnection should be attempted. bool get isAutomaticReconnectionEnabled => switch (this) { Disconnected(:final source) => switch (source) { ServerInitiated(:final error) when error?.code == CloseCode.normalClosure => false, ServerInitiated(:final error) => switch (error?.apiError) { - final error? when error.isInvalidTokenError => false, - final error? when error.isClientError && !error.isTokenExpiredError && !error.isRateLimitError => false, + final it? when it.isInvalidTokenError => false, + final it? when it.isClientError && !it.isRateLimitError => false, _ => true, // Reconnect on other server initiated disconnections }, UnHealthyConnection() => true, diff --git a/packages/stream_core/test/ws/client/reconnect/automatic_reconnection_policy_test.dart b/packages/stream_core/test/ws/client/reconnect/automatic_reconnection_policy_test.dart deleted file mode 100644 index c8a59c51..00000000 --- a/packages/stream_core/test/ws/client/reconnect/automatic_reconnection_policy_test.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:stream_core/stream_core.dart'; -import 'package:test/test.dart'; - -import '../../../helpers/user_token.dart'; - -StreamApiError _apiError(int code) => StreamApiError( - code: code, - details: const [], - duration: '0ms', - message: 'error $code', - moreInfo: '', - statusCode: 401, -); - -ConnectionStateEmitter _stateOf(WebSocketConnectionState state) { - return MutableConnectionStateEmitter(state); -} - -WebSocketConnectionState _refused(StreamApiError apiError) { - return WebSocketConnectionState.disconnected( - source: DisconnectionSource.serverInitiated( - error: WebSocketEngineException(error: apiError), - ), - ); -} - -void main() { - group('TokenRefreshReconnectionPolicy', () { - test('refuses to reconnect when the provider has only one token', () { - final policy = TokenRefreshReconnectionPolicy( - // Token-invalid error codes are 40..42; 40 = token expired. - connectionState: _stateOf(_refused(_apiError(40))), - tokenManager: TokenManager( - userId: 'user-1', - tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), - ), - ); - - // Reconnecting would present the token the server just refused, over and - // over — the loop this policy exists to prevent. - expect(policy.canBeReconnected(), isFalse); - }); - - test('reconnects when the provider can issue another token', () { - final policy = TokenRefreshReconnectionPolicy( - connectionState: _stateOf(_refused(_apiError(40))), - tokenManager: TokenManager( - userId: 'user-1', - tokenProvider: TokenProvider.dynamic( - (userId) async => generateTestUserToken(userId), - ), - ), - ); - - expect(policy.canBeReconnected(), isTrue); - }); - - test('leaves every other disconnection to the other policies', () { - final policy = TokenRefreshReconnectionPolicy( - // A static provider, so this passes only because the disconnection has - // nothing to do with the token. - connectionState: _stateOf( - const WebSocketConnectionState.disconnected( - source: DisconnectionSource.unHealthyConnection(), - ), - ), - tokenManager: TokenManager( - userId: 'user-1', - tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), - ), - ); - - expect(policy.canBeReconnected(), isTrue); - }); - }); -} diff --git a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart index 1b854860..5219159d 100644 --- a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart @@ -23,11 +23,15 @@ Disconnected _serverDisconnect(StreamApiError apiError) => Disconnected( void main() { group('WebSocketConnectionState.isAutomaticReconnectionEnabled', () { test( - 'is enabled when the token has expired, which another token replaces', + 'is disabled when the token has expired, since a retry here would present ' + 'the same one', () { - // 40 = expired; the server returns 401 with it, so the client-error rule - // has to make room for this one. - expect(_serverDisconnect(_apiError(40)).isAutomaticReconnectionEnabled, isTrue); + // 40 = expired. Replacing it is the caller's to do, and it is the caller + // that retries — `isExpiredTokenDisconnection` is how they are told. + final state = _serverDisconnect(_apiError(40)); + + expect(state.isAutomaticReconnectionEnabled, isFalse); + expect(state.isExpiredTokenDisconnection, isTrue); }, ); From 7ef363f592c2a52f614116e896300b06d20bf642 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 21 Aug 2026 13:18:47 +0200 Subject: [PATCH 48/97] feat(llc): give a token an expiry it can be asked about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `UserToken` carries `expiresAt`, read from its `exp` claim and normalised to UTC, and answers `isExpired` with an optional leeway. A token naming no expiry is never expired, and one whose `exp` is present but not a number is read as naming none rather than throwing out of a constructor. Nothing consults this yet. `TokenManager` keeps serving whatever it has cached, and an expired token is still discovered by the server refusing it. Declining to send a token that is about to run out turned out to belong with the caller that would have to reload it — putting the decision in `peekToken` broke the two callers that use it to ask whether a token is present rather than whether it is valid — so it is left for that change rather than guessed at here. The three token test files now share one `alg: none` JWT builder under `test/helpers/`, which needed the style guide's rule on shared fixtures written down. Co-Authored-By: Claude Opus 5 (1M context) --- STYLE_GUIDE.md | 9 +- melos.yaml | 1 + packages/stream_core/CHANGELOG.md | 13 +- .../lib/src/user/token_manager.dart | 99 ++++----- .../stream_core/lib/src/user/user_token.dart | 48 ++++- packages/stream_core/pubspec.yaml | 1 + .../stream_core/test/helpers/user_token.dart | 19 +- .../test/user/token_manager_test.dart | 195 ++++++------------ .../test/user/token_provider_test.dart | 61 ++---- packages/stream_core/test/user/user_test.dart | 5 +- .../test/user/user_token_test.dart | 114 ++++++++++ 11 files changed, 296 insertions(+), 269 deletions(-) create mode 100644 packages/stream_core/test/user/user_token_test.dart diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index e56ff027..0fe5ce0f 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -844,11 +844,10 @@ over the global `tearDown` callback. The rule targets shared state, not pure construction. A deterministic fixture builder that holds no state — a signed token, an encoded payload, a fixed -timestamp — may live under `test/helpers/` and be imported by several test files, -so one correct definition serves all of them. Copies of a fixture builder tend to -drift, and a subtly wrong fixture is harder to spot than a shared one. Anything -that holds state between tests, or that arranges a scenario rather than building a -value, stays local to the test file. +timestamp — may live under `test/helpers/` and be imported by several test files: +copies of one tend to drift, and a subtly wrong fixture is harder to spot than a +shared one. Anything that holds state between tests, or that arranges a scenario +rather than building a value, stays local to the test file. ### Prefer more test files, avoid long test files diff --git a/melos.yaml b/melos.yaml index b2641fe8..fb7fcfe7 100644 --- a/melos.yaml +++ b/melos.yaml @@ -19,6 +19,7 @@ command: # List of all the dependencies used in the project. dependencies: cached_network_image_ce: ^4.9.0 + clock: ^1.1.2 collection: ^1.19.0 cross_file: ^0.3.4+2 dio: ^5.8.0+1 diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 8b86e432..39c84e83 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -5,13 +5,14 @@ - Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `User.anonymousUserId` - Removed the `TokenManager.tokenProvider` setter, use `setTokenProvider` instead - `TokenManager.userId` is now nullable, and is `null` until an identity is configured -- `User` now requires a user of type `UserType.anonymous` to carry `User.anonymousUserId` as its id. The constructor is `const`, so a mismatch in a const context fails to compile rather than throwing in debug mode +- `User` now requires a user of type `UserType.anonymous` to carry `User.anonymousUserId` as its id. A mismatch fails to compile in a const context, and throws in debug mode otherwise ### ✨ Features - Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token - Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load -- Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access, provided its `user_id` claim is `User.anonymousUserId` (`!anon`), which the server also requires +- Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access; its `user_id` claim must be `!anon` +- Added `UserToken.expiresAt`, from the token's `exp` claim, and `UserToken.isExpired`, which takes an optional `leeway` - Added `User.anonymousUserId`, the id every anonymous user has - Added `TokenManager.unconfigured`, for a client that exists before its user does - Added `TokenManager.reset`, which drops the configured identity and its cached token @@ -26,11 +27,11 @@ ### 🔄 Changed - Raised the minimum Dart SDK to `^3.12.0` -- Anonymous requests now always send `user_id=!anon`. The value previously came from the `TokenManager`, so it was whatever the caller configured; the server requires the claim to be `!anon` and derives the anonymous session itself, so the parameter now matches +- Anonymous requests now always send `user_id=!anon`, rather than whatever id the `TokenManager` was configured with - `DynamicTokenProvider` checks the token type before its user id, so a token of the wrong type is reported as such instead of as a mismatched user -- `TokenManager.setTokenProvider` does nothing when handed the identity it already has, instead of expiring the cached token. The provider is compared with `==`, so one that defines value equality decides when a replacement counts as the same -- `TokenManager.getToken` fails when `reset` runs while the token is loading, instead of returning a token for a user the manager no longer has. A `setTokenProvider` during a load still serves the caller that started it -- `TokenManager.getToken` rejects a token whose `user_id` is not the user it was loading for, which a custom `TokenProvider` is not obliged to check itself +- `TokenManager.setTokenProvider` does nothing when handed the identity it already has; providers are compared with `==` +- `TokenManager.getToken` fails when `reset` runs while the token is loading; a `setTokenProvider` during a load still serves the caller that started it +- `TokenManager.getToken` rejects a token whose `user_id` is not the user it was loading for - `AuthInterceptor` no longer attempts a token refresh when the manager has no identity, so the original token-expired error is surfaced rather than a failure to load a token ## 0.4.0 diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index 501e97d6..25dc53a9 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -7,8 +7,7 @@ import 'user_token.dart'; /// A callback invoked whenever the manager caches a newly loaded token. /// /// Invoked synchronously after the token is cached, before it is returned to -/// the caller that triggered the load. Throwing from it surfaces to that -/// caller, even though the token was loaded and cached successfully. +/// the caller that triggered the load. typedef OnTokenUpdated = void Function(UserToken token); /// Manages user authentication tokens with caching and thread-safe access. @@ -34,14 +33,12 @@ typedef OnTokenUpdated = void Function(UserToken token); /// manager.expireToken(); /// ``` class TokenManager { - /// Creates a [TokenManager] for the specified `userId` with the given - /// `tokenProvider`. + /// Creates a [TokenManager] for the specified [userId] with the given + /// [tokenProvider]. /// - /// The `userId` identifies the user for whom tokens will be managed. - /// The `tokenProvider` is used to load tokens when needed. - /// - /// An optional `onTokenUpdated` callback is invoked after every successful - /// token load. It is not invoked for callers served from the cache. + /// An optional [onTokenUpdated] callback is invoked whenever a loaded token is cached. Not for a + /// caller served from the cache, and not for a load that [expireToken] or [setTokenProvider] + /// invalidated while it ran: that token reaches its caller but is never cached. TokenManager({ required String userId, required TokenProvider tokenProvider, @@ -55,11 +52,8 @@ class TokenManager { /// token; this one has no user at all. TokenManager.unconfigured({this._onTokenUpdated}) : _identity = null; - // The user being managed and the provider that loads their tokens. - // - // A single field rather than two, so the two can never disagree: a user - // without a provider cannot load, and a provider without a user has nothing - // to load for. `null` means no identity is configured. + // A single field rather than two, so a user can never be paired with another user's provider. + // `null` means no identity is configured. ({String userId, TokenProvider provider})? _identity; /// The unique identifier of the user whose tokens are managed, or `null` when @@ -72,16 +66,14 @@ class TokenManager { // Invoked after every successful token load. final OnTokenUpdated? _onTokenUpdated; - /// Points this manager at `userId`, loading its tokens from `tokenProvider`. + /// Points this manager at [userId], loading its tokens from [tokenProvider]. /// - /// The user and the provider change together, so the manager can never cache - /// one user's token under another. Expires the cached token, and discards a - /// load already in flight, so the next [getToken] call loads a fresh one for - /// the new user. + /// The user and the provider change together, so the manager can never cache one user's token + /// under another. Expires the cached token and discards a load already in flight. Setting the + /// identity it already has does nothing; providers are compared with `==`. /// - /// To reuse a manager across users, or to authenticate as a user whose - /// identity is only known after an authenticated request — a guest, whose id - /// and token are both issued in exchange for an anonymous one — consider: + /// Useful to reuse a manager across users, or to adopt an id that is only known after an + /// authenticated request, such as a guest's. /// /// ```dart /// // Authenticate anonymously while the real identity is being obtained. @@ -96,11 +88,10 @@ class TokenManager { /// tokenProvider: TokenProvider.static(UserToken(rawToken)), /// ); /// ``` - /// Re-setting the identity this manager already has does nothing: expiring - /// the cached token would send the next caller to the provider for no reason. - /// The provider is compared with `==`, so a provider that defines value - /// equality decides for itself when a replacement is the same as what it - /// replaces; one that does not is compared by instance. + /// + /// See also: + /// + /// * [reset], which drops the identity rather than replacing it. void setTokenProvider( String userId, { required TokenProvider tokenProvider, @@ -118,9 +109,8 @@ class TokenManager { /// Drops the configured identity, returning this manager to the state of /// [TokenManager.unconfigured]. /// - /// [getToken] fails until [setTokenProvider] supplies an identity again. Use - /// this when the user is going away for good; to keep the identity and only - /// force a reload, use [expireToken]. + /// [getToken] fails until [setTokenProvider] supplies an identity again. Consider this when the + /// user is logging out; to keep the identity and force only a reload, consider [expireToken]. void reset() { _identity = null; expireToken(); @@ -133,10 +123,9 @@ class TokenManager { // before that point can tell its result is no longer wanted. var _generation = 0; - /// Returns the currently cached token without loading a new one. + /// Returns the cached token, without loading a new one. /// - /// Returns the cached [UserToken] if available, or null if no token - /// is currently cached or if the token has been expired. + /// `null` when nothing is cached, or when the cache was expired. UserToken? peekToken() => _cachedToken; /// Whether this manager uses a static token provider. @@ -146,29 +135,19 @@ class TokenManager { /// identity is configured. bool get usesStaticProvider => _identity?.provider is StaticTokenProvider; - /// Gets a valid token for the user, loading one if necessary. + /// Returns the cached token, loading one from the [TokenProvider] when nothing is cached. /// - /// Returns the cached token if available, otherwise loads a new token - /// from the [TokenProvider] and caches it for future use. This method - /// is thread-safe and ensures only one token loading operation occurs - /// at a time. + /// Loads are serialised, so a provider that never returns blocks every later caller, including one + /// for a different user configured by [setTokenProvider] in the meantime. /// - /// Returns a [Future] that resolves to a [UserToken] for the user. - /// - /// Fails with a [ClientException] when no identity is configured, either - /// because the manager was created with [TokenManager.unconfigured] or - /// because [reset] dropped the previous one, and when [reset] runs while the + /// Fails with a [ClientException] when no identity is configured, or when [reset] runs while the /// token is loading. - /// - /// Loads are serialised, so a provider that never returns blocks every later - /// caller — including one for a different user configured by - /// [setTokenProvider] in the meantime. Future getToken() { - final cached = _cachedToken; + final cached = peekToken(); if (cached != null) return Future.value(cached); return synchronized(() { - final currentToken = _cachedToken; + final currentToken = peekToken(); if (currentToken != null) return Future.value(currentToken); return _loadAndNotify(); @@ -187,21 +166,17 @@ class TokenManager { final loadingGeneration = _generation; final updatedToken = await identity.provider.loadToken(loadingFor); - // Both built-in providers check this, but a custom one is under no - // obligation to, and caching a token for another user would authenticate - // every later request as them. + // Both built-in providers check this, but a custom one need not: caching another user's token + // would authenticate every later request as them. if (updatedToken.userId != loadingFor) { - throw ArgumentError( - 'User ID mismatch: expected "$loadingFor", got "${updatedToken.userId}"', - ); + throw ArgumentError('User ID mismatch: expected "$loadingFor", got "${updatedToken.userId}"'); } - // `setTokenProvider` or `expireToken` may have run while this loaded, in - // which case the token is the one the caller asked to stop using. + // `setTokenProvider` or `expireToken` may have run while this loaded, in which case the token + // is the one the caller asked to stop using. if (loadingGeneration != _generation) { - // A `reset` means the user is gone, so nothing may go out as them. A - // switch is different: the request that started as this user may finish - // as them. + // After a `reset` the user is gone, so the token is not returned. After a switch it is: the + // caller that started as this user may finish as them. if (_identity == null) { throw ClientException(message: 'The user was reset while its token was loading'); } @@ -221,8 +196,8 @@ class TokenManager { /// load a fresh token from the provider. This is useful when a token /// becomes invalid or needs to be refreshed. /// - /// A load already in flight is discarded too, rather than caching the token - /// this call asked to stop using. + /// A load already in flight is discarded too, so it cannot cache the token this call asked to + /// stop using. void expireToken() { _generation++; _cachedToken = null; diff --git a/packages/stream_core/lib/src/user/user_token.dart b/packages/stream_core/lib/src/user/user_token.dart index 8a425ae8..a6b7facf 100644 --- a/packages/stream_core/lib/src/user/user_token.dart +++ b/packages/stream_core/lib/src/user/user_token.dart @@ -1,3 +1,4 @@ +import 'package:clock/clock.dart'; import 'package:equatable/equatable.dart'; import 'package:jose/jose.dart'; @@ -41,8 +42,8 @@ class UserToken extends Equatable { /// Throws an [ArgumentError] if the 'user_id' claim is missing or empty, and /// a [FormatException] if [rawValue] cannot be parsed as a JWT. factory UserToken(String rawValue) { - final jwtBody = JsonWebToken.unverified(rawValue); - final userId = jwtBody.claims.getTyped('user_id'); + final claims = JsonWebToken.unverified(rawValue).claims; + final userId = claims.getTyped('user_id'); if (userId == null || userId.isEmpty) { throw ArgumentError.value( rawValue, @@ -55,6 +56,7 @@ class UserToken extends Equatable { rawValue: rawValue, userId: userId, authType: AuthType.jwt, + expiresAt: _expiresAtOf(claims), ); } @@ -63,9 +65,8 @@ class UserToken extends Equatable { /// Anonymous tokens always use [User.anonymousUserId] as their user id. /// /// An optional [rawValue] can carry a JWT that is sent along with anonymous - /// requests, granting the caller access to the specific resources its claims - /// name. When omitted, the token carries no raw value and requests are sent - /// without credentials. + /// requests, granting access to the specific resources its claims name. When omitted, the token + /// carries no raw value and requests are sent without credentials. /// /// Returns a [UserToken] configured for anonymous access. /// @@ -73,9 +74,11 @@ class UserToken extends Equatable { /// is not [User.anonymousUserId], and a [FormatException] if it cannot be parsed /// as a JWT. factory UserToken.anonymous({String rawValue = ''}) { + DateTime? expiresAt; if (rawValue.isNotEmpty) { - final jwtBody = JsonWebToken.unverified(rawValue); - final userId = jwtBody.claims.getTyped('user_id'); + final claims = JsonWebToken.unverified(rawValue).claims; + expiresAt = _expiresAtOf(claims); + final userId = claims.getTyped('user_id'); if (userId != User.anonymousUserId) { throw ArgumentError.value( userId, @@ -89,6 +92,7 @@ class UserToken extends Equatable { rawValue: rawValue, userId: User.anonymousUserId, authType: AuthType.anonymous, + expiresAt: expiresAt, ); } @@ -96,6 +100,7 @@ class UserToken extends Equatable { required this.rawValue, required this.userId, required this.authType, + this.expiresAt, }); /// The raw token value. @@ -115,6 +120,25 @@ class UserToken extends Equatable { /// Indicates whether this token uses JWT authentication or anonymous access. final AuthType authType; + /// The moment this token stops being valid, from its 'exp' claim, in UTC. + /// + /// `null` when the token names no expiry, including an anonymous token carrying no raw value. + /// Such a token is never considered expired. + final DateTime? expiresAt; + + /// Whether this token has expired, or expires within [leeway]. + /// + /// A [leeway] covers the gap between the check and the token being used, so one that would run + /// out mid-request counts as expired before it is sent. It also absorbs a client clock running + /// behind. + bool isExpired({Duration leeway = Duration.zero}) { + final expiresAt = this.expiresAt; + if (expiresAt == null) return false; + + // Not `isAfter`: a token expiring at exactly this moment has expired. + return !clock.now().add(leeway).isBefore(expiresAt); + } + @override List get props => [rawValue, userId, authType]; } @@ -145,3 +169,13 @@ enum AuthType { /// method being used for API requests. final String headerValue; } + +// The moment the token stops being valid, or null when it names none. +// +// A claim that is present but not a number is read as no expiry rather than thrown from a +// constructor: the expiry is not what identifies a token, and a token the client cannot read the +// expiry of is still one the server can accept or refuse on its own terms. +DateTime? _expiresAtOf(JsonWebTokenClaims claims) { + if (claims['exp'] is! num) return null; + return claims.expiry?.toUtc(); +} diff --git a/packages/stream_core/pubspec.yaml b/packages/stream_core/pubspec.yaml index 7fa4b4fa..f56f6ab2 100644 --- a/packages/stream_core/pubspec.yaml +++ b/packages/stream_core/pubspec.yaml @@ -19,6 +19,7 @@ environment: sdk: ^3.12.0 dependencies: + clock: ^1.1.2 collection: ^1.19.0 cross_file: ^0.3.4+2 dio: ^5.8.0+1 diff --git a/packages/stream_core/test/helpers/user_token.dart b/packages/stream_core/test/helpers/user_token.dart index 457776b4..4707a5b3 100644 --- a/packages/stream_core/test/helpers/user_token.dart +++ b/packages/stream_core/test/helpers/user_token.dart @@ -4,22 +4,27 @@ import 'package:stream_core/stream_core.dart'; /// Builds an unsigned JWT carrying [userId] as its 'user_id' claim. /// -/// Sufficient for [UserToken]'s unverified parsing — nothing in these tests -/// checks a signature. Pass [nonce] to tell two tokens for the same user apart. -String generateTestJwt(String userId, {String? nonce}) { +/// Unsigned is enough for [UserToken], which parses without verifying. Pass [nonce] to tell two +/// tokens for the same user apart, and [expiresAt] to give the token an 'exp' claim. +String generateTestJwt(String userId, {String? nonce, DateTime? expiresAt}) { String b64UrlNoPad(Object jsonObj) { final bytes = utf8.encode(jsonEncode(jsonObj)); return base64Url.encode(bytes).replaceAll('=', ''); } final header = {'alg': 'none', 'typ': 'JWT'}; - final payload = {'user_id': userId, 'nonce': ?nonce}; + final payload = { + 'user_id': userId, + 'nonce': ?nonce, + // 'exp' is in whole seconds since the epoch. + 'exp': ?expiresAt?.millisecondsSinceEpoch.let((it) => it ~/ 1000), + }; - // Trailing dot = empty signature, which is what alg=none means. + // The trailing dot is the empty signature that `alg: none` requires. return '${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'; } /// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. -UserToken generateTestUserToken(String userId, {String? nonce}) { - return UserToken(generateTestJwt(userId, nonce: nonce)); +UserToken generateTestUserToken(String userId, {String? nonce, DateTime? expiresAt}) { + return UserToken(generateTestJwt(userId, nonce: nonce, expiresAt: expiresAt)); } diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 24ef2ca1..0cb804b7 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -6,8 +6,7 @@ import 'package:test/test.dart'; import '../helpers/user_token.dart'; -/// A token provider that defines value equality, as an implementation is free -/// to do — here on nothing but its own type. +/// A token provider whose instances all compare equal, as an implementation is free to define. @immutable class _AlwaysEqualProvider implements TokenProvider { const _AlwaysEqualProvider(this._token); @@ -45,10 +44,7 @@ void main() { group('getToken', () { test('loads from the provider and caches the result', () async { final provider = _CountingProvider((_) async => generateTestUserToken('user-1')); - final manager = TokenManager( - userId: 'user-1', - tokenProvider: provider, - ); + final manager = TokenManager(userId: 'user-1', tokenProvider: provider); final first = await manager.getToken(); final second = await manager.getToken(); @@ -65,10 +61,7 @@ void main() { requestedUserId = userId; return generateTestUserToken(userId); }); - final manager = TokenManager( - userId: 'user-1', - tokenProvider: provider, - ); + final manager = TokenManager(userId: 'user-1', tokenProvider: provider); await manager.getToken(); @@ -78,10 +71,7 @@ void main() { test('coalesces concurrent calls into a single load', () async { final completer = Completer(); final provider = _CountingProvider((_) => completer.future); - final manager = TokenManager( - userId: 'user-1', - tokenProvider: provider, - ); + final manager = TokenManager(userId: 'user-1', tokenProvider: provider); final futures = [manager.getToken(), manager.getToken()]; completer.complete(generateTestUserToken('user-1')); @@ -98,10 +88,7 @@ void main() { if (attempts == 1) throw StateError('load failed'); return generateTestUserToken('user-1'); }); - final manager = TokenManager( - userId: 'user-1', - tokenProvider: provider, - ); + final manager = TokenManager(userId: 'user-1', tokenProvider: provider); await expectLater(manager.getToken(), throwsStateError); expect(manager.peekToken(), isNull); @@ -115,13 +102,8 @@ void main() { group('expireToken', () { test('clears the cache and forces a reload', () async { var version = 0; - final provider = _CountingProvider( - (userId) async => generateTestUserToken(userId, nonce: 'v${++version}'), - ); - final manager = TokenManager( - userId: 'user-1', - tokenProvider: provider, - ); + final provider = _CountingProvider((userId) async => generateTestUserToken(userId, nonce: 'v${++version}')); + final manager = TokenManager(userId: 'user-1', tokenProvider: provider); expect(await manager.getToken(), generateTestUserToken('user-1', nonce: 'v1')); @@ -132,24 +114,18 @@ void main() { expect(provider.loadCount, 2); }); - test( - 'discards a load in flight', - () async { - final slowLoad = Completer(); - final manager = TokenManager( - userId: 'user-1', - tokenProvider: _CountingProvider((_) => slowLoad.future), - ); + test('discards a load in flight', () async { + final slowLoad = Completer(); + final manager = TokenManager(userId: 'user-1', tokenProvider: _CountingProvider((_) => slowLoad.future)); - final pending = manager.getToken(); + final pending = manager.getToken(); - manager.expireToken(); - slowLoad.complete(generateTestUserToken('user-1')); - await pending; + manager.expireToken(); + slowLoad.complete(generateTestUserToken('user-1')); + await pending; - expect(manager.peekToken(), isNull); - }, - ); + expect(manager.peekToken(), isNull); + }); }); group('setTokenProvider', () { @@ -161,10 +137,7 @@ void main() { expect((await manager.getToken()).userId, 'user-1'); - manager.setTokenProvider( - 'user-2', - tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), - ); + manager.setTokenProvider('user-2', tokenProvider: TokenProvider.static(generateTestUserToken('user-2'))); expect(manager.userId, 'user-2'); expect((await manager.getToken()).userId, 'user-2'); @@ -179,84 +152,60 @@ void main() { await manager.getToken(); expect(manager.peekToken(), generateTestUserToken('user-1')); - manager.setTokenProvider( - 'user-2', - tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), - ); + manager.setTokenProvider('user-2', tokenProvider: TokenProvider.static(generateTestUserToken('user-2'))); expect(manager.peekToken(), isNull); }); - test( - 'adopts a user id and token that were not known up front', - () async { - const serverId = 'guest-abc-guest-123'; + test('adopts a user id and token that were not known up front', () async { + const serverId = 'guest-abc-guest-123'; - final manager = TokenManager( - userId: User.anonymousUserId, - tokenProvider: TokenProvider.static(UserToken.anonymous()), - ); + final manager = TokenManager( + userId: User.anonymousUserId, + tokenProvider: TokenProvider.static(UserToken.anonymous()), + ); - final anonymous = await manager.getToken(); - expect(anonymous.authType, AuthType.anonymous); - expect(anonymous.rawValue, isEmpty); + final anonymous = await manager.getToken(); + expect(anonymous.authType, AuthType.anonymous); + expect(anonymous.rawValue, isEmpty); - manager.setTokenProvider( - serverId, - tokenProvider: TokenProvider.static(generateTestUserToken(serverId)), - ); + manager.setTokenProvider(serverId, tokenProvider: TokenProvider.static(generateTestUserToken(serverId))); - final guest = await manager.getToken(); - expect(manager.userId, serverId); - expect(guest.authType, AuthType.jwt); - expect(guest.userId, serverId); - }, - ); + final guest = await manager.getToken(); + expect(manager.userId, serverId); + expect(guest.authType, AuthType.jwt); + expect(guest.userId, serverId); + }); test('a load in flight does not cache its token over the new user', () async { final slowLoad = Completer(); - final manager = TokenManager( - userId: 'user-1', - tokenProvider: _CountingProvider((_) => slowLoad.future), - ); + final manager = TokenManager(userId: 'user-1', tokenProvider: _CountingProvider((_) => slowLoad.future)); final pending = manager.getToken(); - manager.setTokenProvider( - 'user-2', - tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), - ); + manager.setTokenProvider('user-2', tokenProvider: TokenProvider.static(generateTestUserToken('user-2'))); slowLoad.complete(generateTestUserToken('user-1')); await pending; - // user-1's token must not be waiting in the cache for user-2 to send. + // The token for user-1 must not be waiting in the cache for user-2 to send. expect(manager.peekToken(), isNull); expect((await manager.getToken()).userId, 'user-2'); }); - test( - 'discards a load in flight when only the provider changes', - () async { - final slowLoad = Completer(); - final manager = TokenManager( - userId: 'user-1', - tokenProvider: _CountingProvider((_) => slowLoad.future), - ); - - final pending = manager.getToken(); - - // Same user, fresh provider — the user id guard alone would let the - // replaced provider's token through. - manager.setTokenProvider( - 'user-1', - tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), - ); - slowLoad.complete(generateTestUserToken('user-1')); - await pending; - - expect(manager.peekToken(), isNull); - }, - ); + test('discards a load in flight when only the provider changes', () async { + final slowLoad = Completer(); + final manager = TokenManager(userId: 'user-1', tokenProvider: _CountingProvider((_) => slowLoad.future)); + + final pending = manager.getToken(); + + // Same user, different provider: the user id guard alone would let the replaced provider's + // token through. + manager.setTokenProvider('user-1', tokenProvider: TokenProvider.static(generateTestUserToken('user-1'))); + slowLoad.complete(generateTestUserToken('user-1')); + await pending; + + expect(manager.peekToken(), isNull); + }); test('usesStaticProvider reflects the new provider', () { final manager = TokenManager( @@ -288,10 +237,7 @@ void main() { test('loads once an identity is supplied', () async { final manager = TokenManager.unconfigured(); - manager.setTokenProvider( - 'user-1', - tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), - ); + manager.setTokenProvider('user-1', tokenProvider: TokenProvider.static(generateTestUserToken('user-1'))); expect(manager.userId, 'user-1'); expect((await manager.getToken()).userId, 'user-1'); @@ -321,27 +267,20 @@ void main() { tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), )..reset(); - manager.setTokenProvider( - 'user-2', - tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), - ); + manager.setTokenProvider('user-2', tokenProvider: TokenProvider.static(generateTestUserToken('user-2'))); expect((await manager.getToken()).userId, 'user-2'); }); test('discards a load already in flight', () async { final completer = Completer(); - final manager = TokenManager( - userId: 'user-1', - tokenProvider: _CountingProvider((_) => completer.future), - ); + final manager = TokenManager(userId: 'user-1', tokenProvider: _CountingProvider((_) => completer.future)); final inFlight = manager.getToken(); manager.reset(); completer.complete(generateTestUserToken('user-1')); - // A reset is a logout: the token is neither cached nor handed to the - // caller, so no request goes out as a user the manager no longer has. + // A reset is a logout, so the token is neither cached nor handed to the caller. await expectLater(inFlight, throwsA(isA())); expect(manager.peekToken(), isNull); }); @@ -355,8 +294,7 @@ void main() { manager.setTokenProvider('user-1', tokenProvider: provider); - // Expiring here would send a caller to the provider for an identity it - // already has, which a defensive re-set on reconnect does routinely. + // A defensive re-set on reconnect does this routinely, and must not cost a load. expect(manager.peekToken(), isNotNull); await manager.getToken(); expect(provider.loadCount, 1); @@ -374,17 +312,15 @@ void main() { tokenProvider: _AlwaysEqualProvider(generateTestUserToken('user-1', nonce: 'second')), ); - // Equality is a declaration of interchangeability, and it is the - // provider's own to make: this one says the replacement is the same, so - // the cached token stands. + // The provider declares the replacement equal, so the cached token stands. expect(manager.peekToken(), generateTestUserToken('user-1', nonce: 'first')); }); }); group('_loadAndNotify', () { test('rejects a token a custom provider issued for another user', () async { - // Neither built-in provider can do this, but `TokenProvider` is an - // interface: caching it would authenticate later requests as them. + // Neither built-in provider can do this, but `TokenProvider` is an interface, and caching + // such a token would authenticate later requests as the wrong user. final manager = TokenManager( userId: 'user-1', tokenProvider: _CountingProvider((_) async => generateTestUserToken('someone-else')), @@ -415,14 +351,8 @@ void main() { test('fires once per load with the loaded token', () async { final updates = []; var version = 0; - final provider = _CountingProvider( - (userId) async => generateTestUserToken(userId, nonce: 'v${++version}'), - ); - final manager = TokenManager( - userId: 'user-1', - tokenProvider: provider, - onTokenUpdated: updates.add, - ); + final provider = _CountingProvider((userId) async => generateTestUserToken(userId, nonce: 'v${++version}')); + final manager = TokenManager(userId: 'user-1', tokenProvider: provider, onTokenUpdated: updates.add); await manager.getToken(); await manager.getToken(); // served from cache @@ -430,10 +360,7 @@ void main() { manager.expireToken(); await manager.getToken(); - expect(updates, [ - generateTestUserToken('user-1', nonce: 'v1'), - generateTestUserToken('user-1', nonce: 'v2'), - ]); + expect(updates, [generateTestUserToken('user-1', nonce: 'v1'), generateTestUserToken('user-1', nonce: 'v2')]); }); test('is invoked before the token is returned', () async { diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index 78b2e4ae..7dfeebcf 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -22,30 +22,18 @@ void main() { expect(token.authType, AuthType.anonymous); }); - test( - 'rejects a raw value claiming a real user', - () { - // An anonymous token must not be able to stand in for someone else. - expect( - () => UserToken.anonymous(rawValue: generateTestJwt('alice')), - throwsArgumentError, - ); - }, - ); + test('rejects a raw value claiming a real user', () { + // An anonymous token must not stand in for a named user. + expect(() => UserToken.anonymous(rawValue: generateTestJwt('alice')), throwsArgumentError); + }); test('rejects a raw value that is not a JWT at all', () { - expect( - () => UserToken.anonymous(rawValue: 'not-a-jwt'), - throwsArgumentError, - ); + expect(() => UserToken.anonymous(rawValue: 'not-a-jwt'), throwsArgumentError); }); test('rejects a raw value whose segments are not valid base64', () { // Shaped like a JWT, so parsing gets further before failing. - expect( - () => UserToken.anonymous(rawValue: 'a.b.c'), - throwsFormatException, - ); + expect(() => UserToken.anonymous(rawValue: 'a.b.c'), throwsFormatException); }); }); @@ -67,9 +55,7 @@ void main() { group('DynamicTokenProvider', () { test('returns JWT tokens from the loader', () async { - final provider = TokenProvider.dynamic( - (userId) async => generateTestUserToken(userId), - ); + final provider = TokenProvider.dynamic((userId) async => generateTestUserToken(userId)); final token = await provider.loadToken('user-1'); @@ -78,34 +64,21 @@ void main() { }); test('throws when the loader returns a non-JWT token', () { - final provider = TokenProvider.dynamic( - (_) async => UserToken.anonymous(), - ); + final provider = TokenProvider.dynamic((_) async => UserToken.anonymous()); - // The type is checked first, so this reports the wrong type rather than - // the id an anonymous token happens to carry. + // The type is checked first, so this reports the wrong type rather than the id an anonymous + // token happens to carry. expect( () => provider.loadToken('user-1'), - throwsA( - isA().having( - (it) => it.message, - 'message', - contains('Token type mismatch'), - ), - ), + throwsA(isA().having((it) => it.message, 'message', contains('Token type mismatch'))), ); }); - test( - 'throws when the loader returns a token for a different user', - () { - // Caching it would authenticate every later request as that user. - final provider = TokenProvider.dynamic( - (_) async => generateTestUserToken('someone-else'), - ); - - expect(() => provider.loadToken('user-1'), throwsArgumentError); - }, - ); + test('throws when the loader returns a token for a different user', () { + // Caching it would authenticate every later request as that user. + final provider = TokenProvider.dynamic((_) async => generateTestUserToken('someone-else')); + + expect(() => provider.loadToken('user-1'), throwsArgumentError); + }); }); } diff --git a/packages/stream_core/test/user/user_test.dart b/packages/stream_core/test/user/user_test.dart index 1e0e87c2..e3a85ce1 100644 --- a/packages/stream_core/test/user/user_test.dart +++ b/packages/stream_core/test/user/user_test.dart @@ -14,10 +14,7 @@ void main() { group('User', () { test('rejects an anonymous user built with any other id', () { // Not `const`: a const context evaluates the assert at compile time. - expect( - () => User(id: 'someone-else', type: UserType.anonymous), - throwsA(isA()), - ); + expect(() => User(id: 'someone-else', type: UserType.anonymous), throwsA(isA())); }); test('allows the anonymous id for a user of another type', () { diff --git a/packages/stream_core/test/user/user_token_test.dart b/packages/stream_core/test/user/user_token_test.dart new file mode 100644 index 00000000..25aeef32 --- /dev/null +++ b/packages/stream_core/test/user/user_token_test.dart @@ -0,0 +1,114 @@ +import 'package:clock/clock.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +import '../helpers/user_token.dart'; + +void main() { + group('UserToken.expiresAt', () { + test('is read from the exp claim', () { + // 'exp' has no room for milliseconds, so a finer-grained moment comes back truncated. + final expiry = DateTime.utc(2030, 1, 1, 12); + + final token = generateTestUserToken('user-id', expiresAt: expiry); + + expect(token.expiresAt, expiry); + }); + + test('is null for a token that names no expiry', () { + final token = generateTestUserToken('user-id'); + + // With no expiry there is nothing to compare against, so the token never expires. + expect(token.expiresAt, isNull); + expect(token.isExpired(), isFalse); + }); + + test('is null for an anonymous token carrying no raw value', () { + final token = UserToken.anonymous(); + + expect(token.expiresAt, isNull); + expect(token.isExpired(), isFalse); + }); + + test('is read from an anonymous token that carries one', () { + final expiry = DateTime.utc(2030, 1, 1, 12); + final rawValue = generateTestJwt(User.anonymousUserId, expiresAt: expiry); + + final token = UserToken.anonymous(rawValue: rawValue); + + expect(token.expiresAt, expiry); + }); + }); + + group('UserToken.isExpired', () { + test('is false while the token still has life left', () { + final token = generateTestUserToken('user-id', expiresAt: DateTime.timestamp().add(const Duration(hours: 1))); + + expect(token.isExpired(), isFalse); + }); + + test('is true once the expiry has passed', () { + final token = generateTestUserToken( + 'user-id', + expiresAt: DateTime.timestamp().subtract(const Duration(seconds: 1)), + ); + + expect(token.isExpired(), isTrue); + }); + + test('is true for a token expiring within the leeway', () { + final token = generateTestUserToken('user-id', expiresAt: DateTime.timestamp().add(const Duration(seconds: 10))); + + // Still valid, but not for longer than the leeway. + expect(token.isExpired(), isFalse); + expect(token.isExpired(leeway: const Duration(seconds: 30)), isTrue); + }); + + test('is false for a token outliving the leeway', () { + final token = generateTestUserToken('user-id', expiresAt: DateTime.timestamp().add(const Duration(minutes: 5))); + + expect(token.isExpired(leeway: const Duration(seconds: 30)), isFalse); + }); + + test('is true for a token expiring at exactly this moment', () { + final expiry = DateTime.utc(2030, 1, 1, 12); + final token = generateTestUserToken('user-id', expiresAt: expiry); + + // A token stops being valid at its expiry, not after it. + withClock(Clock.fixed(expiry), () { + expect(token.isExpired(), isTrue); + }); + }); + + test('is answered against the ambient clock, not the wall clock', () { + final token = generateTestUserToken('user-id', expiresAt: DateTime.utc(2030, 1, 1, 12)); + + withClock(Clock.fixed(DateTime.utc(2030, 1, 1, 11, 59)), () { + expect(token.isExpired(), isFalse); + }); + + withClock(Clock.fixed(DateTime.utc(2030, 1, 1, 12, 1)), () { + expect(token.isExpired(), isTrue); + }); + }); + + test('expires as time passes under fakeAsync', () { + fakeAsync((async) { + final token = generateTestUserToken('user-id', expiresAt: clock.now().add(const Duration(hours: 1))); + expect(token.isExpired(), isFalse); + + // The same elapse that drives backoff and health checks moves token expiry too. + async.elapse(const Duration(hours: 2)); + + expect(token.isExpired(), isTrue); + }); + }); + + test('leaves a token with no expiry alone whatever the leeway', () { + final token = generateTestUserToken('user-id'); + + expect(token.isExpired(leeway: const Duration(days: 365)), isFalse); + }); + }); +} From ae08d2a74badedf4273b0ec6b7e8a69934e4b0cb Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 21 Aug 2026 13:28:06 +0200 Subject: [PATCH 49/97] refactor(llc): read the token's expiry claim directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the guard that read a non-numeric `exp` as no expiry at all. Such a token is not valid — RFC 7519 requires `exp` to be a numeric date, and the backend's own verification refuses any other type — so no conforming issuer can produce one. Reporting it as a token that never expires was the most optimistic possible reading of an input that has no valid source; whoever built it has a bug, which is what the cast failure says. An absent `exp` is still legitimate, and still means the token never expires. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/lib/src/user/user_token.dart | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/stream_core/lib/src/user/user_token.dart b/packages/stream_core/lib/src/user/user_token.dart index a6b7facf..a0617167 100644 --- a/packages/stream_core/lib/src/user/user_token.dart +++ b/packages/stream_core/lib/src/user/user_token.dart @@ -56,7 +56,7 @@ class UserToken extends Equatable { rawValue: rawValue, userId: userId, authType: AuthType.jwt, - expiresAt: _expiresAtOf(claims), + expiresAt: claims.expiry?.toUtc(), ); } @@ -77,7 +77,7 @@ class UserToken extends Equatable { DateTime? expiresAt; if (rawValue.isNotEmpty) { final claims = JsonWebToken.unverified(rawValue).claims; - expiresAt = _expiresAtOf(claims); + expiresAt = claims.expiry?.toUtc(); final userId = claims.getTyped('user_id'); if (userId != User.anonymousUserId) { throw ArgumentError.value( @@ -169,13 +169,3 @@ enum AuthType { /// method being used for API requests. final String headerValue; } - -// The moment the token stops being valid, or null when it names none. -// -// A claim that is present but not a number is read as no expiry rather than thrown from a -// constructor: the expiry is not what identifies a token, and a token the client cannot read the -// expiry of is still one the server can accept or refuse on its own terms. -DateTime? _expiresAtOf(JsonWebTokenClaims claims) { - if (claims['exp'] is! num) return null; - return claims.expiry?.toUtc(); -} From d568f05bb95c770abeea6b41693963f649c55ea0 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 11:55:01 +0200 Subject: [PATCH 50/97] feat(llc)!: keep an authenticated attempt to the attempt it belongs to A connection attempt now authenticates through a handler of its own, which remembers what the server refused so the next attempt can present something else, and scopes both the sender and the failure path to the attempt that started them. An authenticator that outlives its attempt could otherwise send credentials over the connection that replaced it, or close a working connection as `AuthenticationFailed`, which is never reconnected. A refusal is forgotten once the caller disconnects: what they connect with next is theirs to decide. Also stops the client printing every state change, pong and ping to the console, and cancels the socket subscription through its field so it is visibly cancelled. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/lib/src/ws.dart | 1 + .../engine/stream_web_socket_engine.dart | 18 +- .../ws/client/engine/web_socket_engine.dart | 4 + .../connection_recovery_handler.dart | 38 ++-- .../ws/client/stream_web_socket_client.dart | 170 +++++++----------- .../web_socket_authentication_handler.dart | 118 ++++++++++++ .../client/web_socket_connection_state.dart | 29 ++- 7 files changed, 229 insertions(+), 149 deletions(-) create mode 100644 packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart diff --git a/packages/stream_core/lib/src/ws.dart b/packages/stream_core/lib/src/ws.dart index bae54ddf..ceecd379 100644 --- a/packages/stream_core/lib/src/ws.dart +++ b/packages/stream_core/lib/src/ws.dart @@ -5,6 +5,7 @@ export 'ws/client/reconnect/automatic_reconnection_policy.dart'; export 'ws/client/reconnect/connection_recovery_handler.dart'; export 'ws/client/reconnect/retry_strategy.dart'; export 'ws/client/stream_web_socket_client.dart'; +export 'ws/client/web_socket_authentication_handler.dart' show WebSocketAuthenticator, WsRequestSender; export 'ws/client/web_socket_connection_state.dart'; export 'ws/client/web_socket_health_monitor.dart'; export 'ws/events/ws_event.dart'; diff --git a/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart index f702ff6f..0c6fe146 100644 --- a/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart @@ -48,13 +48,15 @@ class StreamWebSocketEngine implements WebSocketEngine { WebSocketEngineListener? _listener; WebSocketChannel? _ws; + // ignore: cancel_subscriptions StreamSubscription? _wsSubscription; @override Future> open(WebSocketOptions options) { - return runSafely(() async { - // Close any existing connection first. - if (_ws != null) await close(); + return runSafely(() { + if (_ws != null) { + throw StateError('WebSocket is already open. Call close() first.'); + } // Create a new WebSocket connection. _ws = _wsProvider.call(options); @@ -98,15 +100,15 @@ class StreamWebSocketEngine implements WebSocketEngine { String? closeReason = 'Closed by client', ]) { return runSafely(() async { - if (_ws == null) return; + final ws = _ws; + final subscription = _wsSubscription; - await _ws?.sink.close(closeCode, closeReason); _ws = null; - - await _wsSubscription?.cancel(); _wsSubscription = null; - // Notify the listener about the closure. + await subscription?.cancel(); + await ws?.sink.close(closeCode, closeReason); + _listener?.onClose(closeCode, closeReason); }); } diff --git a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart index d44a0c00..6a46cb81 100644 --- a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart @@ -26,6 +26,10 @@ abstract interface class WebSocketEngine { /// /// Closes the active WebSocket connection with the specified [closeCode] and [closeReason]. /// + /// The listener is told the connection closed before this completes — however the close went, and + /// even when there was no connection to close. Callers may rely on that, rather than reporting the + /// closure themselves. + /// /// Returns a [Result] indicating success or failure of the close operation. Future> close([int? closeCode, String? closeReason]); diff --git a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart index 48df9c97..265b3829 100644 --- a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart +++ b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart @@ -18,9 +18,9 @@ import 'retry_strategy.dart'; /// when reconnection should occur, implementing exponential backoff with jitter for optimal /// retry behavior. /// -/// It recovers connections that existed: until [StreamWebSocketClient.connect] has established -/// one, connecting belongs to whoever called it and was handed the failure. A first attempt that -/// fails is therefore not retried here, including when the network returns. +/// Only connections that were established are recovered. A first attempt that fails is left to +/// whoever called [StreamWebSocketClient.connect] and was handed the failure, so it is not retried +/// here, not even when the network returns. /// /// ## Built-in Policies /// @@ -83,10 +83,9 @@ class ConnectionRecoveryHandler extends Disposable { late final _subscriptions = CompositeSubscription(); - // Whether a connection has been established since the caller last asked for - // one: it is what separates a drop this handler recovers from an attempt whose - // outcome the caller is waiting on. - var _hasConnected = false; + // Whether a connection has been established since the caller last asked for one. Separates a drop + // this handler recovers from an attempt whose outcome the caller is still waiting on. + var _hasEstablishedConnection = false; /// Attempts reconnection if policies allow it. /// @@ -127,12 +126,7 @@ class ConnectionRecoveryHandler extends Disposable { } bool _canBeReconnected() { - // Until a connection has existed, connecting belongs to whoever called - // `connect` and was handed the failure. Retrying here as well would work - // behind a caller already told the attempt failed — and would race the - // retry that caller makes in response. - if (!_hasConnected) return false; - + if (!_hasEstablishedConnection) return false; return _policies.every((it) => it.canBeReconnected()); } @@ -171,19 +165,21 @@ class ConnectionRecoveryHandler extends Disposable { }; } - // A connection exists, so keeping it is this handler's job from here, and the - // failures the backoff had accumulated are behind us. + // Keeping the connection is this handler's job from here, + // and the accumulated backoff failures no longer apply. void _onConnectionEstablished() { - _hasConnected = true; + _hasEstablishedConnection = true; // Remember that a connection was established. return _reconnectStrategy.resetConsecutiveFailures(); } - // A disconnect the caller asked for hands connecting back to them, so the next - // `connect` is a fresh attempt they await rather than a drop to recover. A - // system-initiated one is the opposite: backgrounding and network loss are what - // this handler exists to come back from. + // A disconnect the caller asked for hands connecting back to them, + // so the next `connect` is a fresh attempt they await rather than a drop to recover. void _onConnectionLost(DisconnectionSource source) { - if (source is UserInitiated) _hasConnected = false; + if (source is UserInitiated) { + _hasEstablishedConnection = false; + return _cancelReconnection(); + } + return _scheduleReconnectionIfNeeded(); } diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index 4c1d5ff4..e41a1672 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -5,6 +5,7 @@ import '../events/ws_event.dart'; import '../events/ws_request.dart'; import 'engine/stream_web_socket_engine.dart'; import 'engine/web_socket_engine.dart'; +import 'web_socket_authentication_handler.dart'; import 'web_socket_connection_state.dart'; import 'web_socket_health_monitor.dart'; @@ -20,30 +21,11 @@ WsRequest _defaultPingRequestBuilder([HealthCheckInfo? info]) { /// A function that builds the options for a connection attempt. /// -/// Called once per attempt, so the options may carry values that change over the -/// client's lifetime. +/// Called once per attempt, so the options can change between attempts. /// /// Returns the [WebSocketOptions] to open the connection with. typedef WebSocketOptionsBuilder = WebSocketOptions Function(); -/// A function that sends a request over a connection that is not usable yet. -/// -/// Handed to a [WebSocketAuthenticator], which runs while the connection is -/// still being established and so cannot be given the client itself. -/// -/// Returns a [Result] indicating whether the request was sent. -typedef WsSender = Result Function(WsRequest request); - -/// A function that authenticates a newly opened connection. -/// -/// Called once the socket is open, while the state is [Authenticating]. Sending -/// the credentials the server expects is this function's job. -/// -/// Returns a [Result] that fails when the credentials could not be sent, in -/// which case the connection is closed with [AuthenticationFailed] rather than -/// left waiting for a reply that never comes. -typedef WebSocketAuthenticator = Future> Function(WsSender send); - /// A WebSocket client with connection management and event handling. /// /// The primary interface for WebSocket connections in the Stream Core SDK that provides @@ -59,7 +41,7 @@ typedef WebSocketAuthenticator = Future> Function(WsSender send); /// final client = StreamWebSocketClient( /// optionsBuilder: () => WebSocketOptions(url: 'wss://api.example.com'), /// messageCodec: JsonMessageCodec(), -/// onAuthenticate: (send) async => send(AuthRequest(token: authToken)), +/// onAuthenticate: (send, _) async => send(AuthRequest(token: authToken)).getOrThrow(), /// ); /// /// await client.connect(); @@ -68,8 +50,8 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, /// Creates a new instance of [StreamWebSocketClient]. StreamWebSocketClient({ required this.optionsBuilder, - this.onAuthenticate, WebSocketProvider? wsProvider, + WebSocketAuthenticator? onAuthenticate, this.pingRequestBuilder = _defaultPingRequestBuilder, required WebSocketMessageCodec messageCodec, Iterable>? eventResolvers, @@ -80,6 +62,14 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, wsProvider: wsProvider, messageCodec: messageCodec, ); + + _authenticationHandler = WebSocketAuthenticationHandler( + send: send, + authenticator: onAuthenticate, + onFailure: (error) => disconnect( + source: .authenticationFailed(error: error), + ), + ); } /// The function used to build the connection options for each attempt. @@ -88,13 +78,12 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, /// The function used to build ping requests for health checks. final PingRequestBuilder pingRequestBuilder; - /// The function used to authenticate a newly opened connection. - final WebSocketAuthenticator? onAuthenticate; - late final StreamWebSocketEngine _engine; + late final WebSocketAuthenticationHandler _authenticationHandler; late final _healthMonitor = WebSocketHealthMonitor(listener: this); - // Bounds a connection attempt that never reaches 'connected'. + // Bounds an attempt while it is 'connecting' or 'authenticating'; + // the health monitor takes over once it is established. Timer? _connectTimeoutTimer; void _startConnectTimeout(Duration timeout) { @@ -129,9 +118,9 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Return early if the state hasn't changed. if (_connectionStateEmitter.value == connectionState) return; - print('WebSocketClient: Connection state changed to $connectionState'); _connectionStateEmitter.value = connectionState; _healthMonitor.onConnectionStateChanged(connectionState); + _authenticationHandler.onConnectionStateChanged(connectionState); } /// Sends a message through the WebSocket connection. @@ -145,14 +134,17 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, /// /// The connection state can be monitored through [connectionState] for real-time updates. /// If the connection is already established or in progress, this method returns immediately, - /// as it does while a previous connection is still closing, and once [dispose] has been called. + /// as it does while a previous connection is still closing. /// - /// Returns a [Future] that completes once the socket is open — before the - /// connection is authenticated, and well before it is [Connected]. Watch - /// [connectionState] to know when it is usable. + /// Returns a [Future] that completes once the socket is open, before the connection is + /// authenticated and well before it is [Connected]. Watch [connectionState] to know when it + /// is usable. + /// + /// Throws a [StateError] once [dispose] has been called. Future connect() async { - assert(!isDisposed, 'Cannot connect a disposed StreamWebSocketClient'); - if (isDisposed) return; + // A disposed client cannot report a state change or tear an idle connection down, so a socket + // opened here would be unobservable. + if (isDisposed) throw StateError('Cannot connect a disposed StreamWebSocketClient'); // If the connection is already established or in the process of connecting, // do not initiate a new connection. @@ -160,9 +152,7 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, if (connectionState.value is Authenticating) return; if (connectionState.value is Connected) return; - // Nor while a previous connection is still closing: the socket it opened - // would be brought down by the old one's close event, which would also - // disarm the new attempt's timeout and leave it authenticating unwatched. + // If the previous connection is still closing, do not initiate a new connection. if (connectionState.value is Disconnecting) return; // Update the connection state to 'connecting'. @@ -171,63 +161,45 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Open the connection using the engine, with options built for this attempt. final options = optionsBuilder.call(); - // Time the whole handshake: nothing else watches 'authenticating'. + // Bound the attempt, so one that never becomes usable is not waited on indefinitely. _startConnectTimeout(options.connectTimeout); final result = await _engine.open(options); - // If some failure occurs, disconnect and rethrow the error. - return result.recover((_, _) => onClose()).getOrThrow(); + // If some failure occurs, close the socket the attempt opened. + return result.recover((_, _) => _engine.close()).getOrThrow(); } /// Closes the WebSocket connection. /// /// When [closeCode] is provided, uses the specified close code for the disconnection. /// The [source] indicates the reason for disconnection and affects reconnection behavior. + /// A [UserInitiated] disconnection takes effect even on a connection that is already down, + /// which is what calls off a reconnection waiting to be made. /// /// Returns a [Future] that completes when the disconnection finishes. Future disconnect({ CloseCode closeCode = CloseCode.normalClosure, DisconnectionSource source = const UserInitiated(), }) async { - // A connection already going down keeps the source it started going down - // with: whoever asked first described why. Without this the connect - // timeout could replace a `ServerInitiated` closure, which is - // reconnectable, with one that is not. - if (connectionState.value case Disconnected() || Disconnecting()) return; - - // Stop the timeout from firing later and replacing this source. _cancelConnectTimeout(); + // If no connection was ever opened, there is nothing to close. + if (connectionState.value case Initialized()) return; + + // A disconnection the client decided on does not relabel one already recorded or under way, + // while one the caller asked for does, so nothing reconnects after it. + final forceDisconnect = source is UserInitiated; + if (connectionState.value case Disconnecting() when !forceDisconnect) return; + if (connectionState.value case Disconnected() when !forceDisconnect) return; + // Update the connection state to 'disconnecting'. _connectionState = WebSocketConnectionState.disconnecting(source: source); - // Awaited so the connection is closed, rather than merely closing, once this - // returns: a reconnect straight afterwards would otherwise race the close - // and see the connection go down again. + // Close the connection using the engine. final result = await _engine.close(closeCode, source.closeReason); - // The engine reports a failed close rather than throwing, and does not - // notify its listener on that path. The connection is unusable either way, - // so report it closed rather than leave it disconnecting for good. - if (result.isFailure) onClose(closeCode, source.closeReason); - } - - /// Releases every resource held by this client. - /// - /// Closes the connection along with [events] and [connectionState], after which - /// this client cannot be connected again. Use [disconnect] for a connection that - /// may be opened again. - /// - /// Returns a [Future] that completes once everything has been released. - @override - Future dispose() async { - await disconnect(); - _healthMonitor.stop(); - - await _events.close(); - await _connectionStateEmitter.close(); - - return super.dispose(); + // If the close fails, report the closure directly so nothing is left disconnecting. + return result.recover((_, _) => onClose(closeCode, source.closeReason)).getOrThrow(); } @override @@ -235,42 +207,19 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Update the connection state to 'authenticating'. _connectionState = const WebSocketConnectionState.authenticating(); - // The socket is open, so authenticate before the connection is usable. - unawaited(_authenticate()); - } - - Future _authenticate() async { - final authenticate = onAuthenticate; - if (authenticate == null) return; - - // Guarded rather than awaited directly: an authenticator that awaits a - // token throws rather than returning a failure, and nothing observes this - // future, so the error would escape and leave the connection - // authenticating until the timeout reported a cause it does not know. - final outcome = await runSafely(() => authenticate(send)); - final result = outcome.flatten(); - - // Close the connection rather than wait for a reply that cannot come. - if (result.exceptionOrNull() case final error?) { - final source = DisconnectionSource.authenticationFailed(error: error); - return disconnect(source: source); - } + // The connection has been established, so authenticate before it can be used. + unawaited(_authenticationHandler.authenticate()); } @override void onClose([int? closeCode, String? closeReason]) { - _cancelConnectTimeout(); - final source = switch (connectionState.value) { // If we were already disconnecting, keep the caller-provided source. Disconnecting(:final source) => source, // Any active state that wasn’t user/system initiated becomes server initiated. Connecting() || Authenticating() || Connected() => ServerInitiated( - error: WebSocketEngineException( - code: closeCode, - reason: closeReason, - ), + error: WebSocketEngineException(code: closeCode, reason: closeReason), ), // Not meaningful to transition from these; just log and bail. @@ -278,6 +227,7 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, }; if (source == null) return; + _cancelConnectTimeout(); // Update the connection state to 'disconnected' with the source. _connectionState = WebSocketConnectionState.disconnected(source: source); @@ -321,11 +271,11 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, } void _handleHealthCheckEvent(WsEvent event, HealthCheckInfo info) { - print('WebSocketClient: Health check pong received: $info'); - // Ignore a pong that arrives once the connection is on its way down. - if (connectionState.value case Disconnecting() || Disconnected()) return; + if (connectionState.value case Disconnecting()) return; + if (connectionState.value case Disconnected()) return; + // The connection is established, so the attempt is no longer being timed. _cancelConnectTimeout(); // Update the connection state with health check info. @@ -349,7 +299,6 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Send the ping request. send(pingRequest); - print('WebSocketClient: Ping request sent: $pingRequest'); } } @@ -359,4 +308,23 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, const source = DisconnectionSource.unHealthyConnection(); return unawaited(disconnect(source: source)); } + + /// Releases every resource held by this client. + /// + /// Closes the connection along with [events] and [connectionState], + /// after which this client cannot be connected again. + /// For a connection that may be opened again, consider [disconnect]. + /// + /// Returns a [Future] that completes once everything is closed. + @override + Future dispose() async { + await disconnect(); + + _healthMonitor.stop(); + + await _events.close(); + await _connectionStateEmitter.close(); + + return super.dispose(); + } } diff --git a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart new file mode 100644 index 00000000..b81cadd5 --- /dev/null +++ b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart @@ -0,0 +1,118 @@ +import '../../errors.dart' show StreamApiError; +import '../../utils.dart'; +import '../events/ws_request.dart'; +import 'web_socket_connection_state.dart'; + +/// A function that sends a request over a connection that is not usable yet. +/// +/// Fails once the connection attempt it was handed to is no longer the one in flight, so +/// credentials loaded for an abandoned attempt are not sent over the connection that replaced it. +typedef WsRequestSender = Result Function(WsRequest request); + +/// A function that authenticates a newly opened connection. +/// +/// Called while the state is [Authenticating], to send the credentials the connection requires. +/// +/// `previousError` is the error the server closed the previous attempt with, or null if there was +/// none. Only the next attempt after a refusal sees it; later ones see null. Use it to replace +/// credentials that were refused. +/// +/// Throw when the credentials did not go out, whether because sending failed or because this +/// function chose not to send them. The connection is then closed with [AuthenticationFailed], and +/// is not reconnected. +typedef WebSocketAuthenticator = Future Function(WsRequestSender send, StreamApiError? previousError); + +/// A handler that authenticates newly opened connections and remembers why the server refused the +/// last one. +/// +/// Driven by a WebSocket client, which feeds it connection state changes. +class WebSocketAuthenticationHandler { + /// Creates a [WebSocketAuthenticationHandler]. + /// + /// `authenticator` may be null, for a connection that needs nothing sent. `onFailure` receives + /// the cause when authentication fails. + WebSocketAuthenticationHandler({ + required this._authenticator, + required this._send, + required this._onFailure, + }); + + final WebSocketAuthenticator? _authenticator; + final WsRequestSender _send; + final void Function(Object error) _onFailure; + + // Identifies the attempt in flight. An authenticator awaiting credentials can outlive the attempt + // that started it, and holds a sender and a failure path that would otherwise reach whichever + // connection is current by then. + var _attempt = 0; + + /// The error the server closed the previous attempt with, if it sent one. + /// + /// Becomes null once the attempt that read it finishes, or once a connection is established. An + /// attempt abandoned before it finishes leaves it behind, for the attempt that replaces it. + StreamApiError? get previousError => _previousError; + StreamApiError? _previousError; + + /// Takes in a connection state change. + /// + /// A [Connecting] state begins an attempt, after which an authenticator still running for an + /// earlier one can neither send nor report a failure. + /// + /// [previousError] is set when the server closes the connection with an error, and cleared when a + /// connection is established or the caller disconnects. It is otherwise left alone, so a refusal + /// outlives the states an attempt passes through. + void onConnectionStateChanged(WebSocketConnectionState state) { + if (state case Connecting()) _attempt++; + + _previousError = switch (state) { + Connected() => null, + // The caller took control, so whatever they connect with next is a decision of their own and + // may have nothing to do with the credentials that were refused. + Disconnected(source: UserInitiated()) => null, + // The server closed without sending an error, so the last one still applies. + Disconnected(source: ServerInitiated(:final error)) => error?.apiError ?? _previousError, + _ => _previousError, + }; + } + + /// Authenticates a socket that has just opened. + /// + /// Does nothing when there is no authenticator. Passes [previousError] to the authenticator, and + /// spends it once this attempt finishes, so a later attempt does not see a refusal that was not + /// about it. + /// + /// An error the authenticator throws is passed to `onFailure` instead of escaping, unless the + /// attempt has since been abandoned, which leaves nothing to report it against. + Future authenticate() async { + final authenticate = _authenticator; + if (authenticate == null) return; + + final attempt = _attempt; + final previousError = _previousError; + + // Guarded because nothing awaits this: an error thrown here would go unhandled. + final result = await runSafely(() => authenticate(_senderFor(attempt), previousError)); + + // The connection this failure belongs to is already closed, and the one that replaced it did + // not fail: reported against that one it would close a usable connection as + // `AuthenticationFailed`, which is never reconnected. The refusal is left behind with it, for + // the attempt that replaced it and has yet to answer it. + if (attempt != _attempt) return; + + // Spent, unless the server has refused something newer since: either the credentials went out, + // or the authenticator saw the refusal and had nothing else to offer, and one left armed would + // be declined again without anything being sent. + if (_previousError == previousError) _previousError = null; + + if (result case Failure(:final error)) return _onFailure(error); + } + + // An authenticator holds its sender across its own awaits, so the attempt is checked when a + // request is sent rather than once before the authenticator is called. + WsRequestSender _senderFor(int attempt) => (request) { + if (attempt == _attempt) return _send(request); + + final error = StateError('Connection attempt was abandoned before its credentials were sent'); + return Result.failure(error); + }; +} diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index d18e8cf9..182a3d73 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -93,33 +93,23 @@ sealed class WebSocketConnectionState extends Equatable { /// /// ## Reconnection is enabled for: /// - Server-initiated disconnections (except authentication and client errors) + /// - An expired token, since a fresh one is loaded before the next attempt /// - System-initiated disconnections (network changes, app lifecycle, etc.) /// - Unhealthy connection disconnections (missing pong responses) + /// - A connection attempt abandoned for taking too long ([ConnectTimeout]) /// /// ## Reconnection is disabled for: /// - User-initiated disconnections (explicit disconnect calls) - /// - A socket the server closed deliberately (close code 1000) - /// - Tokens another token would not fix, and a wrong API key - /// - Client errors (4xx status codes), other than a rate limit - /// - A failure to load or send credentials - /// - /// Whether the server closed this connection because the token had expired. - /// - /// Reported so a caller that can replace the token knows to, since a - /// reconnection cannot: [isAutomaticReconnectionEnabled] refuses this, because - /// whoever retries it here would present the token that was just refused. - bool get isExpiredTokenDisconnection => switch (this) { - Disconnected(source: ServerInitiated(:final error)) => error?.apiError?.isTokenExpiredError ?? false, - _ => false, - }; - - /// Returns `true` if automatic reconnection should be attempted. + /// - A connection closed deliberately (close code 1000) + /// - Token errors a fresh token would not fix, and a wrong API key + /// - Client errors (4xx status codes), other than a rate limit or an expired token + /// - A failure to load or send credentials ([AuthenticationFailed]) bool get isAutomaticReconnectionEnabled => switch (this) { Disconnected(:final source) => switch (source) { ServerInitiated(:final error) when error?.code == CloseCode.normalClosure => false, ServerInitiated(:final error) => switch (error?.apiError) { final it? when it.isInvalidTokenError => false, - final it? when it.isClientError && !it.isRateLimitError => false, + final it? when it.isClientError && !it.isRateLimitError && !it.isTokenExpiredError => false, _ => true, // Reconnect on other server initiated disconnections }, UnHealthyConnection() => true, @@ -357,8 +347,9 @@ final class ConnectTimeout extends DisconnectionSource { /// A disconnection caused by the connection failing to authenticate. /// -/// This source indicates that the socket opened but authentication did not -/// complete, so the connection was never usable. +/// This source indicates that the socket opened but the credentials could not be loaded or sent, so +/// the connection was never usable. A server rejecting credentials it did receive is reported as an +/// error event instead. final class AuthenticationFailed extends DisconnectionSource { /// Creates an [AuthenticationFailed] disconnection source. const AuthenticationFailed({this.error}); From b30ab0f6e0e38d5203eb312d8226f63602f93105 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 11:55:11 +0200 Subject: [PATCH 51/97] fix(llc): read a Stream error from a body the server sent as text `AuthInterceptor` only recognised a decoded JSON body, so a token-expired response delivered without a JSON content type was never retried. The same body was already read that way when the error was surfaced to the caller, so a request was reported as refused for an expired token without the token ever being replaced. The parsing now lives on `DioException.apiError`, which both callers share, so the two cannot drift apart again. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/interceptors/auth_interceptor.dart | 71 ++++++++++++------- .../lib/src/api/stream_core_dio_error.dart | 19 +++-- 2 files changed, 58 insertions(+), 32 deletions(-) diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index ba19df0f..cd605905 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -4,15 +4,22 @@ import '../../errors.dart'; import '../../user.dart'; import '../stream_core_dio_error.dart'; -/// Authentication interceptor that refreshes the token if -/// an auth error is received -class AuthInterceptor extends QueuedInterceptor { - /// Initialize a new auth interceptor +/// Signs every request with the caller's token, and replaces one the server refused for having +/// expired before retrying the request once. +class AuthInterceptor extends Interceptor { + /// Creates an [AuthInterceptor] that signs requests with the tokens `tokenManager` holds, and + /// retries a refused one through `dio`. AuthInterceptor(this._dio, this._tokenManager); + // Not a `QueuedInterceptor`: that frees a queue slot only once a handler completes, so the retry + // sent from `onError` would wait behind the request still holding it and neither would finish. + // `TokenManager` serialises the token loads, which is the part that needs serialising. + + // Marks a request that has already been retried with a replaced token. + static const _retriedKey = 'stream_core.auth_token_retried'; + final Dio _dio; - /// The token manager used in the client final TokenManager _tokenManager; @override @@ -50,30 +57,42 @@ class AuthInterceptor extends QueuedInterceptor { DioException err, ErrorInterceptorHandler handler, ) async { - final data = err.response?.data; - if (data == null || data is! Map) { - return handler.next(err); - } + // Only an expired token is worth replacing. + final error = err.apiError; + if (error == null || !error.isTokenExpiredError) return handler.next(err); - final error = StreamApiError.fromJson(data); - if (error.isTokenExpiredError) { - // Don't try to refresh the token when there is no user to load one for, - // or when the provider would return the same token again. - final canRefresh = _tokenManager.userId != null && !_tokenManager.usesStaticProvider; - if (!canRefresh) return handler.next(err); - // Otherwise, mark the current token as expired. - _tokenManager.expireToken(); + final options = err.requestOptions; + + // Nothing to refresh with when there is no user to load a token for, or when the provider + // would only return the same one again. + final canRefresh = _tokenManager.userId != null && !_tokenManager.usesStaticProvider; + if (!canRefresh) return handler.next(err); - try { - final options = err.requestOptions; - // ignore: inference_failure_on_function_invocation - final response = await _dio.fetch(options); - return handler.resolve(response); - } on DioException catch (exception) { - return handler.next(exception); - } + // And only once per request: if the replacement token is refused too, the error is surfaced to + // the caller. + if (options.extra[_retriedKey] == true) return handler.next(err); + + // Expire only the token this request actually carried. Another request may have replaced it + // already, and expiring the replacement would discard a valid token. + if (options.headers['Authorization'] == _tokenManager.peekToken()?.rawValue) { + _tokenManager.expireToken(); } - return handler.next(err); + // The retry is a new request rather than the refused one modified, so the options the caller + // holds are left as they were. A multipart body is cloned because the refused attempt has + // already consumed its streams. + final data = options.data; + final retry = options.copyWith( + extra: {...options.extra, _retriedKey: true}, + data: data is FormData ? data.clone() : data, + ); + + try { + // ignore: inference_failure_on_function_invocation + final response = await _dio.fetch(retry); + return handler.resolve(response); + } on DioException catch (exception) { + return handler.next(exception); + } } } diff --git a/packages/stream_core/lib/src/api/stream_core_dio_error.dart b/packages/stream_core/lib/src/api/stream_core_dio_error.dart index 1767ea83..0be2afd8 100644 --- a/packages/stream_core/lib/src/api/stream_core_dio_error.dart +++ b/packages/stream_core/lib/src/api/stream_core_dio_error.dart @@ -21,16 +21,23 @@ class StreamDioException extends DioException { } extension StreamDioExceptionExtension on DioException { - HttpClientException toClientException() { - final apiErrorResult = runSafelySync( - () => switch (response?.data) { + /// The Stream API error the response carried, or `null` when it carried something else. + /// + /// The body arrives decoded when the response was typed as JSON, and as a string when it was + /// not, so both are read. Anything that is not a Stream error payload reads as `null` rather + /// than throwing: a proxy or gateway can answer with a JSON body of its own. + StreamApiError? get apiError { + return runSafelySync(() { + return switch (response?.data) { final Map data => StreamApiError.fromJson(data), final String data => StreamApiError.fromJson(jsonDecode(data) as Map), _ => null, - }, - ); + }; + }).getOrNull(); + } - final apiError = apiErrorResult.getOrNull(); + HttpClientException toClientException() { + final apiError = this.apiError; return HttpClientException( message: apiError?.message ?? response?.statusMessage ?? message ?? '', From b33ef0a7cea6898d2f193331a47a0fdf7395ee70 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 11:55:23 +0200 Subject: [PATCH 52/97] docs(llc): tighten the docs the connection work touched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Names the error codes each token predicate covers, rewraps to the repo's 120 columns, and drops a cross-reference to what the Swift and Android SDKs allow for the same handshake — what this one allows is the contract. `ConnectUserDetailsRequest.fromUser` also reads the excluded details through `takeIf` rather than repeating the condition per field. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/errors/stream_api_error.dart | 10 +++++----- .../src/user/connect_user_details_request.dart | 15 ++++++++------- packages/stream_core/lib/src/utils/result.dart | 12 +++++------- .../src/ws/client/engine/web_socket_options.dart | 9 +++------ 4 files changed, 21 insertions(+), 25 deletions(-) diff --git a/packages/stream_core/lib/src/errors/stream_api_error.dart b/packages/stream_core/lib/src/errors/stream_api_error.dart index f08f5052..0c8c2be0 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.dart @@ -83,14 +83,14 @@ final _clientErrorStatusCodes = _range(400, 499); /// Extension methods for [StreamApiError] to provide convenient error type checks. extension StreamApiErrorExtension on StreamApiError { - /// Whether the token has expired. + /// Whether the token has expired (error code 40). /// - /// Distinct from [isInvalidTokenError]: an expired token is replaced by asking - /// the provider for another, where an invalid one is a configuration problem - /// that a fresh token presents again. + /// Distinct from [isInvalidTokenError]: an expired token is fixed by loading another one, whereas + /// an invalid token is a configuration problem that a fresh token reproduces. bool get isTokenExpiredError => code == _expiredTokenCode; - /// Whether the token, or the key it was signed with, cannot be accepted. + /// Whether the token, or the API key it was signed with, cannot be accepted + /// (error codes 41 to 43, and 2). bool get isInvalidTokenError => _invalidTokenCodes.contains(code) || code == _accessKeyErrorCode; /// Whether this error is a client-side error (4xx status codes). diff --git a/packages/stream_core/lib/src/user/connect_user_details_request.dart b/packages/stream_core/lib/src/user/connect_user_details_request.dart index 7e27d170..e18be4bf 100644 --- a/packages/stream_core/lib/src/user/connect_user_details_request.dart +++ b/packages/stream_core/lib/src/user/connect_user_details_request.dart @@ -1,5 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; +import '../utils/standard.dart'; import 'user.dart'; part 'connect_user_details_request.g.dart'; @@ -15,21 +16,21 @@ class ConnectUserDetailsRequest { this.custom, }); - /// Creates the details a client may send when connecting as [user]. - /// - /// A user's role and teams are left out: the server assigns both and does not - /// accept them from a client. + /// Creates the details to send when connecting as [user]. /// /// Pass [includeDetails] as `false` to send the id alone. factory ConnectUserDetailsRequest.fromUser( User user, { bool includeDetails = true, }) { + // Only the id is sent when the details are not wanted. + final details = user.takeIf((_) => includeDetails); + return ConnectUserDetailsRequest( id: user.id, - name: includeDetails ? user.originalName : null, - image: includeDetails ? user.image : null, - custom: includeDetails ? user.custom : null, + name: details?.originalName, + image: details?.image, + custom: details?.custom, ); } diff --git a/packages/stream_core/lib/src/utils/result.dart b/packages/stream_core/lib/src/utils/result.dart index 0032e597..f1d000e0 100644 --- a/packages/stream_core/lib/src/utils/result.dart +++ b/packages/stream_core/lib/src/utils/result.dart @@ -108,9 +108,9 @@ extension PatternMatching on Result { /// /// This function is a shorthand for `fold(onSuccess: (it) => it, onFailure: onFailure)`. /// - /// [onFailure] returns this result's own type. To fall back to a supertype, - /// widen the result first — `Result widened = intResult` — or use [fold], - /// which takes its return type from both branches. + /// [onFailure] returns this result's own type. To fall back to a supertype, widen the result first + /// (`Result widened = intResult`), or use [fold], which takes its return type from both + /// branches. T getOrElse(T Function(Object error, StackTrace? stackTrace) onFailure) { return switch (this) { Success(:final data) => data, @@ -188,8 +188,7 @@ extension PatternMatching on Result { /// Note, that this function rethrows any error thrown by [transform] function. /// See [recoverCatching] for an alternative that encapsulates errors. /// - /// [transform] returns this result's own type, so widening is done on the way - /// in rather than on the way out: widen the result first, then recover. + /// [transform] returns this result's own type. To recover to a supertype, widen the result first. Result recover( T Function(Object error, StackTrace? stackTrace) transform, ) { @@ -207,8 +206,7 @@ extension PatternMatching on Result { /// This function catches any error thrown by [transform] function and encapsulates it as a failure. /// See [recover] for an alternative that rethrows errors. /// - /// [transform] returns this result's own type, so widening is done on the way - /// in rather than on the way out: widen the result first, then recover. + /// [transform] returns this result's own type. To recover to a supertype, widen the result first. Result recoverCatching( T Function(Object error, StackTrace? stackTrace) transform, ) { diff --git a/packages/stream_core/lib/src/ws/client/engine/web_socket_options.dart b/packages/stream_core/lib/src/ws/client/engine/web_socket_options.dart index e2bd5be9..6b087dc8 100644 --- a/packages/stream_core/lib/src/ws/client/engine/web_socket_options.dart +++ b/packages/stream_core/lib/src/ws/client/engine/web_socket_options.dart @@ -35,16 +35,13 @@ class WebSocketOptions { /// Maximum time allowed for establishing the WebSocket connection. /// - /// Covers the whole attempt, not just opening the socket: a connection that - /// opens but is never established is abandoned once this elapses. + /// Covers the whole attempt, not just opening the socket: a connection that opens but is never + /// established is abandoned once this elapses. /// /// Defaults to [defaultConnectTimeout]. final Duration connectTimeout; - /// The [connectTimeout] used when none is given. - /// - /// Matches the wait the Swift SDK allows for the same handshake; the Android - /// one is stricter at ten seconds. + /// The [connectTimeout] used when none is given, thirty seconds. static const defaultConnectTimeout = Duration(seconds: 30); /// WebSocket sub-protocols to negotiate during the handshake. From e7bd778a9cc534a2fae08beee3271cef1083124c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 11:55:32 +0200 Subject: [PATCH 53/97] test(llc): drive the websocket client through a fake server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client is now exercised the way an app uses it — `connect`, `disconnect`, and frames on the wire — rather than by calling the engine and health listener callbacks it implements. A fake server decodes what the client actually sent and decides its reply, so a handshake succeeds because the credentials were accepted and a ping is answered because a ping arrived. Requests are really serialised and events really parsed; only the socket is stood in for. Adds the coverage that was missing: the health monitor, the retry strategy, the reconnection policies with network and lifecycle providers, and the token refresh loop end to end. Callback-driven tests could not produce two overlapping attempts, which is why the race they now cover went unnoticed. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/dart_test.yaml | 3 + .../stream_core/test/helpers/fake_server.dart | 202 +++ .../stream_core/test/helpers/web_socket.dart | 138 ++ .../test/helpers/ws_client_tester.dart | 285 ++++ .../engine/stream_web_socket_engine_test.dart | 249 +++ .../client/engine/web_socket_engine_test.dart | 55 + .../automatic_reconnection_policy_test.dart | 143 ++ .../connection_recovery_handler_test.dart | 343 +++-- .../client/reconnect/retry_strategy_test.dart | 81 + .../client/stream_web_socket_client_test.dart | 1368 ++++++++++++----- ...eb_socket_authentication_handler_test.dart | 297 ++++ .../web_socket_connection_state_test.dart | 207 ++- .../web_socket_health_monitor_test.dart | 224 +++ .../test/ws/events/ws_event_test.dart | 33 + 14 files changed, 2971 insertions(+), 657 deletions(-) create mode 100644 packages/stream_core/dart_test.yaml create mode 100644 packages/stream_core/test/helpers/fake_server.dart create mode 100644 packages/stream_core/test/helpers/web_socket.dart create mode 100644 packages/stream_core/test/helpers/ws_client_tester.dart create mode 100644 packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart create mode 100644 packages/stream_core/test/ws/client/engine/web_socket_engine_test.dart create mode 100644 packages/stream_core/test/ws/client/reconnect/automatic_reconnection_policy_test.dart create mode 100644 packages/stream_core/test/ws/client/reconnect/retry_strategy_test.dart create mode 100644 packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart create mode 100644 packages/stream_core/test/ws/client/web_socket_health_monitor_test.dart create mode 100644 packages/stream_core/test/ws/events/ws_event_test.dart diff --git a/packages/stream_core/dart_test.yaml b/packages/stream_core/dart_test.yaml new file mode 100644 index 00000000..bf85d84d --- /dev/null +++ b/packages/stream_core/dart_test.yaml @@ -0,0 +1,3 @@ +tags: + # Tests that drive a fully wired `StreamWebSocketClient` against a fake server. + ws-client: diff --git a/packages/stream_core/test/helpers/fake_server.dart b/packages/stream_core/test/helpers/fake_server.dart new file mode 100644 index 00000000..5ad02349 --- /dev/null +++ b/packages/stream_core/test/helpers/fake_server.dart @@ -0,0 +1,202 @@ +import 'dart:convert'; + +import 'package:stream_core/stream_core.dart'; + +import 'web_socket.dart'; + +/// The events the fake server sends, decoded from the wire the way a real client decodes them. +/// +/// A consumer of `stream_core` brings its own event types, so these stand in for them: the shapes +/// the client itself reacts to, and nothing else. +sealed class TestEvent extends WsEvent { + const TestEvent(); + + /// Decodes a frame the server sent. + /// + /// Anything unrecognised becomes a [PlainEvent], which the client passes through to its + /// listeners untouched. + factory TestEvent.fromJson(Map json) { + return switch (json['type']) { + 'connection.ok' || 'health.check' => HealthCheck( + connectionId: json['connection_id'] as String?, + ), + 'connection.error' => ConnectionError( + StreamApiError.fromJson(json['error']! as Map), + ), + _ => PlainEvent(json['type']! as String), + }; + } +} + +/// The reply that establishes a connection, and the pong that keeps it alive. +final class HealthCheck extends TestEvent { + const HealthCheck({this.connectionId = 'connection-id'}); + + final String? connectionId; + + @override + HealthCheckInfo? get healthCheckInfo => HealthCheckInfo(connectionId: connectionId); +} + +/// The refusal a server sends before closing a connection it will not serve. +final class ConnectionError extends TestEvent { + const ConnectionError(this.apiError); + + final StreamApiError apiError; + + @override + Object? get error => apiError; +} + +/// Anything the client has no special handling for, and emits to its listeners. +final class PlainEvent extends TestEvent { + const PlainEvent(this.type); + + final String type; +} + +/// The codec a client uses on the wire, so requests are really serialised and events really parsed. +class JsonCodec implements WebSocketMessageCodec { + const JsonCodec(); + + @override + Object encode(WsRequest message) => jsonEncode(message.toJson()); + + @override + WsEvent decode(Object message) { + final json = jsonDecode(message as String) as Map; + return TestEvent.fromJson(json); + } +} + +/// Builds the error payload a server sends, in the shape the API returns it. +Map connectionErrorFrame({required int code, int statusCode = 401}) { + return { + 'type': 'connection.error', + 'connection_id': 'connection-id', + 'error': { + 'code': code, + 'message': 'error $code', + 'StatusCode': statusCode, + 'details': [], + 'duration': '0ms', + 'more_info': '', + }, + }; +} + +/// The refusal that another token repairs, which is why it is reconnected. +Map expiredTokenFrame() => connectionErrorFrame(code: 40); + +/// The refusal no other token repairs, which is why it is not. +Map invalidSignatureFrame() => connectionErrorFrame(code: 43); + +/// A server a test drives, which answers what the client actually sent. +/// +/// Every frame the client puts on the socket is decoded and handed to [onFrame], whose reply goes +/// back over the same socket. Nothing is scripted in advance: a handshake succeeds because the +/// server accepted the credentials it was given, and a ping is answered because a ping arrived. +/// +/// The default behaviour is a healthy server for [user]: it accepts a token issued to them, +/// refuses anyone else's with an invalid-signature error, and answers every ping. +class FakeServer { + FakeServer({this.user = 'luke_skywalker'}); + + /// The user whose token this server accepts. + final String user; + + /// Called with each decoded frame the client sent, in place of the default behaviour. + /// + /// Return the frames to reply with, or an empty list to say nothing. Set this to model a server + /// that refuses, goes quiet, or answers out of order. + List> Function(Map frame)? onFrame; + + /// Every frame the client has sent, decoded, in order. + final received = >[]; + + /// The socket of the attempt in flight, which is the one a reply is sent over. + FakeWebSocketChannel get socket => _socket!; + FakeWebSocketChannel? _socket; + + /// Every socket this server has handed out, in the order they were opened. + final sockets = []; + + /// Hands out a socket wired to this server, for a client's `wsProvider`. + FakeWebSocketChannel connect({ + bool handshakeFails = false, + bool handshakeHangs = false, + bool holdClose = false, + Object? closeError, + }) { + final socket = FakeWebSocketChannel( + holdClose: holdClose, + closeError: closeError, + readyError: handshakeFails ? Exception('upgrade refused') : null, + holdReady: handshakeHangs, + ); + + socket.sink.onSent = _onSent; + sockets.add(socket); + return _socket = socket; + } + + void _onSent(Object? frame) { + final json = jsonDecode(frame! as String) as Map; + received.add(json); + + final replies = onFrame?.call(json) ?? _defaultReply(json); + replies.forEach(send); + } + + List> _defaultReply(Map frame) { + // A ping is answered whatever else is going on: a live connection is one that keeps answering. + if (frame['type'] == 'health.check') return [_connectionOk()]; + + // Anything carrying a token is a client presenting credentials. + if (frame['token'] case final String token) { + if (_userOf(token) != user) return [invalidSignatureFrame()]; + return [_connectionOk()]; + } + + return const []; + } + + Map _connectionOk() { + return {'type': 'connection.ok', 'connection_id': 'connection-id'}; + } + + /// Sends [frame] to the client, as a server pushing an event does. + void send(Map frame) => _socket?.emit(jsonEncode(frame)); + + /// Hangs up without saying why, as a server dropping a connection does. + void hangUp() => _socket?.endStream(); + + /// Breaks the connection with a socket error, as a network failing mid-stream does. + /// + /// Distinct from [send]ing a `connection.error`: that is the server explaining itself over a + /// working socket, this is the socket itself giving out. + void fail(Object error) => _socket?.emitError(error); + + /// Closes every socket this server handed out. + void dispose() { + for (final socket in sockets) { + socket.endStream(); + } + } +} + +// Reads the user a token was issued to, or null for anything that is not a readable JWT — which a +// real server treats the same way it treats a token naming the wrong user. +String? _userOf(String token) { + final parts = token.split('.'); + if (parts.length < 2) return null; + + try { + final payload = parts[1]; + final padded = payload.padRight(payload.length + (4 - payload.length % 4) % 4, '='); + final claims = jsonDecode(utf8.decode(base64Url.decode(padded))) as Map; + return claims['user_id'] as String?; + } on Object { + return null; + } +} diff --git a/packages/stream_core/test/helpers/web_socket.dart b/packages/stream_core/test/helpers/web_socket.dart new file mode 100644 index 00000000..80626f0d --- /dev/null +++ b/packages/stream_core/test/helpers/web_socket.dart @@ -0,0 +1,138 @@ +import 'dart:async'; + +// ignore: depend_on_referenced_packages +import 'package:stream_channel/stream_channel.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +/// A socket a test drives, recording what was sent to the peer. +/// +/// Closing it ends [FakeWebSocketChannel.stream], as a real socket does. Pass `holdClose` for a +/// close that only finishes once [completeClose] is called, and `closeError` for one that refuses to +/// close at all. +class FakeWebSocketSink implements WebSocketSink { + FakeWebSocketSink({this.holdClose = false, this.closeError, this.onSent, this._onClosed}); + + /// Whether [close] waits for [completeClose] before finishing. + final bool holdClose; + + /// Thrown by [close], for a socket that refuses to close. + final Object? closeError; + + /// Called with each frame sent, for a peer that answers. + void Function(Object? frame)? onSent; + + final void Function()? _onClosed; + + /// Everything sent to the peer, in order. + final sent = []; + + /// The code and reason [close] was called with, or `null` while still open. + ({int? code, String? reason})? closedWith; + + final _held = Completer(); + final _done = Completer(); + + /// Lets a close held by `holdClose` finish. + void completeClose() { + if (!_held.isCompleted) _held.complete(); + } + + @override + void add(Object? data) { + sent.add(data); + onSent?.call(data); + } + + @override + void addError(Object error, [StackTrace? stackTrace]) {} + + @override + Future addStream(Stream stream) => stream.forEach(add); + + @override + Future close([int? closeCode, String? closeReason]) async { + closedWith = (code: closeCode, reason: closeReason); + if (holdClose) await _held.future; + if (closeError case final error?) throw error; // ignore: only_throw_errors + + _onClosed?.call(); + if (!_done.isCompleted) _done.complete(); + } + + @override + Future get done => _done.future; +} + +/// A WebSocket a test drives, in place of a real connection. +/// +/// Incoming frames arrive through [emit], and [endStream] is the peer hanging up. Closing [sink] +/// ends [stream], which is the guarantee `StreamChannel` requires of a real channel. +class FakeWebSocketChannel extends StreamChannelMixin implements WebSocketChannel { + FakeWebSocketChannel({ + bool holdClose = false, + Object? closeError, + this.readyError, + this.holdReady = false, + }) { + sink = FakeWebSocketSink( + holdClose: holdClose, + closeError: closeError, + onClosed: endStream, + ); + } + + /// Thrown by [ready], for a socket that never opens. + final Object? readyError; + + /// Whether [ready] never completes, for a handshake that hangs. + final bool holdReady; + + @override + // ignore: close_sinks + late final FakeWebSocketSink sink; + + // A controller with no `onCancel` has nothing to wait for, so `cancel` hands back a future that + // belongs to the root zone. A `fakeAsync` test never drives that zone, so awaiting the cancel there + // stalls for good, while against a real socket it completes. Naming an `onCancel` is what makes the + // controller hand back a future of its own instead. + final _incoming = StreamController(onCancel: Future.value); + + /// Delivers an incoming frame. + void emit(Object? frame) { + if (!_incoming.isClosed) _incoming.add(frame); + } + + /// Delivers a socket error, as a connection failing mid-stream does. + /// + /// A real socket closes itself after reporting one, so [endStream] follows. + void emitError(Object error) { + if (_incoming.isClosed) return; + + _incoming.addError(error); + endStream(); + } + + /// Ends the incoming stream, as a peer hanging up does. + void endStream() { + if (!_incoming.isClosed) _incoming.close().ignore(); + } + + @override + Stream get stream => _incoming.stream; + + @override + Future get ready { + if (readyError case final error?) return Future.error(error); + if (holdReady) return Completer().future; + return Future.value(); + } + + @override + String? get protocol => null; + + @override + int? get closeCode => sink.closedWith?.code; + + @override + String? get closeReason => sink.closedWith?.reason; +} diff --git a/packages/stream_core/test/helpers/ws_client_tester.dart b/packages/stream_core/test/helpers/ws_client_tester.dart new file mode 100644 index 00000000..e47938ba --- /dev/null +++ b/packages/stream_core/test/helpers/ws_client_tester.dart @@ -0,0 +1,285 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart' as test; + +import 'fake_server.dart'; +import 'user_token.dart'; + +/// A network provider a test drives, in place of one watching a real interface. +class TestNetworkStateProvider implements NetworkStateProvider { + TestNetworkStateProvider([NetworkState initial = NetworkState.connected]) : _state = MutableStateEmitter(initial); + + @override + NetworkStateEmitter get state => _state; + final MutableStateEmitter _state; + + /// Reports the network as available, as regaining a connection does. + void connect() => _state.value = NetworkState.connected; + + /// Reports the network as gone, as losing a connection does. + void disconnect() => _state.value = NetworkState.disconnected; + + Future close() => _state.close(); +} + +/// A lifecycle provider a test drives, in place of one watching the real app. +class TestLifecycleStateProvider implements LifecycleStateProvider { + TestLifecycleStateProvider([LifecycleState initial = LifecycleState.foreground]) + : _state = MutableStateEmitter(initial); + + @override + LifecycleStateEmitter get state => _state; + final MutableStateEmitter _state; + + /// Reports the app as in use, as returning to it does. + void foreground() => _state.value = LifecycleState.foreground; + + /// Reports the app as put away, as leaving it does. + void background() => _state.value = LifecycleState.background; + + Future close() => _state.close(); +} + +/// Everything a test drives a client through, and the signals it asserts on. +class WsClientTester { + WsClientTester._({ + required this.client, + required this.server, + required this.network, + required this.lifecycle, + required this.states, + required this._attempts, + required this._tokenLoads, + required this._subscription, + required this._recovery, + required this.tokens, + }); + + /// The client under test, driven through its public surface alone. + final StreamWebSocketClient client; + + /// The server it is talking to. + final FakeServer server; + + /// The manager the default authenticator loads credentials from. + /// + /// Point it at another user with `setTokenProvider` to model an app signing a different one in. + final TokenManager tokens; + + /// The network the recovery handler is watching. + final TestNetworkStateProvider network; + + /// The app lifecycle the recovery handler is watching. + final TestLifecycleStateProvider lifecycle; + + /// Every connection state the client has reported, in order. + /// + /// An app can observe these, which is what makes them worth asserting on: a reconnection shows + /// up as another [Connecting], and nothing else needs to be reached into to see it. + final List states; + + final int Function() _attempts; + final int Function() _tokenLoads; + final StreamSubscription _subscription; + final ConnectionRecoveryHandler? _recovery; + + /// How many connection attempts have been made, counted as the options were built for each. + int get attempts => _attempts(); + + /// How many times credentials have been loaded from the token provider. + /// + /// A reconnection that presents a token issued after a refusal shows up here as another load. + int get tokenLoads => _tokenLoads(); + + /// The current connection state. + WebSocketConnectionState get connectionState => client.connectionState.value; + + /// Sends [frame] from the server and lets the client react to it. + Future emit(Map frame) async { + server.send(frame); + await pumpEventQueue(); + } + + /// Waits for the work an action set off to finish. + Future pumpEventQueue({int times = 20}) => test.pumpEventQueue(times: times); + + /// Releases everything this tester holds. + /// + /// [wsClientTest] calls this for you. A test driving `fakeAsync` must not: these futures were + /// created inside the fake zone, and awaiting them once it has been discarded never returns. + /// Nothing outlives that zone, so there is nothing left to release. + Future dispose() async { + await _subscription.cancel(); + await _recovery?.dispose(); + await client.dispose(); + await network.close(); + await lifecycle.close(); + server.dispose(); + } +} + +/// Runs a test against a fully wired [StreamWebSocketClient]. +/// +/// Only the socket is stood in for: the engine, codec, authentication handler, health monitor and +/// recovery handler are the real ones, so a test drives the client the way an app does and the +/// server answers what the client actually sent. +/// +/// [user] is who the client authenticates as, and who the server accepts by default. [tokenLoader] +/// replaces the default token, for a test that cares how often credentials are loaded or what is +/// presented the second time. [authenticator] replaces the whole handshake, for a client that sends +/// something else. Pass `authenticates: false` for a connection that needs nothing sent. +/// +/// [recover] wires the recovery handler in, so a drop is reconnected as it is in an app. It is off +/// by default, because most tests are about what a single attempt does. +/// +/// [connect] runs before [body] and defaults to connecting and asserting the connection was +/// established. Pass a callback of your own for a test that starts from somewhere else, or +/// `(_) async {}` to start from a client that has never connected. +@isTest +void wsClientTest( + String description, { + String user = 'luke_skywalker', + Future Function(String userId)? tokenLoader, + WebSocketAuthenticator? authenticator, + bool authenticates = true, + TokenManager? tokens, + bool recover = false, + Duration? connectTimeout, + bool handshakeFails = false, + bool handshakeHangs = false, + bool holdClose = false, + Object? closeError, + FutureOr Function(WsClientTester tester)? connect, + required FutureOr Function(WsClientTester tester) body, + Iterable tags = const ['ws-client'], +}) { + return test.test( + description, + tags: tags, + () async { + final tester = buildTester( + user: user, + tokenLoader: tokenLoader, + authenticator: authenticator, + authenticates: authenticates, + recover: recover, + connectTimeout: connectTimeout, + handshakeFails: handshakeFails, + handshakeHangs: handshakeHangs, + holdClose: holdClose, + closeError: closeError, + ); + test.addTearDown(tester.dispose); + + await (connect ?? _defaultConnect)(tester); + await body(tester); + }, + ); +} + +// Connects and checks the connection was established, so a test body starts from a client that +// completed a handshake rather than one that merely tried. +Future _defaultConnect(WsClientTester tester) async { + await tester.client.connect(); + await tester.pumpEventQueue(); + + test.expect(tester.connectionState, test.isA()); +} + +/// Builds a tester without running a test around it, for a body that needs to control time. +/// +/// A test that drives timers wraps its own `fakeAsync` and cannot await a connect across it, so it +/// calls this and connects by hand. Everything else should use [wsClientTest]. +WsClientTester buildTester({ + String user = 'luke_skywalker', + Future Function(String userId)? tokenLoader, + WebSocketAuthenticator? authenticator, + bool authenticates = true, + TokenManager? tokens, + bool recover = false, + Duration? connectTimeout, + bool handshakeFails = false, + bool handshakeHangs = false, + bool holdClose = false, + Object? closeError, +}) { + final server = FakeServer(user: user); + + var tokenLoads = 0; + final tokenManager = + tokens ?? + TokenManager( + userId: user, + tokenProvider: TokenProvider.dynamic((userId) async { + tokenLoads++; + return tokenLoader?.call(userId) ?? generateTestUserToken(userId); + }), + ); + + var attempts = 0; + final client = StreamWebSocketClient( + optionsBuilder: () { + attempts++; + return switch (connectTimeout) { + final it? => WebSocketOptions(url: 'wss://example.com', connectTimeout: it), + // Left to the class default, so the attempts that rely on it really go through it. + null => const WebSocketOptions(url: 'wss://example.com'), + }; + }, + wsProvider: (_) => server.connect( + handshakeFails: handshakeFails, + handshakeHangs: handshakeHangs, + holdClose: holdClose, + closeError: closeError, + ), + onAuthenticate: switch (authenticates) { + true => authenticator ?? _authenticatorFor(tokenManager), + false => null, + }, + messageCodec: const JsonCodec(), + ); + + final network = TestNetworkStateProvider(); + final lifecycle = TestLifecycleStateProvider(); + + final recovery = switch (recover) { + true => ConnectionRecoveryHandler( + client: client, + networkStateProvider: network, + lifecycleStateProvider: lifecycle, + ), + false => null, + }; + + final states = []; + // Cancelled by `WsClientTester.dispose`, which a `fakeAsync` test deliberately skips. + // ignore: cancel_subscriptions + final subscription = client.connectionState.listen(states.add); + + return WsClientTester._( + client: client, + server: server, + network: network, + lifecycle: lifecycle, + states: states, + attempts: () => attempts, + tokenLoads: () => tokenLoads, + subscription: subscription, + recovery: recovery, + tokens: tokenManager, + ); +} + +// The handshake a consuming SDK performs: present a token, and replace one the server refused for +// having expired before presenting another. +WebSocketAuthenticator _authenticatorFor(TokenManager tokens) { + return (send, previousError) async { + // The refusal another token repairs. Left cached, the same token would be offered again. + if (previousError?.isTokenExpiredError ?? false) tokens.expireToken(); + + final token = await tokens.getToken(); + send(WsAuthMessageRequest(token: token.rawValue)).getOrThrow(); + }; +} diff --git a/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart b/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart new file mode 100644 index 00000000..d3b7f04b --- /dev/null +++ b/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart @@ -0,0 +1,249 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +import '../../../helpers/web_socket.dart'; + +/// A codec that passes strings through untouched. +class _StringCodec implements WebSocketMessageCodec { + const _StringCodec(); + + @override + Object encode(String message) => message; + + @override + String decode(Object message) => message.toString(); +} + +/// Records what the engine reported, in the order it reported it. +class _RecordingListener implements WebSocketEngineListener { + final closures = <({int? code, String? reason})>[]; + final messages = []; + int get opened => _opened; + var _opened = 0; + + @override + void onOpen() => _opened++; + + @override + void onMessage(String message) => messages.add(message); + + @override + void onError(Object error, [StackTrace? stackTrace]) {} + + @override + void onClose([int? closeCode, String? closeReason]) { + closures.add((code: closeCode, reason: closeReason)); + } +} + +/// Builds an engine over a socket a test drives. +/// +/// Pass [closeFails] for a socket that refuses to close. +({ + StreamWebSocketEngine engine, + _RecordingListener listener, + FakeWebSocketChannel socket, +}) +_subject({bool closeFails = false}) { + final socket = FakeWebSocketChannel( + closeError: closeFails ? Exception('close failed') : null, + ); + addTearDown(socket.endStream); + + final listener = _RecordingListener(); + final engine = StreamWebSocketEngine( + wsProvider: (_) => socket, + listener: listener, + messageCodec: const _StringCodec(), + ); + + return (engine: engine, listener: listener, socket: socket); +} + +const _options = WebSocketOptions(url: 'wss://example.com'); + +void main() { + test('reports the closure with the code and reason it was asked for', () async { + final (:engine, :listener, socket: _) = _subject(); + await engine.open(_options); + + final result = await engine.close(CloseCode.normalClosure, 'done'); + + expect(result.isSuccess, isTrue); + expect(listener.closures, [(code: CloseCode.normalClosure, reason: 'done')]); + }); + + test('refuses to send once closed', () async { + final (:engine, listener: _, socket: _) = _subject(); + await engine.open(_options); + + await engine.close(); + + expect(engine.sendMessage('ping').isFailure, isTrue); + }); + + test('reports the closure once, however many ways it hears about it', () async { + final (:engine, :listener, socket: _) = _subject(); + await engine.open(_options); + + await engine.close(CloseCode.normalClosure, 'done'); + // Long enough for a stream that ended to have reported it. + await pumpEventQueue(); + + // The socket ending is the same closure, not a second one: a listener told twice would treat + // the echo as a fresh disconnection. + expect(listener.closures, hasLength(1)); + }); + + test('reports a socket that failed to close as a failure, not a closure', () async { + final (:engine, :listener, socket: _) = _subject(closeFails: true); + await engine.open(_options); + + final result = await engine.close(CloseCode.normalClosure, 'done'); + + // Nothing closed, so nothing is announced. The caller hears the failure instead and decides + // what to tell anyone waiting. + expect(result.isFailure, isTrue); + expect(listener.closures, isEmpty); + }); + + test('delivers nothing from a socket it has closed', () async { + // A close that fails leaves the socket's stream running, which is what a message arriving after + // the closure needs to come from. + final (:engine, :listener, :socket) = _subject(closeFails: true); + await engine.open(_options); + + await engine.close(); + socket.emit('late'); + await pumpEventQueue(); + + // Delivered, it would arrive on a connection its listener has already been told is closed. + expect(listener.messages, isEmpty); + }); + + test('lets go of a socket that failed to close', () async { + final (:engine, listener: _, socket: _) = _subject(closeFails: true); + await engine.open(_options); + + await engine.close(); + + // A socket that refuses to close is not one this engine can use. Held on to, it would refuse + // every later open, leaving no way to replace it. + expect(engine.sendMessage('ping').isFailure, isTrue); + }); + + test('reports the closure when there was no connection to close', () async { + final (:engine, :listener, socket: _) = _subject(); + + final result = await engine.close(CloseCode.normalClosure, 'done'); + + // The caller asked for a closed connection and has one. Staying silent would leave whoever is + // waiting on the closure waiting for a report that never comes. + expect(result.isSuccess, isTrue); + expect(listener.closures, [(code: CloseCode.normalClosure, reason: 'done')]); + }); + + test('reports the closure for every close it is asked for', () async { + final (:engine, :listener, socket: _) = _subject(); + await engine.open(_options); + + await engine.close(CloseCode.normalClosure, 'first'); + await engine.close(CloseCode.normalClosure, 'second'); + + expect(listener.closures, [ + (code: CloseCode.normalClosure, reason: 'first'), + (code: CloseCode.normalClosure, reason: 'second'), + ]); + }); + + test('reports the socket as open', () async { + final (:engine, :listener, socket: _) = _subject(); + + final result = await engine.open(_options); + + expect(result.isSuccess, isTrue); + expect(listener.opened, 1); + }); + + test('refuses to open a socket while one is still open', () async { + final (:engine, :listener, :socket) = _subject(); + await engine.open(_options); + + final result = await engine.open(_options); + + // Closing the live socket to make room would hide a caller opening a second connection over a + // connection it still has. + expect(result.isFailure, isTrue); + expect(socket.sink.closedWith, isNull); + expect(listener.closures, isEmpty); + }); + + test('reports a socket that ends on its own as closed', () async { + final (:engine, :listener, :socket) = _subject(); + await engine.open(_options); + + socket.endStream(); + await pumpEventQueue(); + + // A server that hangs up produces no close call, only a stream that ends. + expect(listener.closures, hasLength(1)); + }); + + test('refuses to send before a socket is open', () { + final (:engine, listener: _, socket: _) = _subject(); + + expect(engine.sendMessage('ping').isFailure, isTrue); + }); + + test('encodes what it sends', () async { + final (:engine, listener: _, :socket) = _subject(); + await engine.open(_options); + + final result = engine.sendMessage('ping'); + + expect(result.isSuccess, isTrue); + expect(socket.sink.sent, ['ping']); + }); + + test('decodes what it receives', () async { + final (:engine, :listener, :socket) = _subject(); + await engine.open(_options); + + socket.emit('pong'); + await pumpEventQueue(); + + expect(listener.messages, ['pong']); + }); + + test('drops a message it cannot decode, rather than reporting it', () async { + final socket = FakeWebSocketChannel(); + addTearDown(socket.endStream); + + final listener = _RecordingListener(); + final engine = StreamWebSocketEngine( + wsProvider: (_) => socket, + listener: listener, + messageCodec: const _ThrowingCodec(), + ); + await engine.open(_options); + + socket.emit('garbage'); + await pumpEventQueue(); + + // A frame the codec rejects says nothing about the connection, so it is not worth ending one + // over. + expect(listener.messages, isEmpty); + expect(listener.closures, isEmpty); + }); +} + +/// A codec that cannot decode anything, as one meeting an unknown frame cannot. +class _ThrowingCodec implements WebSocketMessageCodec { + const _ThrowingCodec(); + + @override + Object encode(String message) => message; + + @override + String decode(Object message) => throw const FormatException('undecodable'); +} diff --git a/packages/stream_core/test/ws/client/engine/web_socket_engine_test.dart b/packages/stream_core/test/ws/client/engine/web_socket_engine_test.dart new file mode 100644 index 00000000..f6ee932a --- /dev/null +++ b/packages/stream_core/test/ws/client/engine/web_socket_engine_test.dart @@ -0,0 +1,55 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +StreamApiError _apiError({required int code}) => StreamApiError( + code: code, + details: const [], + duration: '0ms', + message: 'error $code', + moreInfo: '', + statusCode: 401, +); + +void main() { + group('WebSocketEngineException', () { + test('reads the API error out of a closure the server explained', () { + final apiError = _apiError(code: 40); + + // This is what decides whether a closure is reconnected, so it has to survive being wrapped. + expect(WebSocketEngineException(error: apiError).apiError, apiError); + }); + + test('has no API error for a closure that carried something else', () { + // A socket that gave out carries the failure it hit, which says nothing about credentials. + expect(WebSocketEngineException(error: StateError('socket died')).apiError, isNull); + }); + + test('stands in for a code and reason it was not given', () { + const exception = WebSocketEngineException(); + + // A closure with no code is not the same as one closed normally, which is never reconnected. + expect(exception.code, 0); + expect(exception.code, isNot(CloseCode.normalClosure)); + expect(exception.reason, 'Unknown'); + }); + + test('stands in for a code given as null', () { + // The engine reads this off a socket, which reports null for a closure it never saw. Unlike + // the reason, the code has a non-null default, so passing null has to be handled too. + expect(const WebSocketEngineException(code: null).code, 0); + }); + + test('compares by what it carries', () { + final apiError = _apiError(code: 40); + + expect( + WebSocketEngineException(code: 1000, reason: 'bye', error: apiError), + WebSocketEngineException(code: 1000, reason: 'bye', error: apiError), + ); + expect( + const WebSocketEngineException(code: 1000), + isNot(const WebSocketEngineException(code: 1011)), + ); + }); + }); +} diff --git a/packages/stream_core/test/ws/client/reconnect/automatic_reconnection_policy_test.dart b/packages/stream_core/test/ws/client/reconnect/automatic_reconnection_policy_test.dart new file mode 100644 index 00000000..6fe03e3e --- /dev/null +++ b/packages/stream_core/test/ws/client/reconnect/automatic_reconnection_policy_test.dart @@ -0,0 +1,143 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +import '../../../helpers/ws_client_tester.dart'; + +/// A policy with a fixed answer, for testing how policies combine. +class _Fixed implements AutomaticReconnectionPolicy { + const _Fixed({required this._answer}); + + final bool _answer; + + @override + bool canBeReconnected() => _answer; +} + +void main() { + group('the connection state policy', () { + /// Builds the policy over a state a test controls. + ({WebSocketAutomaticReconnectionPolicy policy, MutableStateEmitter state}) subject() { + final state = MutableStateEmitter(const Initialized()); + addTearDown(state.close); + + return ( + policy: WebSocketAutomaticReconnectionPolicy(connectionState: state), + state: state, + ); + } + + test('allows a reconnection after a drop the client did not ask for', () { + final (:policy, :state) = subject(); + + state.value = const Disconnected(source: SystemInitiated()); + + expect(policy.canBeReconnected(), isTrue); + }); + + test('refuses one after a disconnect the caller asked for', () { + final (:policy, :state) = subject(); + + state.value = const Disconnected(source: UserInitiated()); + + expect(policy.canBeReconnected(), isFalse); + }); + + test('refuses one while a connection is still being made', () { + final (:policy, :state) = subject(); + + // A second attempt on top of one already running would leave a socket nothing closes. + state.value = const Connecting(); + expect(policy.canBeReconnected(), isFalse); + + state.value = const Authenticating(); + expect(policy.canBeReconnected(), isFalse); + }); + }); + + group('the internet availability policy', () { + test('allows a reconnection while the network is up', () { + final network = TestNetworkStateProvider(); + addTearDown(network.close); + + expect(InternetAvailabilityReconnectionPolicy(networkState: network.state).canBeReconnected(), isTrue); + }); + + test('refuses one while the network is down', () { + final network = TestNetworkStateProvider(NetworkState.disconnected); + addTearDown(network.close); + + // An attempt made with no network fails immediately and spends a backoff step for nothing. + expect(InternetAvailabilityReconnectionPolicy(networkState: network.state).canBeReconnected(), isFalse); + }); + + test('refuses one before the network has been looked at', () { + final network = TestNetworkStateProvider(NetworkState.unknown); + addTearDown(network.close); + + // Not yet known is not the same as available, and guessing costs an attempt. + expect(InternetAvailabilityReconnectionPolicy(networkState: network.state).canBeReconnected(), isFalse); + }); + }); + + group('the background state policy', () { + test('allows a reconnection while the app is in use', () { + final lifecycle = TestLifecycleStateProvider(); + addTearDown(lifecycle.close); + + expect(BackgroundStateReconnectionPolicy(appLifecycleState: lifecycle.state).canBeReconnected(), isTrue); + }); + + test('refuses one while the app is put away', () { + final lifecycle = TestLifecycleStateProvider(LifecycleState.background); + addTearDown(lifecycle.close); + + // Nobody is looking at it, so a connection would cost battery for nothing. + expect(BackgroundStateReconnectionPolicy(appLifecycleState: lifecycle.state).canBeReconnected(), isFalse); + }); + + test('refuses one before the lifecycle has been looked at', () { + final lifecycle = TestLifecycleStateProvider(LifecycleState.unknown); + addTearDown(lifecycle.close); + + expect(BackgroundStateReconnectionPolicy(appLifecycleState: lifecycle.state).canBeReconnected(), isFalse); + }); + }); + + group('policies combined', () { + test('with `and`, one refusal is enough to stop a reconnection', () { + final policy = CompositeReconnectionPolicy( + operator: Operator.and, + policies: const [_Fixed(answer: true), _Fixed(answer: false), _Fixed(answer: true)], + ); + + expect(policy.canBeReconnected(), isFalse); + }); + + test('with `and`, every policy has to agree', () { + final policy = CompositeReconnectionPolicy( + operator: Operator.and, + policies: const [_Fixed(answer: true), _Fixed(answer: true)], + ); + + expect(policy.canBeReconnected(), isTrue); + }); + + test('with `or`, one policy is enough to allow a reconnection', () { + final policy = CompositeReconnectionPolicy( + operator: Operator.or, + policies: const [_Fixed(answer: false), _Fixed(answer: true)], + ); + + expect(policy.canBeReconnected(), isTrue); + }); + + test('with `or`, every policy has to refuse to stop one', () { + final policy = CompositeReconnectionPolicy( + operator: Operator.or, + policies: const [_Fixed(answer: false), _Fixed(answer: false)], + ); + + expect(policy.canBeReconnected(), isFalse); + }); + }); +} diff --git a/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart b/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart index 234b4846..7b437779 100644 --- a/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart +++ b/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart @@ -1,207 +1,272 @@ -import 'dart:async'; - import 'package:fake_async/fake_async.dart'; -import 'package:mocktail/mocktail.dart'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; -class _MockWebSocketChannel extends Mock implements WebSocketChannel {} +import '../../../helpers/ws_client_tester.dart'; -class _MockWebSocketSink extends Mock implements WebSocketSink {} +/// Long enough for a health check to go unanswered, which is the drop this handler recovers from. +/// +/// The monitor pings every 25 seconds and gives the peer 3 to answer. +const _untilUnhealthy = Duration(seconds: 29); -/// A stream whose subscription cancels without going through the event loop, so -/// a close under `fakeAsync` runs to completion as it does in production. -class _CancellableStream extends Stream { - _CancellableStream(this._source); +void main() { + test('does not retry a first attempt that never connected', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + // A server that takes the credentials and never answers, so the attempt is abandoned. + tester.server.onFrame = (_) => []; + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + async.elapse(WebSocketOptions.defaultConnectTimeout); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + // Retrying here would work behind a caller already told it failed. + async.elapse(const Duration(minutes: 1)); + expect(tester.attempts, 1); + }); + }); - final Stream _source; + test('retries a connection that dropped after being established', () { + fakeAsync((async) { + final tester = buildTester(recover: true); - @override - StreamSubscription listen( - void Function(T event)? onData, { - Function? onError, - void Function()? onDone, - bool? cancelOnError, - }) { - return _CancellableSubscription( - _source.listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError), - ); - } -} - -class _CancellableSubscription implements StreamSubscription { - _CancellableSubscription(this._delegate); + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); - final StreamSubscription _delegate; + // The server stops answering health checks, which is a drop rather than a failed attempt, so + // recovering it is this handler's job. The first retry carries no delay. + tester.server.onFrame = (_) => []; + async.elapse(_untilUnhealthy); + async.flushMicrotasks(); - @override - Future cancel() { - _delegate.cancel().ignore(); - return Future.value(); - } + expect(tester.attempts, 2); + expect(tester.states.whereType(), hasLength(2)); + }); + }); - @override - void onData(void Function(T data)? handleData) => _delegate.onData(handleData); + test('hands connecting back after the caller disconnected', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + tester.client.disconnect().ignore(); + async.flushMicrotasks(); + + // A fresh attempt, awaited by whoever made it, that never connects. + tester.server.onFrame = (_) => []; + tester.client.connect().ignore(); + async.flushMicrotasks(); + async.elapse(WebSocketOptions.defaultConnectTimeout); + async.flushMicrotasks(); + + // Having connected in a previous session does not make this failure the handler's to retry. + async.elapse(const Duration(minutes: 1)); + expect(tester.attempts, 2); + }); + }); - @override - void onError(Function? handleError) => _delegate.onError(handleError); + test('stops a retry the caller called off while it was pending', () { + fakeAsync((async) { + final tester = buildTester(recover: true); - @override - void onDone(void Function()? handleDone) => _delegate.onDone(handleDone); + tester.client.connect().ignore(); + async.flushMicrotasks(); - @override - void pause([Future? resumeSignal]) => _delegate.pause(resumeSignal); + // A closure worth retrying, so one is scheduled. + tester.server.hangUp(); + async.flushMicrotasks(); - @override - void resume() => _delegate.resume(); + // The caller says stop while that retry is still pending. Asking to disconnect a connection + // that is already down is asking for whatever is pending on its behalf to stop too. + tester.client.disconnect().ignore(); + async.flushMicrotasks(); - @override - bool get isPaused => _delegate.isPaused; + async.elapse(const Duration(minutes: 1)); - @override - Future asFuture([E? futureValue]) => _delegate.asFuture(futureValue); -} + expect(tester.attempts, 1); + }); + }); -class _NoopCodec implements WebSocketMessageCodec { - const _NoopCodec(); + test('cancels a retry it had already scheduled when the caller disconnects', () { + fakeAsync((async) { + final tester = buildTester(recover: true); - @override - Object encode(WsRequest message) => ''; + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); - @override - WsEvent decode(Object message) => const _HealthCheckEvent(); -} + // The server hangs up, which schedules a retry. + tester.server.hangUp(); + async.flushMicrotasks(); + expect(async.pendingTimers, isNotEmpty); -final class _HealthCheckEvent extends WsEvent { - const _HealthCheckEvent(); + tester.client.disconnect().ignore(); + async.flushMicrotasks(); - @override - HealthCheckInfo? get healthCheckInfo => const HealthCheckInfo(connectionId: 'connection-id'); -} + // Nothing is left armed. A timer that outlives the caller's disconnect fires against a client + // they have closed, and reconnects it behind them. + expect(async.pendingTimers, isEmpty); + }); + }); -final class _PingRequest extends WsRequest { - const _PingRequest(); + test('does not retry a disconnect the caller asked for', () { + fakeAsync((async) { + final tester = buildTester(recover: true); - @override - Map toJson() => const {}; + tester.client.connect().ignore(); + async.flushMicrotasks(); - @override - List get props => const []; -} + tester.client.disconnect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); -/// A client whose socket opens but answers nothing, with a handler attached and -/// a count of the attempts it has made. -({StreamWebSocketClient client, int Function() attempts}) _client() { - final incoming = StreamController.broadcast(); - addTearDown(incoming.close); - - final channel = _MockWebSocketChannel(); - when(() => channel.ready).thenAnswer((_) async {}); - when(() => channel.stream).thenAnswer((_) => _CancellableStream(incoming.stream)); - final sink = _MockWebSocketSink(); - when(() => channel.sink).thenReturn(sink); - when(() => sink.close(any(), any())).thenAnswer((_) async {}); - - var attempts = 0; - final client = StreamWebSocketClient( - optionsBuilder: () { - attempts++; - return const WebSocketOptions(url: 'wss://example.com'); - }, - wsProvider: (_) => channel, - pingRequestBuilder: ([_]) => const _PingRequest(), - messageCodec: const _NoopCodec(), - ); - - final handler = ConnectionRecoveryHandler(client: client); - addTearDown(handler.dispose); - - return (client: client, attempts: () => attempts); -} + // Having been connected is not enough on its own: the source says this was deliberate. + async.elapse(const Duration(minutes: 1)); + expect(tester.attempts, 1); + }); + }); -void main() { - group('ConnectionRecoveryHandler', () { - test('does not retry a first attempt that never connected', () { + group('while the network is down', () { + test('does not retry a drop', () { fakeAsync((async) { - final (:client, :attempts) = _client(); + final tester = buildTester(recover: true); - client.connect().ignore(); + tester.client.connect().ignore(); async.flushMicrotasks(); - // The socket opened but the server never answers, so the attempt is - // abandoned — the failure the caller of `connect` is handed. - async.elapse(WebSocketOptions.defaultConnectTimeout); + tester.network.disconnect(); async.flushMicrotasks(); - expect(client.connectionState.value, isA()); - // Retrying here would work behind a caller already told it failed, and - // would race the retry that caller makes in response. + // Losing the network takes the connection down, and there is nothing to reconnect to. async.elapse(const Duration(minutes: 1)); - expect(attempts(), 1); + expect(tester.attempts, 1); }); }); - test('retries a connection that dropped after being established', () { + test('retries as soon as it comes back', () { fakeAsync((async) { - final (:client, :attempts) = _client(); + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); - client.connect().ignore(); + tester.network.disconnect(); async.flushMicrotasks(); - client.onMessage(const _HealthCheckEvent()); - expect(client.connectionState.value, isA()); + expect(tester.connectionState, isA()); - // The connection stops answering health checks — a drop rather than a - // failure to connect, so recovering it is this handler's job. The first - // retry carries no delay, so it is under way by the time this returns. - async.elapse(const Duration(seconds: 29)); + tester.network.connect(); async.flushMicrotasks(); - expect(attempts(), 2); - expect(client.connectionState.value, isA()); + // The network returning is the triggering event, so nothing waits out a backoff for it. + expect(tester.attempts, 2); + expect(tester.connectionState, isA()); }); }); + }); - test('hands connecting back after the caller disconnected', () { + group('while the app is in the background', () { + test('takes the connection down and leaves it down', () { fakeAsync((async) { - final (:client, :attempts) = _client(); + final tester = buildTester(recover: true); - client.connect().ignore(); + tester.client.connect().ignore(); async.flushMicrotasks(); - client.onMessage(const _HealthCheckEvent()); - client.disconnect().ignore(); + + tester.lifecycle.background(); async.flushMicrotasks(); - // A fresh attempt, awaited by whoever made it, that never connects. - client.connect().ignore(); + // A connection nobody is looking at costs battery for nothing. + expect(tester.connectionState, isA()); + + async.elapse(const Duration(minutes: 1)); + expect(tester.attempts, 1); + }); + }); + + test('reconnects when the app is opened again', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + tester.lifecycle.background(); async.flushMicrotasks(); - async.elapse(WebSocketOptions.defaultConnectTimeout); + + tester.lifecycle.foreground(); async.flushMicrotasks(); - // Having connected in the previous session does not make this failure - // the handler's to retry. - async.elapse(const Duration(minutes: 1)); - expect(attempts(), 2); + expect(tester.attempts, 2); + expect(tester.connectionState, isA()); }); }); + }); - test('does not retry a disconnect the caller asked for', () { + group('a policy the app supplied', () { + test('can refuse a reconnection the built-in policies would allow', () { fakeAsync((async) { - final (:client, :attempts) = _client(); + // No handler of its own, so this test owns the one it is testing. + final tester = buildTester(); + ConnectionRecoveryHandler( + client: tester.client, + policies: [const _Refuses()], + ); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); - client.connect().ignore(); + // A drop that would otherwise be recovered from. + tester.server.hangUp(); async.flushMicrotasks(); - client.onMessage(const _HealthCheckEvent()); + async.elapse(const Duration(minutes: 1)); + + // Policies are combined with `and`, so an app can veto a reconnection the client is + // otherwise happy to make. + expect(tester.attempts, 1); + }); + }); - client.disconnect().ignore(); + test('is consulted alongside them rather than replacing them', () { + fakeAsync((async) { + final tester = buildTester(); + ConnectionRecoveryHandler( + client: tester.client, + policies: [const _Allows()], + ); + + tester.client.connect().ignore(); async.flushMicrotasks(); - expect(client.connectionState.value, isA()); - // Having been connected is not enough on its own: the source still says - // this was deliberate. + // A disconnect the caller asked for, which a built-in policy refuses. An app policy that + // allows must not override that. + tester.client.disconnect().ignore(); + async.flushMicrotasks(); async.elapse(const Duration(minutes: 1)); - expect(attempts(), 1); + + expect(tester.attempts, 1); }); }); }); } + +/// An app policy that never wants a reconnection. +class _Refuses implements AutomaticReconnectionPolicy { + const _Refuses(); + + @override + bool canBeReconnected() => false; +} + +/// An app policy that always wants one. +class _Allows implements AutomaticReconnectionPolicy { + const _Allows(); + + @override + bool canBeReconnected() => true; +} diff --git a/packages/stream_core/test/ws/client/reconnect/retry_strategy_test.dart b/packages/stream_core/test/ws/client/reconnect/retry_strategy_test.dart new file mode 100644 index 00000000..1600e405 --- /dev/null +++ b/packages/stream_core/test/ws/client/reconnect/retry_strategy_test.dart @@ -0,0 +1,81 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +void main() { + test('retries the first time without waiting', () { + final strategy = RetryStrategy(); + + // A connection that has just dropped is most likely to come straight back, and a delay here is + // felt by whoever is looking at the screen. + expect(strategy.getNextRetryDelay(), Duration.zero); + }); + + test('waits longer the more attempts have failed', () { + final strategy = RetryStrategy(); + + final delays = []; + for (var i = 0; i < 6; i++) { + delays.add(strategy.getDelayAfterTheFailure()); + } + + // Not strictly increasing, because each delay is drawn from a range, but the ranges climb: a + // server that is struggling is asked less and less often. + expect(delays.first, Duration.zero); + expect(delays.last, greaterThan(delays[1])); + expect(delays, everyElement(lessThanOrEqualTo(const Duration(seconds: 25)))); + }); + + test('never waits longer than the ceiling', () { + final strategy = RetryStrategy(); + + for (var i = 0; i < 100; i++) { + strategy.incrementConsecutiveFailures(); + } + + // Without a ceiling a long outage would push the next attempt hours away, and the connection + // would not come back for a user who is waiting on it. + expect( + strategy.getNextRetryDelay(), + lessThanOrEqualTo(const Duration(seconds: DefaultRetryStrategy.maximumReconnectionDelayInSeconds)), + ); + }); + + test('spreads the delay across a range, so clients do not retry in lockstep', () { + // Every client that dropped when a server went down would otherwise come back at the same + // instant and take it down again. + final delays = {}; + for (var i = 0; i < 200; i++) { + final strategy = RetryStrategy(); + for (var f = 0; f < 5; f++) { + strategy.incrementConsecutiveFailures(); + } + delays.add(strategy.getNextRetryDelay()); + } + + expect(delays, hasLength(greaterThan(1))); + }); + + test('counts the failures it has seen', () { + final strategy = RetryStrategy(); + expect(strategy.consecutiveFailuresCount, 0); + + strategy.getDelayAfterTheFailure(); + strategy.getDelayAfterTheFailure(); + + expect(strategy.consecutiveFailuresCount, 2); + }); + + test('starts over once a connection is established', () { + final strategy = RetryStrategy(); + for (var i = 0; i < 5; i++) { + strategy.getDelayAfterTheFailure(); + } + + strategy.resetConsecutiveFailures(); + + // The backoff a previous outage built up does not apply to the next one, which starts with the + // immediate retry a fresh drop deserves. + expect(strategy.consecutiveFailuresCount, 0); + expect(strategy.getNextRetryDelay(), Duration.zero); + }); +} diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index 3b073e3c..58592845 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -1,465 +1,793 @@ import 'dart:async'; import 'package:fake_async/fake_async.dart'; -import 'package:mocktail/mocktail.dart'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; -class _MockWebSocketChannel extends Mock implements WebSocketChannel {} +import '../../helpers/fake_server.dart'; +import '../../helpers/user_token.dart'; +import '../../helpers/ws_client_tester.dart'; -class _MockWebSocketSink extends Mock implements WebSocketSink {} +/// A connect that is expected not to establish anything, so nothing is asserted about the outcome. +Future _justConnect(WsClientTester tester) async { + await tester.client.connect(); + await tester.pumpEventQueue(); +} -/// A codec that is never exercised: these tests drive the client through its -/// engine listener callbacks rather than through encoded frames. -class _NoopCodec implements WebSocketMessageCodec { - const _NoopCodec(); +void main() { + group('connect', () { + wsClientTest( + 'reports a handshake that failed as closed', + handshakeFails: true, + connect: _justConnect, + body: (tester) { + // Nothing else reports it: a socket that never opened has no closure of its own to deliver, + // so the client would otherwise be left connecting for good. + expect(tester.connectionState, isA()); + }, + ); - @override - Object encode(WsRequest message) => ''; + wsClientTest( + 'closes a socket whose handshake failed', + handshakeFails: true, + connect: _justConnect, + body: (tester) { + // The socket is opened before the handshake it fails, so reporting closed without closing + // left one open that nothing could reach — not even `dispose`. + expect(tester.server.socket.sink.closedWith, isNotNull); + }, + ); - @override - WsEvent decode(Object message) => const _HealthCheckEvent(); -} + wsClientTest( + 'leaves no socket behind for a later attempt to close', + handshakeFails: true, + connect: _justConnect, + body: (tester) async { + await tester.client.connect(); + await tester.pumpEventQueue(); + + // Each attempt closes its own socket. A socket left behind is closed by the next attempt + // instead, which reports a closure while that attempt is still connecting. + expect(tester.server.sockets, hasLength(2)); + expect(tester.server.sockets.every((it) => it.sink.closedWith != null), isTrue); + }, + ); -final class _HealthCheckEvent extends WsEvent { - const _HealthCheckEvent({this.connectionId = 'connection-id'}); + wsClientTest( + 'builds the options for every connection attempt, not once per client', + body: (tester) async { + expect(tester.attempts, 1); - final String? connectionId; + await tester.client.disconnect(); + await tester.client.connect(); + await tester.pumpEventQueue(); - @override - HealthCheckInfo? get healthCheckInfo { - return HealthCheckInfo(connectionId: connectionId); - } -} + expect(tester.attempts, 2); + }, + ); -final class _PingRequest extends WsRequest { - const _PingRequest(); + wsClientTest( + 'reports the connection established only once the server answers', + connect: (tester) async { + // A server that accepts the credentials but has not replied yet. + tester.server.onFrame = (_) => []; - @override - Map toJson() => const {}; + await tester.client.connect(); + await tester.pumpEventQueue(); + }, + body: (tester) async { + // `connect` completes when the socket opens, well before the connection is usable, which is + // why an app watches the state rather than awaiting the call. + expect(tester.connectionState, isA()); - @override - List get props => const []; -} + await tester.emit({'type': 'connection.ok', 'connection_id': 'connection-id'}); -/// A stream whose subscription cancels without going through the event loop. -/// -/// `StreamController`'s cancel completes on the event loop, which `fakeAsync` -/// never drives, so a client awaiting one hangs in a test where it would not in -/// production — leaving the close half finished. -class _CancellableStream extends Stream { - _CancellableStream(this._source); - - final Stream _source; - - @override - StreamSubscription listen( - void Function(T event)? onData, { - Function? onError, - void Function()? onDone, - bool? cancelOnError, - }) { - return _CancellableSubscription( - _source.listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError), + expect(tester.connectionState, isA()); + expect( + tester.states.map((it) => it.runtimeType), + containsAllInOrder([Initialized, Connecting, Authenticating, Connected]), + ); + }, ); - } -} -class _CancellableSubscription implements StreamSubscription { - _CancellableSubscription(this._delegate); + wsClientTest( + 'carries the connection id the server issued', + connect: (tester) async { + tester.server.onFrame = (_) => []; + await tester.client.connect(); + await tester.pumpEventQueue(); + }, + body: (tester) async { + await tester.emit({'type': 'connection.ok', 'connection_id': 'a-real-connection'}); + + // The id is what later pings are stamped with, so it has to survive the wire. + expect( + tester.connectionState, + isA().having( + (it) => it.healthCheck.connectionId, + 'healthCheck.connectionId', + 'a-real-connection', + ), + ); + }, + ); + }); - final StreamSubscription _delegate; + group('authenticate', () { + wsClientTest( + 'presents credentials once the socket is open, while authenticating', + authenticator: (send, _) async { + send(const WsAuthMessageRequest(token: 'token')).getOrThrow(); + }, + connect: (tester) async { + WebSocketConnectionState? whenPresented; + tester.server.onFrame = (_) { + whenPresented = tester.connectionState; + return []; + }; + + await tester.client.connect(); + await tester.pumpEventQueue(); + + // The socket is open but the connection is not usable until the server answers. + expect(whenPresented, isA()); + }, + body: (tester) { + expect(tester.connectionState, isA()); + }, + ); - @override - Future cancel() { - _delegate.cancel().ignore(); - return Future.value(); - } + wsClientTest( + 'authenticates once per connection attempt', + body: (tester) async { + expect(tester.server.received, hasLength(1)); - @override - void onData(void Function(T data)? handleData) => _delegate.onData(handleData); + await tester.client.disconnect(); + await tester.client.connect(); + await tester.pumpEventQueue(); - @override - void onError(Function? handleError) => _delegate.onError(handleError); + // Each attempt presents credentials of its own; the server has now seen two handshakes. + expect(tester.server.received, hasLength(2)); + expect(tester.connectionState, isA()); + }, + ); - @override - void onDone(void Function()? handleDone) => _delegate.onDone(handleDone); + wsClientTest( + 'puts the credentials it was given on the wire', + tokenLoader: (userId) async => generateTestUserToken(userId), + body: (tester) { + // Really serialised and really read by the server, rather than handed over in memory. + final handshake = tester.server.received.single; + expect(handshake['token'], isA()); + expect(tester.tokenLoads, 1); + }, + ); - @override - void pause([Future? resumeSignal]) => _delegate.pause(resumeSignal); + wsClientTest( + 'stays authenticating when there is nothing to authenticate with', + authenticates: false, + connect: (tester) async { + await tester.client.connect(); + await tester.pumpEventQueue(); + }, + body: (tester) async { + // Nothing was sent, so the server has nothing to answer and the connection waits. + expect(tester.server.received, isEmpty); + expect(tester.connectionState, isA()); + + await tester.emit({'type': 'connection.ok', 'connection_id': 'connection-id'}); + expect(tester.connectionState, isA()); + }, + ); - @override - void resume() => _delegate.resume(); + group('the refusal handed to the next attempt', () { + /// Records what each attempt was told about the previous one. + ({WebSocketAuthenticator authenticator, List seen}) watching() { + final seen = []; + return ( + authenticator: (send, previousError) async { + seen.add(previousError); + send(WsAuthMessageRequest(token: generateTestUserToken('luke_skywalker').rawValue)).getOrThrow(); + }, + seen: seen, + ); + } - @override - bool get isPaused => _delegate.isPaused; + test('is what the server closed the previous attempt with', () async { + final (:authenticator, :seen) = watching(); + final tester = buildTester(authenticator: authenticator); - @override - Future asFuture([E? futureValue]) => _delegate.asFuture(futureValue); -} + await tester.client.connect(); + await tester.pumpEventQueue(); -/// Builds a client whose socket opens successfully but sends nothing, so the -/// handshake only progresses when a test drives it. -({ - StreamWebSocketClient client, - StreamController incoming, - int Function() optionsBuilt, - WebSocketSink sink, -}) -_client({ - Duration connectTimeout = WebSocketOptions.defaultConnectTimeout, - WebSocketAuthenticator? onAuthenticate, -}) { - final incoming = StreamController.broadcast(); - addTearDown(incoming.close); - - final channel = _MockWebSocketChannel(); - when(() => channel.ready).thenAnswer((_) async {}); - when(() => channel.stream).thenAnswer((_) => _CancellableStream(incoming.stream)); - final sink = _MockWebSocketSink(); - when(() => channel.sink).thenReturn(sink); - // A socket that closes cleanly; tests that need otherwise re-stub this. - when(() => sink.close(any(), any())).thenAnswer((_) async {}); - - var built = 0; - final client = StreamWebSocketClient( - optionsBuilder: () { - built++; - return WebSocketOptions( - url: 'wss://example.com', - connectTimeout: connectTimeout, - ); - }, - onAuthenticate: onAuthenticate, - wsProvider: (_) => channel, - pingRequestBuilder: ([_]) => const _PingRequest(), - messageCodec: const _NoopCodec(), - ); - - return (client: client, incoming: incoming, optionsBuilt: () => built, sink: sink); -} + await tester.emit(expiredTokenFrame()); + await tester.client.connect(); + await tester.pumpEventQueue(); -void main() { - group('StreamWebSocketClient.disconnect', () { - test('leaves the connection closed, not closing, once it returns', () async { - final (:client, :sink, incoming: _, optionsBuilt: _) = _client(); - await client.connect(); + // The second attempt is told why the first ended, so it can present something else. + expect(seen, [null, isA().having((it) => it.code, 'code', 40)]); + }); - await client.disconnect(); + test('is absent once a connection has been established', () async { + final (:authenticator, :seen) = watching(); + final tester = buildTester(authenticator: authenticator); - // A caller that reconnects straight away would otherwise race the close - // and see the connection go down again. - expect(client.connectionState.value, isA()); - }); + await tester.client.connect(); + await tester.pumpEventQueue(); + await tester.emit(expiredTokenFrame()); - test('reports the connection closed even when the socket close fails', () async { - final (:client, :sink, incoming: _, optionsBuilt: _) = _client(); - when(() => sink.close(any(), any())).thenAnswer((_) => Future.error(Exception('close failed'))); - await client.connect(); + // The replacement is accepted, so the refusal is spent. + await tester.client.connect(); + await tester.pumpEventQueue(); + expect(tester.connectionState, isA()); - await client.disconnect(); + await tester.client.disconnect(); + await tester.client.connect(); + await tester.pumpEventQueue(); - // The engine swallows the failure and never notifies its listener, which - // used to leave the connection disconnecting for good. - expect( - client.connectionState.value, - isA().having((it) => it.source, 'source', isA()), - ); - }); + expect(seen, [null, isA(), null]); + }); - test('does not open a socket while the previous one is still closing', () async { - final (:client, :sink, incoming: _, :optionsBuilt) = _client(); - // Held open so the connection is still closing when connect is called. - final closing = Completer(); - when(() => sink.close(any(), any())).thenAnswer((_) => closing.future); - await client.connect(); - - client.disconnect().ignore(); - expect(client.connectionState.value, isA()); - await client.connect(); - - // The old socket's close event would otherwise bring the new connection - // down and disarm the timeout meant to be watching it. - expect(optionsBuilt(), 1); - expect(client.connectionState.value, isA()); - closing.complete(); - }); + test('is absent after a closure the server did not cause', () async { + final seen = []; + final tester = buildTester( + // Declines, the way an authenticator with nothing left to offer does, which closes the + // connection as `AuthenticationFailed`. + authenticator: (send, previousError) async { + seen.add(previousError); + if (previousError != null) throw StateError('nothing to offer'); + send(WsAuthMessageRequest(token: generateTestUserToken('luke_skywalker').rawValue)).getOrThrow(); + }, + ); - test('can be followed by another connect', () async { - final (:client, :sink, incoming: _, :optionsBuilt) = _client(); - await client.connect(); - await client.disconnect(); + await tester.client.connect(); + await tester.pumpEventQueue(); + await tester.emit(expiredTokenFrame()); - await client.connect(); + await tester.client.connect(); + await tester.pumpEventQueue(); + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); - expect(client.connectionState.value, isA()); - expect(optionsBuilt(), 2); - }); - }); + // A refusal left armed through that would decline this attempt too, without the credentials + // it carries ever being sent. + await tester.client.connect(); + await tester.pumpEventQueue(); - group('StreamWebSocketClient.dispose', () { - test('closes the connection and both emitters', () async { - final (:client, :sink, incoming: _, optionsBuilt: _) = _client(); - await client.connect(); + expect(seen, [null, isA(), null]); + }); + }); - await client.dispose(); + group('when authentication fails', () { + wsClientTest( + 'closes the connection instead of waiting for a reply', + authenticator: (_, _) async => throw StateError('no token'), + connect: (tester) async { + await tester.client.connect(); + await tester.pumpEventQueue(); + }, + body: (tester) { + expect( + tester.connectionState, + isA().having( + (it) => it.source, + 'source', + isA().having((it) => it.error, 'error', isStateError), + ), + ); + }, + ); - expect(client.isDisposed, isTrue); - expect(client.events.isClosed, isTrue); - expect(client.connectionState.isClosed, isTrue); - }); + wsClientTest( + 'does not retry, since the same credentials would fail again', + authenticator: (_, _) async => throw StateError('no token'), + recover: true, + connect: (tester) async { + await tester.client.connect(); + await tester.pumpEventQueue(); + }, + body: (tester) { + expect(tester.connectionState.isAutomaticReconnectionEnabled, isFalse); - test('does nothing when called again', () async { - final (:client, :sink, incoming: _, optionsBuilt: _) = _client(); - await client.connect(); - await client.dispose(); + // Nothing was sent, so there is nothing for a retry to build on. + expect(tester.server.received, isEmpty); + expect(tester.attempts, 1); + }, + ); - await expectLater(client.dispose(), completes); - }); + wsClientTest( + 'reports a refusal the server sent, carrying what it said', + connect: (tester) async { + // The credentials reach the server and it refuses them, rather than the client failing to + // present any. + tester.server.onFrame = (_) => [invalidSignatureFrame()]; - test('refuses to connect again', () async { - final (:client, :sink, incoming: _, :optionsBuilt) = _client(); - await client.connect(); - await client.dispose(); - - // Asserts rather than throws: a reconnect can come from the recovery - // handler, which does not await it and cannot report an error. In a - // release build the assert is gone and the call is a no-op, which is what - // the untouched builder count pins. - await expectLater(client.connect(), throwsA(isA())); - expect(optionsBuilt(), 1); + await tester.client.connect(); + await tester.pumpEventQueue(); + }, + body: (tester) { + // The credentials went out and were answered, so this is the server's refusal rather than + // a failure to present them. + expect(tester.server.received, hasLength(1)); + expect( + tester.connectionState, + isA().having( + (it) => it.source, + 'source', + isA().having( + (it) => it.error?.apiError?.code, + 'apiError.code', + 43, + ), + ), + ); + + // No other token repairs a signature the server rejected. + expect(tester.connectionState.isAutomaticReconnectionEnabled, isFalse); + }, + ); }); - test('ignores a socket event arriving after it', () async { - final (:client, :sink, incoming: _, optionsBuilt: _) = _client(); - await client.connect(); - await client.dispose(); + group('when an attempt is abandoned while authenticating', () { + test('does not send its credentials over the connection that replaced it', () { + fakeAsync((async) { + final loaded = Completer(); + var calls = 0; + final tester = buildTester( + // Only the first attempt waits, so the token it is eventually handed belongs to a + // connection that has since been abandoned. + authenticator: (send, _) async { + if (++calls > 1) return; + await loaded.future; + send(const WsAuthMessageRequest(token: 'stale')).getOrThrow(); + }, + ); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + final abandoned = tester.server.socket; + + async.elapse(WebSocketOptions.defaultConnectTimeout); + expect(tester.connectionState, isA()); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + final replacement = tester.server.socket; + expect(replacement, isNot(abandoned)); + + loaded.complete(); + async.flushMicrotasks(); + + // The sender writes to whichever socket the engine holds, so credentials loaded for the + // abandoned attempt land on the one that replaced it and never asked for them. + expect(tester.server.received, isEmpty); + }); + }); - // The state emitter is closed, so a late event must not be reported into - // it rather than throwing. - expect(() => client.onClose(1000, 'late'), returnsNormally); + test('does not close the connection that replaced it when its credentials fail', () { + fakeAsync((async) { + final loaded = Completer(); + var calls = 0; + final tester = buildTester( + authenticator: (send, _) async { + if (++calls > 1) return; + await loaded.future; + throw StateError('token load failed'); + }, + ); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + async.elapse(WebSocketOptions.defaultConnectTimeout); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + tester.server.send({'type': 'connection.ok', 'connection_id': 'connection-id'}); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + loaded.complete(); + async.flushMicrotasks(); + + // The failure belongs to an attempt abandoned before this connection was opened. Reported + // against this one it closes a working connection as `AuthenticationFailed`, which is + // never retried. + expect(tester.connectionState, isA()); + }); + }); }); }); - group('StreamWebSocketClient.optionsBuilder', () { - test('is called for every connection attempt, not once per client', () async { - final (:client, :incoming, :optionsBuilt, sink: _) = _client(); + group('disconnect', () { + wsClientTest( + 'leaves the connection closed, not closing, once it returns', + body: (tester) async { + await tester.client.disconnect(); - await client.connect(); - expect(optionsBuilt(), 1); + // A caller that reconnects straight away would otherwise race the close. + expect(tester.connectionState, isA()); + }, + ); - await client.disconnect(); + wsClientTest( + 'reports the connection closed even when the socket close fails', + closeError: Exception('close failed'), + body: (tester) async { + await tester.client.disconnect(); - await client.connect(); - expect(optionsBuilt(), 2); - }); - }); + // The engine reports the failure rather than the closure, so the client is what moves this + // out of 'disconnecting'. + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + }, + ); - group('StreamWebSocketClient.onAuthenticate', () { - test('is called once the socket is open, while authenticating', () async { - WebSocketConnectionState? stateWhenCalled; - late StreamWebSocketClient client; - final built = _client( - onAuthenticate: (_) async { - stateWhenCalled = client.connectionState.value; - return const Result.success(null); - }, - ); - client = built.client; + wsClientTest( + 'leaves a client whose socket refused to close able to connect', + closeError: Exception('close failed'), + body: (tester) async { + await tester.client.disconnect(); - await client.connect(); - await pumpEventQueue(); + await tester.client.connect(); + await tester.pumpEventQueue(); - expect(stateWhenCalled, isA()); - }); + // A socket that refuses to close is not one the client can use, and holding on to it would + // refuse every later connect. + expect(tester.connectionState, isA()); + expect(tester.attempts, 2); + }, + ); - test('is called once per connection attempt', () async { - var calls = 0; - final (:client, :incoming, optionsBuilt: _, sink: _) = _client( - onAuthenticate: (_) async { - calls++; - return const Result.success(null); - }, - ); + wsClientTest( + 'does not open a socket while the previous one is still closing', + holdClose: true, + body: (tester) async { + tester.client.disconnect().ignore(); + expect(tester.connectionState, isA()); + + await tester.client.connect(); + + // The old socket's close event would otherwise bring the new connection down and disarm the + // timeout meant to be watching it. + expect(tester.attempts, 1); + expect(tester.connectionState, isA()); + tester.server.socket.sink.completeClose(); + }, + ); - await client.connect(); - await pumpEventQueue(); - expect(calls, 1); + wsClientTest( + 'does not refuse a connect made straight after it', + connect: (_) {}, + body: (tester) async { + // Not awaited, as a caller reasonably might not: there is no connection to close, so nothing + // should stand in the way of the connect that follows. + tester.client.disconnect().ignore(); + tester.client.connect().ignore(); + await tester.pumpEventQueue(); + + expect(tester.connectionState, isA()); + expect(tester.attempts, 1); + }, + ); - await client.disconnect(); + wsClientTest( + 'leaves a client that never connected able to connect', + connect: (_) {}, + body: (tester) async { + // Nothing was opened, so there is nothing to close and no closure to report. Reporting one + // would leave a connect made straight after it racing a close that never happened. + await tester.client.disconnect(); + expect(tester.connectionState, isA()); + + await tester.client.connect(); + await tester.pumpEventQueue(); + + expect(tester.connectionState, isA()); + expect(tester.attempts, 1); + }, + ); - await client.connect(); - await pumpEventQueue(); - expect(calls, 2); - }); + wsClientTest( + 'takes over a closure already under way', + holdClose: true, + body: (tester) async { + // A closure the client decided on, still in flight. + tester.client.disconnect(source: const UnHealthyConnection()).ignore(); + expect(tester.connectionState, isA()); - test('is handed a sender that puts the request on the socket', () async { - Result? sent; - final (:client, incoming: _, optionsBuilt: _, :sink) = _client( - onAuthenticate: (send) async => sent = send(const _PingRequest()), - ); + final disconnected = tester.client.disconnect(); + tester.server.socket.sink.completeClose(); + await disconnected; - await client.connect(); - await pumpEventQueue(); + // Recorded as the caller's, so nothing is reconnected after they asked to stop. + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + }, + ); - // The sender is only useful if it reaches the socket: an authenticator - // that cannot send has nothing to report but failure. - expect(sent, isA>()); - verify(() => sink.add(any())).called(1); - }); + wsClientTest( + 'takes over a closure the server already made', + body: (tester) async { + // The server hung up, which the client would reconnect after. + tester.server.hangUp(); + await tester.pumpEventQueue(); + expect(tester.connectionState, isA()); - test('leaves the connection authenticating when it succeeds', () async { - final (:client, :incoming, optionsBuilt: _, sink: _) = _client( - onAuthenticate: (_) async => const Result.success(null), - ); + await tester.client.disconnect(); - await client.connect(); - await pumpEventQueue(); + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + }, + ); - // Sending the credentials does not establish the connection; the server - // answering does. - expect(client.connectionState.value, isA()); + wsClientTest( + 'keeps the reason the caller gave when a server error arrives mid-close', + holdClose: true, + body: (tester) async { + tester.client.disconnect().ignore(); - client.onMessage(const _HealthCheckEvent()); - expect(client.connectionState.value, isA()); - }); + // A rate limit clears on its own, so the client would reconnect after a closure recorded for + // one. + await tester.emit(connectionErrorFrame(code: 9, statusCode: 429)); + tester.server.socket.sink.completeClose(); + await tester.pumpEventQueue(); - test('leaves the connection authenticating when there is no authenticator', () async { - // A socket that has nothing to send before it is usable, such as one whose - // protocol authenticates elsewhere. - final (:client, :incoming, optionsBuilt: _, sink: _) = _client(); + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + }, + ); - await client.connect(); - await pumpEventQueue(); + test('stands the health monitor down, so it cannot relabel the closure', () { + fakeAsync((async) { + final tester = buildTester(); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); - expect(client.connectionState.value, isA()); + // A live connection is being watched, so there is something to stand down. + expect(async.pendingTimers, isNotEmpty); - client.onMessage(const _HealthCheckEvent()); - expect(client.connectionState.value, isA()); + tester.client.disconnect().ignore(); + async.flushMicrotasks(); + + // Nothing is left to fire. A monitor still watching would report the connection unhealthy, + // which is reconnected where the caller's closure is not. + expect(async.pendingTimers, isEmpty); + + async.elapse(const Duration(minutes: 1)); + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + }); }); - }); - group('StreamWebSocketClient authentication failure', () { - test('closes the connection instead of waiting for a reply', () async { - final (:client, :incoming, optionsBuilt: _, sink: _) = _client( - onAuthenticate: (_) async => Result.failure(StateError('no token')), + group('dispose', () { + wsClientTest( + 'closes the connection and both emitters', + body: (tester) async { + await tester.client.dispose(); + + expect(tester.client.isDisposed, isTrue); + expect(tester.client.events.isClosed, isTrue); + expect(tester.client.connectionState.isClosed, isTrue); + }, + ); + + wsClientTest( + 'does nothing when called again', + body: (tester) async { + await tester.client.dispose(); + + await expectLater(tester.client.dispose(), completes); + }, ); - await client.connect(); - await pumpEventQueue(); - - final state = client.connectionState.value; - expect( - state, - isA().having( - (it) => it.source, - 'source', - isA().having((it) => it.error, 'error', isStateError), - ), + wsClientTest( + 'refuses to connect again', + body: (tester) async { + await tester.client.dispose(); + + // Throws rather than asserts, so a release build refuses too: the recovery handler is the + // one caller that does not await this, and it ignores the future. The untouched builder + // count pins that no socket was opened. + await expectLater(tester.client.connect(), throwsA(isA())); + expect(tester.attempts, 1); + }, ); }); + }); - test('closes the connection when the authenticator throws', () async { - // The natural authenticator awaits a token, and loading one throws rather - // than returning a failure. Left unguarded the error escapes unhandled and - // the connection waits for the timeout, which knows no cause. - final (:client, :sink, incoming: _, optionsBuilt: _) = _client( - onAuthenticate: (_) async => throw StateError('token load failed'), + group('the events it receives', () { + group('an event the client has no handling of', () { + wsClientTest( + 'reaches whoever is listening', + body: (tester) async { + final received = []; + final subscription = tester.client.events.listen(received.add); + addTearDown(subscription.cancel); + + await tester.emit({'type': 'activity.added'}); + + // Everything the client does not act on itself is an app's to act on, and an event it does + // not recognise must not be taken as a reason to end the connection. + expect(received, [isA().having((it) => it.type, 'type', 'activity.added')]); + expect(tester.connectionState, isA()); + }, ); + }); + + group('a health check', () { + wsClientTest( + 'is emitted as well as acted on', + body: (tester) async { + final received = []; + final subscription = tester.client.events.listen(received.add); + addTearDown(subscription.cancel); - await client.connect(); - await pumpEventQueue(); + await tester.emit({'type': 'health.check', 'connection_id': 'connection-id'}); - expect( - client.connectionState.value, - isA().having( - (it) => it.source, - 'source', - isA().having((it) => it.error, 'error', isStateError), - ), + // Handled and then passed on, so an app can react to a connection coming back. + expect(received, hasLength(1)); + expect(tester.connectionState, isA()); + }, ); }); - test('is not retried, since the same credentials would fail again', () async { - final (:client, :incoming, optionsBuilt: _, sink: _) = _client( - onAuthenticate: (_) async => Result.failure(StateError('no token')), + group('when the socket itself gives out', () { + wsClientTest( + 'reports the closure as the server ending it', + connect: (tester) async { + await tester.client.connect(); + await tester.pumpEventQueue(); + }, + body: (tester) async { + // Not a `connection.error` over a working socket: the socket is what failed. + tester.server.fail(StateError('socket died')); + await tester.pumpEventQueue(); + + expect( + tester.connectionState, + isA().having( + (it) => it.source, + 'source', + isA().having((it) => it.error?.error, 'error.error', isStateError), + ), + ); + }, ); - await client.connect(); - await pumpEventQueue(); + wsClientTest( + 'is reconnected, since nothing says the credentials were the problem', + recover: true, + body: (tester) async { + tester.server.fail(StateError('socket died')); + await tester.pumpEventQueue(); + + // A socket that gave out says nothing about the token, so this is a drop to recover from, + // and the token it presents again is the one it already had. + expect(tester.attempts, 2); + expect(tester.connectionState, isA()); + expect(tester.tokenLoads, 1); + }, + ); + }); - final state = client.connectionState.value; - expect(state, isA()); - expect(state.isAutomaticReconnectionEnabled, isFalse); + group('an error event the server sent', () { + wsClientTest( + 'closes the connection rather than being emitted', + body: (tester) async { + final received = []; + final subscription = tester.client.events.listen(received.add); + addTearDown(subscription.cancel); + + await tester.emit(expiredTokenFrame()); + + // The client acts on it; an app learns about it from the connection state. + expect(received, isEmpty); + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + }, + ); }); }); - group('StreamWebSocketClient connect timeout', () { + group('the connect timeout', () { test('abandons an attempt that never becomes connected', () { fakeAsync((async) { - final (:client, :incoming, optionsBuilt: _, sink: _) = _client(); + // A server that takes the credentials and never answers. + final tester = buildTester(); + tester.server.onFrame = (_) => []; - client.connect().ignore(); + tester.client.connect().ignore(); async.flushMicrotasks(); - // The socket opened, so the client is authenticating with nothing else - // watching it. - expect(client.connectionState.value, isA()); + // The socket opened, so the client is authenticating with nothing else watching it. + expect(tester.connectionState, isA()); // Still waiting a tick before the timeout is due. async.elapse(WebSocketOptions.defaultConnectTimeout - const Duration(seconds: 1)); - expect(client.connectionState.value, isA()); + expect(tester.connectionState, isA()); async.elapse(const Duration(seconds: 1)); - final state = client.connectionState.value; + final state = tester.connectionState; + expect(state, isA().having((it) => it.source, 'source', isA())); + + // An attempt abandoned here is retried: a first health check that never arrives is the same + // failure as one that stops arriving. + expect(state.isAutomaticReconnectionEnabled, isTrue); + }); + }); + + test('abandons an attempt whose socket never opens', () { + fakeAsync((async) { + // A handshake that hangs, so the attempt never leaves 'connecting'. + final tester = buildTester(handshakeHangs: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + async.elapse(WebSocketOptions.defaultConnectTimeout); + expect( - state, + tester.connectionState, isA().having((it) => it.source, 'source', isA()), ); - - // An attempt abandoned here is retried: a first health check that never - // arrives is the same failure as one that stops arriving. - expect(state.isAutomaticReconnectionEnabled, isTrue); }); }); test('abandons an attempt whose authenticator never returns', () { fakeAsync((async) { - // The realistic hang: an authenticator awaiting something that never - // resolves. Nothing else watches 'authenticating', so only this fires. - final (:client, incoming: _, optionsBuilt: _, sink: _) = _client( - onAuthenticate: (_) => Completer>().future, + // An authenticator awaiting something that never resolves. Nothing else watches + // 'authenticating', so only this fires. + final tester = buildTester( + authenticator: (_, _) => Completer().future, ); - client.connect().ignore(); + tester.client.connect().ignore(); async.flushMicrotasks(); - expect(client.connectionState.value, isA()); + expect(tester.connectionState, isA()); async.elapse(WebSocketOptions.defaultConnectTimeout); expect( - client.connectionState.value, + tester.connectionState, isA().having((it) => it.source, 'source', isA()), ); }); }); - test('is armed again for a later attempt', () { + test('times out a later attempt too', () { fakeAsync((async) { - final (:client, incoming: _, optionsBuilt: _, sink: _) = _client(); + final tester = buildTester(); + tester.server.onFrame = (_) => []; - client.connect().ignore(); + tester.client.connect().ignore(); async.flushMicrotasks(); async.elapse(WebSocketOptions.defaultConnectTimeout); - expect(client.connectionState.value, isA()); + expect(tester.connectionState, isA()); - client.connect().ignore(); + tester.client.connect().ignore(); async.flushMicrotasks(); async.elapse(WebSocketOptions.defaultConnectTimeout); expect( - client.connectionState.value, + tester.connectionState, isA().having((it) => it.source, 'source', isA()), ); }); @@ -467,137 +795,353 @@ void main() { test('honours a timeout given in the options', () { fakeAsync((async) { - final (:client, :incoming, optionsBuilt: _, sink: _) = _client( - connectTimeout: const Duration(seconds: 2), - ); + final tester = buildTester(connectTimeout: const Duration(seconds: 2)); + tester.server.onFrame = (_) => []; - client.connect().ignore(); + tester.client.connect().ignore(); async.flushMicrotasks(); async.elapse(const Duration(seconds: 2)); expect( - client.connectionState.value, + tester.connectionState, isA().having((it) => it.source, 'source', isA()), ); }); }); - test('does not fire once the connection is established', () { + test('does not time out once the connection is established', () { fakeAsync((async) { - final (:client, :sink, incoming: _, optionsBuilt: _) = _client(); - - // A live connection answers its pings, which is what keeps the health - // monitor quiet across a connection that outlives this timeout. The - // answer arrives over the wire, so it lands after the monitor has armed - // its pong timeout rather than inside the send that triggered it. - when(() => sink.add(any())).thenAnswer((_) { - Timer(const Duration(milliseconds: 50), () { - client.onMessage(const _HealthCheckEvent()); - }); - }); + // The default server answers every ping, which is what keeps a live connection alive. + final tester = buildTester(); - client.connect().ignore(); + tester.client.connect().ignore(); async.flushMicrotasks(); - client.onMessage(const _HealthCheckEvent()); - expect(client.connectionState.value, isA()); + expect(tester.connectionState, isA()); - // Past the timeout, and past a ping cycle with it. - async.elapse(WebSocketOptions.defaultConnectTimeout + const Duration(seconds: 10)); + // Past the timeout, and past several ping cycles with it. + async.elapse(WebSocketOptions.defaultConnectTimeout + const Duration(seconds: 60)); - expect(client.connectionState.value, isA()); + expect(tester.connectionState, isA()); }); }); - test('does not replace the source of a socket error that came first', () { + test('does not replace the source of a closure that came first', () { fakeAsync((async) { - final (:client, incoming: _, optionsBuilt: _, sink: _) = _client(); + final tester = buildTester(); + tester.server.onFrame = (_) => []; - client.connect().ignore(); + tester.client.connect().ignore(); async.flushMicrotasks(); - // A socket error closes the connection without cancelling the timer. - client.onError(StateError('socket died')); - expect(client.connectionState.value, isA()); + // The server refuses and hangs up while the attempt is still being timed. + tester.server.send(expiredTokenFrame()); + async.flushMicrotasks(); async.elapse(WebSocketOptions.defaultConnectTimeout * 2); - // The peer closes after sending the error, as the socket protocol has - // it, which is what turns the state into a disconnection. - client.onClose(); - - // Replacing this source with `ConnectTimeout` used to make a - // reconnectable failure permanent. - final state = client.connectionState.value; - expect( - state, - isA().having((it) => it.source, 'source', isA()), - ); + // The source of the closure already under way wins: `ConnectTimeout` would say the server + // never answered, when it answered with a refusal an authenticator can act on. + final state = tester.connectionState; + expect(state, isA().having((it) => it.source, 'source', isA())); expect(state.isAutomaticReconnectionEnabled, isTrue); }); }); test('does not replace the source of a disconnect that came first', () { fakeAsync((async) { - final (:client, :incoming, optionsBuilt: _, sink: _) = _client(); + final tester = buildTester(); + tester.server.onFrame = (_) => []; - client.connect().ignore(); + tester.client.connect().ignore(); async.flushMicrotasks(); - client.disconnect().ignore(); + tester.client.disconnect().ignore(); async.flushMicrotasks(); async.elapse(WebSocketOptions.defaultConnectTimeout * 2); - // The timeout would otherwise report this deliberate disconnect as a - // timed-out attempt, which is retried where this is not. + // The timeout would otherwise report this deliberate disconnect as a timed-out attempt, + // which is retried where this is not. expect( - client.connectionState.value, + tester.connectionState, isA().having((it) => it.source, 'source', isA()), ); }); }); + + test('releases the timeout of an attempt in flight when disposed', () { + fakeAsync((async) { + final tester = buildTester(handshakeHangs: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + tester.client.dispose().ignore(); + async.flushMicrotasks(); + + // A timeout left armed keeps a disposed client alive for as long as it has left to run. + expect(async.pendingTimers, isEmpty); + }); + }); + + group('when a health check arrives while disconnecting', () { + wsClientTest( + 'does not report the connection as established again', + holdClose: true, + body: (tester) async { + tester.client.disconnect().ignore(); + expect(tester.connectionState, isA()); + + // Arrives before the socket finished closing. + await tester.emit({'type': 'connection.ok', 'connection_id': 'late'}); + + expect(tester.connectionState, isA()); + tester.server.socket.sink.completeClose(); + }, + ); + + wsClientTest( + 'leaves the disconnection source intact once the socket closes', + holdClose: true, + body: (tester) async { + tester.client.disconnect().ignore(); + await tester.emit({'type': 'connection.ok', 'connection_id': 'late'}); + + tester.server.socket.sink.completeClose(); + await tester.pumpEventQueue(); + + // A late health check must not move the state back to connected: the closure would then be + // reported as server-initiated, which is eligible for a reconnect. + final state = tester.connectionState; + expect(state, isA().having((it) => it.source, 'source', isA())); + expect(state.isAutomaticReconnectionEnabled, isFalse); + }, + ); + }); }); - group('StreamWebSocketClient health check while disconnecting', () { - test('does not report the connection as established again', () async { - final (:client, :sink, incoming: _, optionsBuilt: _) = _client(); - // Held open so the connection is still closing when the pong arrives. - final closing = Completer(); - when(() => sink.close(any(), any())).thenAnswer((_) => closing.future); + // The client, its authentication handler, its health monitor and its recovery handler all take + // part, so these cover the loop rather than any one of them: the server refuses, the refusal + // reaches the next attempt, that attempt presents something else, and the connection comes back + // without the app being told anything. + group('the token behind the connection', () { + // The handshake a consuming SDK performs, which acts on `previousError`: an expired token is + // dropped so the provider can issue another, and a provider with nothing else to give declines + // rather than presenting the same token again. + WebSocketAuthenticator authenticatorFor(TokenManager tokens) { + return (send, previousError) async { + if (previousError?.isTokenExpiredError ?? false) { + tokens.expireToken(); + if (tokens.usesStaticProvider) { + throw ClientException(message: 'The token was refused and the provider has no other to give'); + } + } + + final token = await tokens.getToken(); + send(WsAuthMessageRequest(token: token.rawValue)).getOrThrow(); + }; + } + + test('is still answered for by the attempt after it, when the user has not changed', () { + fakeAsync((async) { + final asked = []; + final tokens = TokenManager( + userId: 'user-1', + tokenProvider: TokenProvider.dynamic((id) async => generateTestUserToken(id)), + ); + final tester = buildTester( + tokens: tokens, + authenticator: (send, previousError) async { + asked.add(previousError); + send(WsAuthMessageRequest(token: (await tokens.getToken()).rawValue)).getOrThrow(); + }, + ); + tester.server.onFrame = (_) => [ + {'type': 'connection.ok', 'connection_id': 'connection-id'}, + ]; + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + tester.server.send(expiredTokenFrame()); + async.flushMicrotasks(); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + // Nothing replaced the credentials, so the refusal still describes what this attempt holds + // and forgetting it would leave the same token offered again. + expect(asked, [null, isA().having((it) => it.code, 'code', 40)]); + }); + }); + + test('is not answered for with a refusal recorded against the user before it', () { + fakeAsync((async) { + final tokens = TokenManager( + userId: 'user-1', + tokenProvider: TokenProvider.dynamic((id) async => generateTestUserToken(id)), + ); + final tester = buildTester(tokens: tokens, authenticator: authenticatorFor(tokens)); + // Whose token it is, is the server's business elsewhere; this is about what the client does. + tester.server.onFrame = (_) => [ + {'type': 'connection.ok', 'connection_id': 'connection-id'}, + ]; + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + // The server refuses the token. Nothing retries it, so the refusal is still armed. + tester.server.send(expiredTokenFrame()); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + // The app takes connecting back, then signs a different user in: a new identity, on a + // provider with one token to give, as a guest exchange produces. + tester.client.disconnect().ignore(); + async.flushMicrotasks(); + tokens.setTokenProvider('guest-1', tokenProvider: TokenProvider.static(generateTestUserToken('guest-1'))); - await client.connect(); - client.onMessage(const _HealthCheckEvent()); - expect(client.connectionState.value, isA()); + tester.client.connect().ignore(); + async.flushMicrotasks(); - client.disconnect().ignore(); - expect(client.connectionState.value, isA()); + // The refusal was about the user before this one. Handed on, it makes an authenticator drop + // credentials that were never refused and decline a connection that would have been served. + expect(tester.connectionState, isA()); + expect(tokens.peekToken(), isNotNull); + }); + }); - // Arrives before the socket finished closing. - client.onMessage(const _HealthCheckEvent(connectionId: 'late')); + test('comes back with a fresh token after the server refuses an expired one', () { + fakeAsync((async) { + final tester = buildTester(recover: true); - expect(client.connectionState.value, isA()); - closing.complete(); + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + expect(tester.tokenLoads, 1); + + // The server refuses the token of a connection that was working. + tester.server.send(expiredTokenFrame()); + async.flushMicrotasks(); + async.elapse(Duration.zero); + + // Reconnecting is the recovery handler's, and the token it presents was loaded after the + // refusal. The app is told nothing and does nothing. + expect(tester.connectionState, isA()); + expect(tester.tokenLoads, 2); + expect(tester.states.whereType(), hasLength(2)); + }); }); - test('leaves the disconnection source intact once the socket closes', () async { - final (:client, :sink, incoming: _, optionsBuilt: _) = _client(); - final closing = Completer(); - when(() => sink.close(any(), any())).thenAnswer((_) => closing.future); - - await client.connect(); - client.onMessage(const _HealthCheckEvent()); - client.disconnect().ignore(); - client.onMessage(const _HealthCheckEvent(connectionId: 'late')); - - closing.complete(); - await pumpEventQueue(); - - // Without the guard the late health check moves the state back to - // connected, and `onClose` then reports a server-initiated disconnect, - // which is eligible for an automatic reconnect. - final state = client.connectionState.value; - expect(state, isA().having((it) => it.source, 'source', isA())); - expect(state.isAutomaticReconnectionEnabled, isFalse); + test('presents the replacement, not the token that was refused', () { + fakeAsync((async) { + var issued = 0; + final tester = buildTester( + recover: true, + // A backend handing out a distinguishable token each time. The user has to stay the same — + // a manager configured for one user refuses to cache another's token — so these differ by + // the nonce they carry. + tokenLoader: (userId) async => generateTestUserToken(userId, nonce: '${++issued}'), + ); + // The server accepts any token, so what matters is which one arrives second. + tester.server.onFrame = (_) => [ + {'type': 'connection.ok', 'connection_id': 'connection-id'}, + ]; + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + tester.server.send(expiredTokenFrame()); + async.flushMicrotasks(); + async.elapse(Duration.zero); + + final presented = tester.server.received.map((it) => it['token']).toList(); + expect(presented, hasLength(2)); + + // Offering the refused token again would be refused again, for the life of the client. + expect(presented[1], isNot(presented[0])); + }); + }); + + test('keeps the token when the server closed for a reason that was not about it', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.tokenLoads, 1); + + // A server error that says nothing about the token, and is retried like any other. Dropping + // the token here would send the reconnect to the provider for one that was never refused. + tester.server.send(connectionErrorFrame(code: 5, statusCode: 500)); + async.flushMicrotasks(); + async.elapse(Duration.zero); + + expect(tester.connectionState, isA()); + expect(tester.tokenLoads, 1); + }); + }); + + test('keeps the token across a disconnect the caller asked for', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.tokenLoads, 1); + + // A deliberate disconnect says nothing about the token. Dropping it here would send every + // reconnect to the provider for a token that was never refused. + tester.client.disconnect().ignore(); + async.flushMicrotasks(); + tester.client.connect().ignore(); + async.flushMicrotasks(); + + expect(tester.connectionState, isA()); + expect(tester.tokenLoads, 1); + }); + }); + + test('keeps the token across a connection that stopped answering', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.tokenLoads, 1); + + // A connection that goes quiet says nothing about the token either. + tester.server.onFrame = (frame) => switch (frame['type']) { + 'health.check' => const [], + _ => [ + {'type': 'connection.ok', 'connection_id': 'connection-id'}, + ], + }; + async.elapse(const Duration(seconds: 29)); + async.flushMicrotasks(); + + expect(tester.states.whereType(), hasLength(2)); + expect(tester.tokenLoads, 1); + }); + }); + + test('stays closed when no other token would be accepted', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + // A signature signed with the wrong secret: another token from the same provider carries the + // same problem, so retrying would offer refused credentials for the life of the client. + tester.server.send(invalidSignatureFrame()); + async.flushMicrotasks(); + async.elapse(const Duration(minutes: 1)); + + expect(tester.connectionState, isA()); + expect(tester.tokenLoads, 1); + expect(tester.attempts, 1); + }); }); }); } diff --git a/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart b/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart new file mode 100644 index 00000000..9afd3f94 --- /dev/null +++ b/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart @@ -0,0 +1,297 @@ +import 'dart:async'; + +import 'package:stream_core/src/ws/client/web_socket_authentication_handler.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +final class _PingRequest extends WsRequest { + const _PingRequest(); + + @override + Map toJson() => const {}; + + @override + List get props => const []; +} + +StreamApiError _apiError({ + required int code, + int statusCode = 401, +}) => StreamApiError( + code: code, + details: const [], + duration: '0ms', + message: 'error $code', + moreInfo: '', + statusCode: statusCode, +); + +final _expiredToken = _apiError(code: 40); + +Disconnected _serverClosure(StreamApiError? apiError) => Disconnected( + source: ServerInitiated(error: WebSocketEngineException(error: apiError)), +); + +/// Builds a handler, along with the errors it handed the authenticator and the failures it reported. +({ + WebSocketAuthenticationHandler authentication, + List asked, + List failures, +}) +_subject({WebSocketAuthenticator? authenticator}) { + final asked = []; + final failures = []; + + final authentication = WebSocketAuthenticationHandler( + authenticator: + authenticator ?? + (send, previousError) async { + asked.add(previousError); + send(const _PingRequest()).getOrThrow(); + }, + send: (_) => const Result.success(null), + onFailure: failures.add, + ); + + return (authentication: authentication, asked: asked, failures: failures); +} + +void main() { + test('previousError holds what the server closed the connection with', () { + final (:authentication, asked: _, failures: _) = _subject(); + + authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); + + expect(authentication.previousError, _expiredToken); + }); + + test('previousError survives a closure the server did not explain', () { + final (:authentication, asked: _, failures: _) = _subject(); + authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); + + // A socket that fails to open closes without an error. The earlier refusal still applies, + // because no new credentials were sent. + authentication.onConnectionStateChanged(_serverClosure(null)); + + expect(authentication.previousError, _expiredToken); + }); + + test('previousError is cleared once a connection has been established', () { + final (:authentication, asked: _, failures: _) = _subject(); + authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); + + authentication.onConnectionStateChanged( + const Connected(healthCheck: HealthCheckInfo(connectionId: 'connection-id')), + ); + + // The connection succeeded, so there is no refusal to report. + expect(authentication.previousError, isNull); + }); + + test('previousError is forgotten once the caller has disconnected', () { + final (:authentication, asked: _, failures: _) = _subject(); + authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); + + // A caller that disconnects takes connecting back, and what they connect with next is theirs to + // decide: another user, for one, whose credentials this refusal says nothing about. + // + // The cost is a connect made straight afterwards with credentials that are still spent: it is + // refused, and since a caller's disconnect hands connecting back to them, nothing retries it + // either. That attempt records the refusal afresh, so the connect after it is told and succeeds. + authentication.onConnectionStateChanged( + const Disconnected(source: DisconnectionSource.userInitiated()), + ); + + expect(authentication.previousError, isNull); + }); + + test('previousError is taken by the attempt that reads it, not left for the next', () async { + final (:authentication, :asked, failures: _) = _subject(); + authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); + + await authentication.authenticate(); + await authentication.authenticate(); + + // Left behind, the refusal would reach a later attempt that it says nothing about. + expect(asked, [_expiredToken, null]); + expect(authentication.previousError, isNull); + }); + + test('previousError survives the states an attempt passes through', () { + final (:authentication, asked: _, failures: _) = _subject(); + authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); + + // The error must survive until the retry actually authenticates. + authentication.onConnectionStateChanged(const Connecting()); + authentication.onConnectionStateChanged(const Authenticating()); + + expect(authentication.previousError, _expiredToken); + }); + + test('authenticate hands the previous error to the authenticator', () async { + final (:authentication, :asked, failures: _) = _subject(); + + await authentication.authenticate(); + authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); + await authentication.authenticate(); + + expect(asked, [null, _expiredToken]); + }); + + test('authenticate does nothing without an authenticator', () async { + final authentication = WebSocketAuthenticationHandler( + authenticator: null, + send: (_) => const Result.success(null), + onFailure: (_) => fail('nothing to authenticate, so nothing can fail'), + ); + + await expectLater(authentication.authenticate(), completes); + }); + + test('authenticate reports an error the authenticator threw, rather than letting it escape', () async { + final (:authentication, asked: _, :failures) = _subject( + // Loading a token throws, and nothing awaits `authenticate`, so the error would otherwise + // escape unhandled. + authenticator: (_, _) async => throw StateError('token load failed'), + ); + + await authentication.authenticate(); + + expect(failures, [isStateError]); + }); + + test('previousError keeps a refusal the server sent while an attempt was authenticating', () async { + final loaded = Completer(); + final authentication = WebSocketAuthenticationHandler( + authenticator: (_, _) => loaded.future, + send: (_) => const Result.success(null), + onFailure: (_) => fail('the credentials went out'), + ); + + authentication.onConnectionStateChanged(const Connecting()); + final authenticating = authentication.authenticate(); + + // Refused while this attempt was still running, so it is not the refusal this attempt read and + // it has yet to be answered. + authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); + + loaded.complete(); + await authenticating; + + expect(authentication.previousError, _expiredToken); + }); + + group('when another attempt has begun', () { + test('sends nothing over the connection that replaced the one it belongs to', () async { + final loaded = Completer(); + final sent = []; + Result? outcome; + + final authentication = WebSocketAuthenticationHandler( + authenticator: (send, _) async { + await loaded.future; + outcome = send(const _PingRequest()); + }, + send: (request) { + sent.add(request); + return const Result.success(null); + }, + onFailure: (_) => fail('the credentials were never offered, so nothing failed to go out'), + ); + + authentication.onConnectionStateChanged(const Connecting()); + final authenticating = authentication.authenticate(); + + // Abandoned while its credentials were still loading, and replaced. + authentication.onConnectionStateChanged( + const Disconnected(source: DisconnectionSource.connectTimeout()), + ); + authentication.onConnectionStateChanged(const Connecting()); + + loaded.complete(); + await authenticating; + + expect(sent, isEmpty); + expect(outcome, isA()); + }); + + test('reports no failure against the connection that replaced the one it belongs to', () async { + final loaded = Completer(); + final failures = []; + + final authentication = WebSocketAuthenticationHandler( + authenticator: (_, _) async { + await loaded.future; + throw StateError('token load failed'); + }, + send: (_) => const Result.success(null), + onFailure: failures.add, + ); + + authentication.onConnectionStateChanged(const Connecting()); + final authenticating = authentication.authenticate(); + + authentication.onConnectionStateChanged( + const Disconnected(source: DisconnectionSource.connectTimeout()), + ); + authentication.onConnectionStateChanged(const Connecting()); + + loaded.complete(); + await authenticating; + + // Reported against the attempt that replaced it, this would close a connection that + // authenticated fine as `AuthenticationFailed`, which is never reconnected. + expect(failures, isEmpty); + }); + + test('leaves the refusal for the attempt that replaces it', () async { + final loaded = Completer(); + final asked = []; + var calls = 0; + + final authentication = WebSocketAuthenticationHandler( + authenticator: (send, previousError) async { + asked.add(previousError); + if (++calls > 1) return; + await loaded.future; + }, + send: (_) => const Result.success(null), + onFailure: (_) {}, + ); + + authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); + authentication.onConnectionStateChanged(const Connecting()); + final abandoned = authentication.authenticate(); + + authentication.onConnectionStateChanged( + const Disconnected(source: DisconnectionSource.connectTimeout()), + ); + authentication.onConnectionStateChanged(const Connecting()); + await authentication.authenticate(); + + loaded.complete(); + await abandoned; + + // The abandoned attempt never sent anything, so the refusal still applies to the credentials + // in place, and belongs to the attempt that can actually answer it. + expect(asked, [_expiredToken, _expiredToken]); + }); + + test('still reports a failure for the attempt in flight', () async { + final failures = []; + + final authentication = WebSocketAuthenticationHandler( + authenticator: (_, _) async => throw StateError('token load failed'), + send: (_) => const Result.success(null), + onFailure: failures.add, + ); + + authentication.onConnectionStateChanged(const Connecting()); + authentication.onConnectionStateChanged(const Authenticating()); + + await authentication.authenticate(); + + expect(failures, [isStateError]); + }); + }); +} diff --git a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart index 5219159d..be9e1c9b 100644 --- a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart @@ -12,124 +12,119 @@ StreamApiError _apiError(int code, {int statusCode = 401}) => StreamApiError( Disconnected _serverDisconnect(StreamApiError apiError) => Disconnected( source: ServerInitiated( - error: WebSocketEngineException( - reason: apiError.message, - code: 4001, - error: apiError, - ), + error: WebSocketEngineException(reason: apiError.message, code: 4001, error: apiError), ), ); +const _healthCheck = HealthCheckInfo(connectionId: 'connection-id'); + +/// Every state a connection passes through, other than [except]. +Iterable _everyStateBut(WebSocketConnectionState except) { + return const [ + Initialized(), + Connecting(), + Authenticating(), + Connected(healthCheck: _healthCheck), + Disconnecting(source: UserInitiated()), + Disconnected(source: UserInitiated()), + ].where((it) => it != except); +} + void main() { - group('WebSocketConnectionState.isAutomaticReconnectionEnabled', () { - test( - 'is disabled when the token has expired, since a retry here would present ' - 'the same one', - () { - // 40 = expired. Replacing it is the caller's to do, and it is the caller - // that retries — `isExpiredTokenDisconnection` is how they are told. - final state = _serverDisconnect(_apiError(40)); - - expect(state.isAutomaticReconnectionEnabled, isFalse); - expect(state.isExpiredTokenDisconnection, isTrue); - }, - ); + test('automatic reconnection is enabled when the token has expired, since the next attempt loads another', () { + // 40 is an expired token, the one token error that fixes itself, because a fresh token is + // loaded before the next attempt authenticates. + final state = _serverDisconnect(_apiError(40)); + + expect(state.isAutomaticReconnectionEnabled, isTrue); + }); + + test('automatic reconnection is disabled when another token would be refused too', () { + // 41 not valid yet, 42 used before issued, 43 wrong secret, 2 wrong API key. A fresh token + // repairs none of them. + for (final code in [41, 42, 43, 2]) { + expect(_serverDisconnect(_apiError(code)).isAutomaticReconnectionEnabled, isFalse, reason: 'code $code'); + } + }); + + test('automatic reconnection is enabled when the request was rate limited', () { + // 9 is a rate limit, sent as 429, which clears on its own without the caller doing anything. + final state = _serverDisconnect(_apiError(9, statusCode: 429)); - test('is disabled when another token would be refused too', () { - // 41 not valid yet, 42 used before issued, 43 signed with the wrong - // secret, 2 wrong API key — none of which a fresh token repairs. - for (final code in [41, 42, 43, 2]) { - expect( - _serverDisconnect(_apiError(code)).isAutomaticReconnectionEnabled, - isFalse, - reason: 'code $code', - ); - } - }); - - test('is enabled when the request was rate limited', () { - // 9 = rate limited, sent as 429. The server closes with the window's reset - // in the response headers, so the condition clears without the caller - // doing anything. - final state = _serverDisconnect(_apiError(9, statusCode: 429)); - - expect(state.isAutomaticReconnectionEnabled, isTrue); - }); - - test('is disabled for any other client error', () { - // 17 = not allowed. Nothing about retrying changes the answer. - final state = _serverDisconnect(_apiError(17, statusCode: 403)); - - expect(state.isAutomaticReconnectionEnabled, isFalse); - }); - - test('is enabled for a server-side failure', () { - // Stream error codes never fall in 400..499, so this is classified by the - // status code alone — which is what the ported rule got wrong. - final state = _serverDisconnect(_apiError(9, statusCode: 500)); - - expect(state.isAutomaticReconnectionEnabled, isTrue); - }); - - test('is disabled when the socket was closed deliberately', () { - const state = Disconnected( - source: ServerInitiated( - error: WebSocketEngineException( - code: CloseCode.normalClosure, - ), - ), - ); - - expect(state.isAutomaticReconnectionEnabled, isFalse); - }); - - test('is enabled when the server closed without saying why', () { - const state = Disconnected(source: ServerInitiated()); - - expect(state.isAutomaticReconnectionEnabled, isTrue); - }); - - test( - 'is disabled when a connection could not be authenticated, since the ' - 'same credentials would fail again', - () { - const state = Disconnected(source: AuthenticationFailed(error: 'no token')); - - expect(state.isAutomaticReconnectionEnabled, isFalse); - }, + expect(state.isAutomaticReconnectionEnabled, isTrue); + }); + + test('automatic reconnection is disabled for any other client error', () { + // 17 is not allowed, and retrying does not change the answer. + final state = _serverDisconnect(_apiError(17, statusCode: 403)); + + expect(state.isAutomaticReconnectionEnabled, isFalse); + }); + + test('automatic reconnection is enabled for a server-side failure', () { + // Stream error codes never fall in 400..499, so this is classified by the status code alone. + final state = _serverDisconnect(_apiError(9, statusCode: 500)); + + expect(state.isAutomaticReconnectionEnabled, isTrue); + }); + + test('automatic reconnection is disabled when the socket was closed deliberately', () { + const state = Disconnected( + source: ServerInitiated(error: WebSocketEngineException(code: CloseCode.normalClosure)), ); - test('is enabled when a connected socket stops answering health checks', () { - const state = Disconnected(source: UnHealthyConnection()); + expect(state.isAutomaticReconnectionEnabled, isFalse); + }); + + test('automatic reconnection is enabled when the server closed without saying why', () { + const state = Disconnected(source: ServerInitiated()); + + expect(state.isAutomaticReconnectionEnabled, isTrue); + }); + + test('automatic reconnection is disabled when a connection could not be authenticated, since the ' + 'same credentials would fail again', () { + const state = Disconnected(source: AuthenticationFailed(error: 'no token')); + + expect(state.isAutomaticReconnectionEnabled, isFalse); + }); + + test('automatic reconnection is enabled when a connected socket stops answering health checks', () { + const state = Disconnected(source: UnHealthyConnection()); - expect(state.isAutomaticReconnectionEnabled, isTrue); - }); + expect(state.isAutomaticReconnectionEnabled, isTrue); }); - group('DisconnectionSource.closeReason', () { - test('reads differently for every source', () { - const sources = [ - UserInitiated(), - ServerInitiated(), - SystemInitiated(), - UnHealthyConnection(), - ConnectTimeout(), - AuthenticationFailed(error: 'no token'), - ]; - - final reasons = sources.map((it) => it.closeReason).toSet(); - - // A shared reason would report two different outcomes identically. - expect(reasons, hasLength(sources.length)); - }); + test('closeReason reads differently for every source', () { + const sources = [ + UserInitiated(), + ServerInitiated(), + SystemInitiated(), + UnHealthyConnection(), + ConnectTimeout(), + AuthenticationFailed(error: 'no token'), + ]; + + final reasons = sources.map((it) => it.closeReason).toSet(); + + // A shared reason would report two different outcomes identically. + expect(reasons, hasLength(sources.length)); + }); + + test('a connection is active until it is closed', () { + // A connection being made, or being closed, is still one an app must not open another over. + for (final state in _everyStateBut(const Disconnected(source: UserInitiated()))) { + expect(state.isActive, isTrue, reason: '$state'); + } + + expect(const Disconnected(source: UserInitiated()).isActive, isFalse); }); - group('WebSocketOptions.defaultConnectTimeout', () { - test('is the timeout used when the options do not say', () { - const options = WebSocketOptions(url: 'wss://example.com'); + test('a connection is connected only once it is established', () { + expect(const Connected(healthCheck: _healthCheck).isConnected, isTrue); - expect(options.connectTimeout, WebSocketOptions.defaultConnectTimeout); - expect(WebSocketOptions.defaultConnectTimeout, const Duration(seconds: 30)); - }); + for (final state in _everyStateBut(const Connected(healthCheck: _healthCheck))) { + expect(state.isConnected, isFalse, reason: '$state'); + } }); } diff --git a/packages/stream_core/test/ws/client/web_socket_health_monitor_test.dart b/packages/stream_core/test/ws/client/web_socket_health_monitor_test.dart new file mode 100644 index 00000000..c113240e --- /dev/null +++ b/packages/stream_core/test/ws/client/web_socket_health_monitor_test.dart @@ -0,0 +1,224 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +/// Records what the monitor asked of the connection it is watching. +class _Listener implements WebSocketHealthListener { + var _pings = 0; + var _unhealthy = 0; + + /// How many times the monitor asked for a ping to be sent. + int get pings => _pings; + + /// How many times it reported the connection unhealthy. + int get unhealthy => _unhealthy; + + @override + void onPingRequested() => _pings++; + + @override + void onUnhealthy() => _unhealthy++; +} + +// Deliberately not the defaults, so these also pin that the monitor uses what it was given. +const _pingInterval = Duration(seconds: 10); +const _timeout = Duration(seconds: 2); + +({WebSocketHealthMonitor monitor, _Listener listener}) _subject() { + final listener = _Listener(); + return ( + monitor: WebSocketHealthMonitor( + listener: listener, + pingInterval: _pingInterval, + timeoutThreshold: _timeout, + ), + listener: listener, + ); +} + +void main() { + test('asks for nothing until it is started', () { + fakeAsync((async) { + final (monitor: _, :listener) = _subject(); + + async.elapse(_pingInterval * 5); + + expect(listener.pings, 0); + expect(listener.unhealthy, 0); + }); + }); + + test('waits out the interval before the first ping', () { + fakeAsync((async) { + final (:monitor, :listener) = _subject(); + monitor.start(); + + // A connection that has just answered does not need asking again straight away. + async.elapse(_pingInterval - const Duration(seconds: 1)); + expect(listener.pings, 0); + + async.elapse(const Duration(seconds: 1)); + expect(listener.pings, 1); + + monitor.stop(); + }); + }); + + test('keeps asking for as long as the answers keep coming', () { + fakeAsync((async) { + final (:monitor, :listener) = _subject(); + monitor.start(); + + for (var i = 0; i < 4; i++) { + async.elapse(_pingInterval); + monitor.onPongReceived(); + } + + expect(listener.pings, 4); + expect(listener.unhealthy, 0); + + monitor.stop(); + }); + }); + + test('reports the connection unhealthy when an answer does not arrive', () { + fakeAsync((async) { + final (:monitor, :listener) = _subject(); + monitor.start(); + + async.elapse(_pingInterval); + expect(listener.pings, 1); + expect(listener.unhealthy, 0); + + // Still inside the window the peer has to answer in. + async.elapse(_timeout - const Duration(seconds: 1)); + expect(listener.unhealthy, 0); + + async.elapse(const Duration(seconds: 1)); + expect(listener.unhealthy, 1); + + monitor.stop(); + }); + }); + + test('holds off on the verdict while answers arrive in time', () { + fakeAsync((async) { + final (:monitor, :listener) = _subject(); + monitor.start(); + + // An answer that lands just inside the window, as a slow connection gives. + async.elapse(_pingInterval); + async.elapse(_timeout - const Duration(milliseconds: 1)); + monitor.onPongReceived(); + + async.elapse(const Duration(seconds: 1)); + expect(listener.unhealthy, 0); + + monitor.stop(); + }); + }); + + test('asks for nothing once it is stopped', () { + fakeAsync((async) { + final (:monitor, :listener) = _subject(); + monitor.start(); + async.elapse(_pingInterval); + + monitor.stop(); + async.elapse(_pingInterval * 5); + + // The one ping from before it stopped, and nothing after. + expect(listener.pings, 1); + expect(async.pendingTimers, isEmpty); + }); + }); + + test('does not deliver a verdict it had already reached when stopped', () { + fakeAsync((async) { + final (:monitor, :listener) = _subject(); + monitor.start(); + + // A ping went unanswered, so the verdict is pending. + async.elapse(_pingInterval); + monitor.stop(); + + async.elapse(_timeout * 2); + + // Reported after the connection was closed, this would reopen one nobody asked for. + expect(listener.unhealthy, 0); + }); + }); + + group('following the connection state', () { + test('starts watching once a connection is established', () { + fakeAsync((async) { + final (:monitor, :listener) = _subject(); + + monitor.onConnectionStateChanged( + const Connected(healthCheck: HealthCheckInfo(connectionId: 'connection-id')), + ); + async.elapse(_pingInterval); + + expect(listener.pings, 1); + + monitor.stop(); + }); + }); + + test('stops watching once it is not', () { + fakeAsync((async) { + final (:monitor, :listener) = _subject(); + monitor.onConnectionStateChanged( + const Connected(healthCheck: HealthCheckInfo(connectionId: 'connection-id')), + ); + + monitor.onConnectionStateChanged(const Disconnected(source: UserInitiated())); + async.elapse(_pingInterval * 3); + + expect(listener.pings, 0); + expect(async.pendingTimers, isEmpty); + }); + }); + + test('watches nothing while a connection is still being made', () { + fakeAsync((async) { + final (:monitor, :listener) = _subject(); + + // The connect timeout is what bounds an attempt; pinging a socket that has not been + // accepted yet would report it unhealthy before it ever had a chance. + monitor.onConnectionStateChanged(const Connecting()); + monitor.onConnectionStateChanged(const Authenticating()); + async.elapse(_pingInterval * 2); + + expect(listener.pings, 0); + }); + }); + + test('keeps a fixed cadence when an answer arrives mid-interval', () { + fakeAsync((async) { + final (:monitor, :listener) = _subject(); + monitor.onConnectionStateChanged( + const Connected(healthCheck: HealthCheckInfo(connectionId: 'connection-id')), + ); + + async.elapse(_pingInterval * 0.6); + expect(listener.pings, 0); + + // Every answer arrives as another `Connected`, which asks the monitor to start again. + monitor.onPongReceived(); + monitor.onConnectionStateChanged( + const Connected(healthCheck: HealthCheckInfo(connectionId: 'answered')), + ); + + async.elapse(_pingInterval * 0.5); + + // Starting again would reschedule the ping, so each answer would push the next one a full + // interval away and a connection answering steadily would be asked less and less often. + expect(listener.pings, 1); + expect(listener.unhealthy, 0); + + monitor.stop(); + }); + }); + }); +} diff --git a/packages/stream_core/test/ws/events/ws_event_test.dart b/packages/stream_core/test/ws/events/ws_event_test.dart new file mode 100644 index 00000000..58c1d9ab --- /dev/null +++ b/packages/stream_core/test/ws/events/ws_event_test.dart @@ -0,0 +1,33 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('HealthCheckInfo', () { + test('compares by what it carries', () { + expect( + const HealthCheckInfo(connectionId: 'a', participantCount: 2), + const HealthCheckInfo(connectionId: 'a', participantCount: 2), + ); + expect( + const HealthCheckInfo(connectionId: 'a'), + isNot(const HealthCheckInfo(connectionId: 'b')), + ); + }); + }); + + group('HealthCheckPingEvent', () { + test('is sent as a health check naming the connection it is for', () { + // The server matches a ping to a connection by this id, so the wire shape is the contract. + expect( + const HealthCheckPingEvent(connectionId: 'connection-id').toJson(), + {'type': 'health.check', 'client_id': 'connection-id'}, + ); + }); + + test('leaves the connection out when there is not one yet', () { + // A null id is omitted rather than sent as null, which the server would read as a client + // claiming no connection. + expect(const HealthCheckPingEvent(connectionId: null).toJson(), {'type': 'health.check'}); + }); + }); +} From 89db6878e68050ae526a29e89eb09a14d81f5e1b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 11:55:39 +0200 Subject: [PATCH 54/97] test(llc): rebuild the auth interceptor tests around one fake backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five near-identical adapter classes and sixteen repetitions of the same Dio setup become one configurable backend and one subject helper. Assertions that only checked something was set now check what it is: a header carrying the wrong token authenticates as the wrong user, and `throwsA(isA())` passed for any failure at all. Covers the paths that had none: a token that cannot be loaded at all, an error carrying no JSON, and that requests are not held behind one another — the invariant that makes this an `Interceptor` rather than a `QueuedInterceptor`. Co-Authored-By: Claude Opus 5 (1M context) --- .../interceptors/auth_interceptor_test.dart | 711 +++++++++++------- 1 file changed, 452 insertions(+), 259 deletions(-) diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index 5117099b..ee852596 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -6,42 +6,73 @@ import 'package:test/test.dart'; import '../../helpers/user_token.dart'; -// A minimal HttpClientAdapter that captures the outgoing RequestOptions and -// always responds with an empty successful response. -class _CapturingHttpClientAdapter implements HttpClientAdapter { - RequestOptions? lastRequest; +/// The body the API returns when the token it was given has run out. +Map _expiredTokenBody() => { + 'code': 40, + 'details': [], + 'duration': '0ms', + 'message': 'token expired', + 'more_info': '', + 'StatusCode': 401, +}; + +ResponseBody _json(Object? body, int statusCode) => ResponseBody.fromString( + jsonEncode(body), + statusCode, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, +); + +/// A backend a test drives, in place of a real one. +/// +/// Answers the first [refusals] requests with an expired-token error and accepts every one after +/// that, so a refresh that works and one that never does share a harness. Set [reply] to answer +/// something else entirely, or [overlap] to answer nothing until that many requests are in flight +/// at once. +class _FakeApi implements HttpClientAdapter { + _FakeApi({ + this.refusals = 0, + this.refusalDelay, + this.onRequest, + this.reply, + this.overlap, + }); - @override - Future fetch( - RequestOptions options, - Stream? requestStream, - Future? cancelFuture, - ) async { - lastRequest = options; - return ResponseBody.fromString( - '{}', - 200, - headers: { - Headers.contentTypeHeader: [Headers.jsonContentType], - }, - ); - } + /// How many of the first requests are refused. + final int refusals; - @override - void close({bool force = false}) {} -} + /// How long the nth refusal is held, for a rejection that has to land after an earlier one has + /// already replaced the token. + final Duration? Function(int attempt)? refusalDelay; + + /// Called as each request is dispatched, for a test that moves the manager mid-flight. + final void Function()? onRequest; + + /// Replaces the reply entirely, for a backend answering something other than a Stream error. + final ResponseBody Function(int attempt)? reply; -// An adapter that always responds with a token-expired API error (code 40), -// counting how many times it is hit so a retry can be detected. [onFetch], if -// provided, runs when the request is dispatched — used to simulate a token -// manager being swapped in mid-flight. -class _TokenExpiredHttpClientAdapter implements HttpClientAdapter { - _TokenExpiredHttpClientAdapter({this.onFetch}); + /// Holds every request until this many are in flight at once. + /// + /// A caller that runs its requests one at a time never gets there, and the wait never finishes, + /// which is what makes serialising them visible. + final int? overlap; - final void Function()? onFetch; + /// The most requests that were in flight at once. + int get peakInFlight => _peakInFlight; + var _peakInFlight = 0; - var _requestCount = 0; - int get requestCount => _requestCount; + final _reachedOverlap = Completer(); + var _inFlight = 0; + + /// Every request that reached this backend, in order. + final requests = []; + + /// The `Authorization` header each request carried, in order. + final sentTokens = []; + + int get count => requests.length; + RequestOptions? get lastRequest => requests.isEmpty ? null : requests.last; @override Future fetch( @@ -49,285 +80,447 @@ class _TokenExpiredHttpClientAdapter implements HttpClientAdapter { Stream? requestStream, Future? cancelFuture, ) async { - _requestCount++; - onFetch?.call(); - return ResponseBody.fromString( - jsonEncode({ - 'code': 40, // token expired - 'details': [], - 'duration': '0ms', - 'message': 'token expired', - 'more_info': '', - 'StatusCode': 401, - }), - 401, - headers: { - Headers.contentTypeHeader: [Headers.jsonContentType], - }, - ); + requests.add(options); + sentTokens.add(options.headers['Authorization'] as String?); + final attempt = requests.length; + + onRequest?.call(); + + if (reply case final reply?) return reply(attempt); + + if (attempt > refusals) { + if (overlap case final overlap?) await _awaitOverlap(overlap); + return _json(const {}, 200); + } + + if (refusalDelay?.call(attempt) case final delay?) { + await Future.delayed(delay); + } + + return _json(_expiredTokenBody(), 401); + } + + Future _awaitOverlap(int overlap) async { + _inFlight++; + if (_inFlight > _peakInFlight) _peakInFlight = _inFlight; + if (_inFlight >= overlap && !_reachedOverlap.isCompleted) _reachedOverlap.complete(); + + await _reachedOverlap.future; + _inFlight--; } @override void close({bool force = false}) {} } +/// Builds a Dio carrying the interceptor under test, over a backend the test drives. +/// +/// Defaults to the dynamic provider a real app has, issuing a token that can be told apart from the +/// last one. Pass [tokenProvider] for a static one, or [loader] to control when a load finishes and +/// what it returns; [loader] is given the number of the load, counting from one. +({ + Dio dio, + _FakeApi api, + TokenManager tokens, + int Function() loads, +}) +_subject({ + String userId = 'user-1', + TokenProvider? tokenProvider, + Future Function(String userId, int load)? loader, + _FakeApi? api, +}) { + var loads = 0; + + final tokens = TokenManager( + userId: userId, + tokenProvider: + tokenProvider ?? + TokenProvider.dynamic((id) { + loads++; + return loader?.call(id, loads) ?? Future.value(generateTestUserToken(id, nonce: '$loads')); + }), + ); + + final backend = api ?? _FakeApi(); + final dio = Dio(BaseOptions(baseUrl: 'https://example.com'))..httpClientAdapter = backend; + dio.interceptors.add(AuthInterceptor(dio, tokens)); + + return (dio: dio, api: backend, tokens: tokens, loads: () => loads); +} + +/// Matches the token-expired error the backend refuses with, as the caller receives it. +final _expiredTokenError = isA() + .having((it) => it.response?.statusCode, 'response.statusCode', 401) + .having((it) => (it.response?.data as Map?)?['code'], 'response.data.code', 40); + void main() { group('AuthInterceptor', () { - test( - 'sets the Authorization header, the auth type, and the user_id query ' - 'parameter', - () async { - final tokenManager = TokenManager( - userId: 'user-123', - tokenProvider: TokenProvider.static( - generateTestUserToken('user-123'), + test('sends the token, its auth type and its user as the request credentials', () async { + final token = generateTestUserToken('user-123'); + final (:dio, :api, tokens: _, loads: _) = _subject( + userId: 'user-123', + tokenProvider: TokenProvider.static(token), + ); + + await dio.get('/test'); + + // The exact values, not merely that something was set: a header carrying the wrong token + // authenticates as the wrong user, or not at all. + expect(api.lastRequest?.headers['Authorization'], token.rawValue); + expect(api.lastRequest?.headers['stream-auth-type'], AuthType.jwt.headerValue); + expect(api.lastRequest?.queryParameters['user_id'], 'user-123'); + }); + + test('sends an anonymous token as an empty Authorization header with the anonymous auth type', () async { + final (:dio, :api, tokens: _, loads: _) = _subject( + userId: User.anonymousUserId, + tokenProvider: TokenProvider.static(UserToken.anonymous()), + ); + + await dio.get('/test'); + + expect(api.lastRequest?.headers['Authorization'], isEmpty); + expect(api.lastRequest?.headers['stream-auth-type'], AuthType.anonymous.headerValue); + expect(api.lastRequest?.queryParameters['user_id'], User.anonymousUserId); + }); + + test('sends a restricted anonymous token as the Authorization header', () async { + final restricted = generateTestUserToken(User.anonymousUserId); + final (:dio, :api, tokens: _, loads: _) = _subject( + userId: User.anonymousUserId, + tokenProvider: TokenProvider.static(UserToken.anonymous(rawValue: restricted.rawValue)), + ); + + await dio.get('/test'); + + // An anonymous token may still carry a JWT granting restricted access, which has to go out. + expect(api.lastRequest?.headers['Authorization'], restricted.rawValue); + expect(api.lastRequest?.headers['stream-auth-type'], AuthType.anonymous.headerValue); + }); + + test('sends the user id of the token it actually sent', () async { + // A load already running for one user can finish after the manager has moved to another, and + // its token still goes to the request that triggered it. Taking `user_id` from the token keeps + // the pair consistent. + final slowLoad = Completer(); + final (:dio, :api, :tokens, loads: _) = _subject(loader: (_, _) => slowLoad.future); + + final pending = dio.get('/test'); + await pumpEventQueue(); + + final userOneToken = generateTestUserToken('user-1'); + tokens.setTokenProvider('user-2', tokenProvider: TokenProvider.static(generateTestUserToken('user-2'))); + slowLoad.complete(userOneToken); + await pending; + + expect(api.lastRequest?.queryParameters['user_id'], 'user-1'); + expect(api.lastRequest?.headers['Authorization'], userOneToken.rawValue); + }); + + test('fails a request whose token could not be loaded, rather than sending it unauthenticated', () async { + final (:dio, :api, tokens: _, loads: _) = _subject( + tokenProvider: TokenProvider.dynamic((_) async => throw StateError('token endpoint down')), + ); + + await expectLater( + dio.get('/test'), + throwsA( + isA().having( + (it) => it.error, + 'error', + isA() + .having((it) => it.message, 'message', 'Failed to load auth token') + .having((it) => it.underlyingError, 'underlyingError', isStateError), ), - ); + ), + ); - final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); - final adapter = _CapturingHttpClientAdapter(); - dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + // Sent without credentials it would come back 401, which reads as a token the server refused + // rather than one that was never loaded. + expect(api.count, 0); + }); - await dio.get('/test'); + group('the refresh-and-retry', () { + test('retries a token-expired response once with the token the provider issued next', () async { + final (:dio, :api, tokens: _, :loads) = _subject(api: _FakeApi(refusals: 1)); - expect(adapter.lastRequest?.queryParameters['user_id'], 'user-123'); - expect( - adapter.lastRequest?.headers['Authorization'], - isNotNull, - ); - expect( - adapter.lastRequest?.headers['stream-auth-type'], - isNotNull, - ); - }, - ); - - test( - 'sends the user id a guest exchange returned, once the token manager is ' - 'pointed at it', - () async { - // Simulates the guest flow: the exchange is authenticated anonymously, - // then the manager is pointed at the id the exchange returned before - // the next request goes out, so nothing is in flight across the swap. - const serverId = 'server-assigned-id'; - - final tokenManager = TokenManager( - userId: User.anonymousUserId, - tokenProvider: TokenProvider.static(UserToken.anonymous()), - ); + final response = await dio.get('/test'); - final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); - final adapter = _CapturingHttpClientAdapter(); - dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + expect(response.statusCode, 200); + expect(api.count, 2); + expect(loads(), 2); + expect(api.sentTokens.first, isNot(api.sentTokens.last)); + }); - tokenManager.setTokenProvider( - serverId, - tokenProvider: TokenProvider.static(generateTestUserToken(serverId)), - ); + test('retries a multipart request without re-sending a consumed body', () async { + final (:dio, :api, tokens: _, loads: _) = _subject(api: _FakeApi(refusals: 1)); - await dio.get('/test'); + // A file's bytes are a stream, and the refused attempt has already read it. Without a clone + // the retry fails with "The FormData has already been finalized". + final body = FormData()..files.add(MapEntry('file', MultipartFile.fromString('contents'))); - expect(adapter.lastRequest?.queryParameters['user_id'], serverId); - expect( - adapter.lastRequest?.headers['stream-auth-type'], - AuthType.jwt.headerValue, - ); - }, - ); - - test( - 'sends an anonymous token as an empty Authorization header with the ' - 'anonymous auth type', - () async { - final tokenManager = TokenManager( - userId: User.anonymousUserId, - tokenProvider: TokenProvider.static(UserToken.anonymous()), - ); + final response = await dio.post('/upload', data: body); - final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); - final adapter = _CapturingHttpClientAdapter(); - dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + expect(response.statusCode, 200); + expect(api.count, 2); + }); - await dio.get('/test'); + test('reports a replacement the server refuses too, rather than leaving the caller waiting', () async { + // The provider keeps issuing tokens that are refused. Retrying the retry would re-enter this + // interceptor from inside itself, so the retry happens exactly once. + final (:dio, :api, tokens: _, :loads) = _subject(api: _FakeApi(refusals: 10)); - expect(adapter.lastRequest?.headers['Authorization'], isEmpty); - expect( - adapter.lastRequest?.headers['stream-auth-type'], - AuthType.anonymous.headerValue, - ); - expect( - adapter.lastRequest?.queryParameters['user_id'], - User.anonymousUserId, + await expectLater( + dio.get('/test').timeout(const Duration(seconds: 5)), + throwsA(_expiredTokenError), ); - }, - ); - - test( - 'sends a restricted anonymous token as the Authorization header', - () async { - final restricted = generateTestUserToken(User.anonymousUserId); - final tokenManager = TokenManager( - userId: User.anonymousUserId, - tokenProvider: TokenProvider.static( - UserToken.anonymous(rawValue: restricted.rawValue), + + expect(api.count, 2); + expect(loads(), 2); + }); + + test('leaves the token alone for a rejection that arrives after it was already replaced', () async { + // Both requests carry the same token. The first is refused and replaces it, so by the time + // the second is refused it is no longer the cached one, and expiring then would discard a + // good replacement. + final (:dio, :api, tokens: _, :loads) = _subject( + api: _FakeApi( + refusals: 2, + refusalDelay: (attempt) => attempt == 2 ? const Duration(milliseconds: 200) : null, ), ); - final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); - final adapter = _CapturingHttpClientAdapter(); - dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + await Future.wait([dio.get('/a'), dio.get('/b')]); + + expect(loads(), 2); + expect(api.count, 4); + expect(api.sentTokens.toSet(), hasLength(2)); + }); + + test('sends a burst of rejected requests to the provider once between them', () async { + // Every request in flight is rejected carrying the same spent token, and each expires it in + // turn. The manager serialises the loads, so all three share one replacement. + final replacementLoading = Completer(); + final (:dio, :api, tokens: _, :loads) = _subject( + api: _FakeApi(refusals: 3), + loader: (id, load) async { + // Held so all three refusals are handled at once. + if (load > 1) await replacementLoading.future; + return generateTestUserToken(id, nonce: '$load'); + }, + ); - await dio.get('/test'); + final requests = Future.wait([dio.get('/a'), dio.get('/b'), dio.get('/c')]); - expect(adapter.lastRequest?.headers['Authorization'], restricted.rawValue); - expect( - adapter.lastRequest?.headers['stream-auth-type'], - AuthType.anonymous.headerValue, + await pumpEventQueue(); + replacementLoading.complete(); + await requests; + + expect(loads(), 2); + expect(api.count, 6); + expect(api.sentTokens.skip(3).toSet(), hasLength(1)); + }); + }); + + group('while a token load is in flight', () { + test('holds a burst of requests until the first load finishes', () async { + final loadStarted = Completer(); + final loadGate = Completer(); + final (:dio, :api, tokens: _, :loads) = _subject( + loader: (id, load) async { + if (!loadStarted.isCompleted) loadStarted.complete(); + await loadGate.future; + return generateTestUserToken(id, nonce: '$load'); + }, ); - }, - ); - - test( - 'sends the user id of the token it actually sent', - () async { - // The two can disagree: a load already running for one user finishes - // after the manager has moved to another, and that token is still - // handed to the request that triggered it. Deriving `user_id` from the - // token keeps the pair self-consistent, so the request is accepted as - // the token's owner rather than rejected for a mismatch. - final slowLoad = Completer(); - final tokenManager = TokenManager( - userId: 'user-1', - tokenProvider: TokenProvider.dynamic((_) => slowLoad.future), + + final requests = Future.wait(List.generate(10, (i) => dio.get('/r$i'))); + + await loadStarted.future; + await pumpEventQueue(); + expect(api.count, 0); + expect(loads(), 1); + + loadGate.complete(); + await requests; + + // One load shared by all ten, rather than ten loads racing each other. + expect(loads(), 1); + expect(api.count, 10); + expect(api.sentTokens.toSet(), hasLength(1)); + }); + + test('does not hold requests behind one another', () async { + // The reason this is an `Interceptor` and not a `QueuedInterceptor`: a queue would run these + // one at a time, and the retry sent from `onError` would wait behind the request still + // holding the handler, so neither would ever finish. + final (:dio, :api, tokens: _, loads: _) = _subject(api: _FakeApi(overlap: 5)); + + final requests = Future.wait(List.generate(5, (i) => dio.get('/r$i'))); + await requests.timeout(const Duration(seconds: 5)); + + expect(api.peakInFlight, 5); + }); + + test('does not hold the retries of a burst that was all refused behind one another', () async { + // Every request is refused for its token, so every one retries. The token load is shared, + // but the retries themselves must still go out together. + final (:dio, :api, tokens: _, :loads) = _subject(api: _FakeApi(refusals: 3, overlap: 3)); + + final requests = Future.wait([dio.get('/a'), dio.get('/b'), dio.get('/c')]); + await requests.timeout(const Duration(seconds: 5)); + + expect(api.peakInFlight, 3); + expect(loads(), 2); + }); + + test('holds a new request until an in-flight refresh finishes', () async { + final refreshGate = Completer(); + final (:dio, :api, tokens: _, :loads) = _subject( + api: _FakeApi(refusals: 1), + loader: (id, load) async { + // Hold the replacement, not the token the first request carries. + if (load > 1) await refreshGate.future; + return generateTestUserToken(id, nonce: '$load'); + }, ); - final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); - final adapter = _CapturingHttpClientAdapter(); - dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + final first = dio.get('/a'); + await pumpEventQueue(); + expect(loads(), 2); + expect(api.count, 1); - final pending = dio.get('/test'); + // A request arriving during the refresh waits for the replacement rather than going out with + // the token that was just refused. + final second = dio.get('/b'); await pumpEventQueue(); + expect(api.count, 1); - // The load is already running for user-1 when the manager moves on. - final userOneToken = generateTestUserToken('user-1'); - tokenManager.setTokenProvider( - 'user-2', - tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), - ); - slowLoad.complete(userOneToken); - await pending; + refreshGate.complete(); + await Future.wait([first, second]); - expect(adapter.lastRequest?.queryParameters['user_id'], 'user-1'); - expect( - adapter.lastRequest?.headers['Authorization'], - userOneToken.rawValue, - ); - }, - ); - - test( - 'does not retry a token-expired response when using a static provider ' - '(e.g. a guest token): the error is surfaced to the caller instead of ' - 'silently re-minting the token', - () async { - final tokenManager = TokenManager( + expect(loads(), 2); + expect(api.sentTokens.first, isNot(api.sentTokens.last)); + expect(api.sentTokens.skip(1).toSet(), hasLength(1)); + }); + }); + + group('when no other token could be presented', () { + test('does not retry when the provider has only the token that was refused', () async { + final (:dio, :api, tokens: _, loads: _) = _subject( userId: 'guest-1', tokenProvider: TokenProvider.static(generateTestUserToken('guest-1')), + api: _FakeApi(refusals: 10), ); - final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); - final adapter = _TokenExpiredHttpClientAdapter(); - dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + // A static provider reissues what was refused, so the retry would present it again. + await expectLater(dio.get('/test'), throwsA(_expiredTokenError)); - await expectLater( - dio.get('/test'), - throwsA(isA()), - ); + expect(api.count, 1); + }); - // A static provider must not trigger the refresh-and-retry path, so - // the request is attempted exactly once. - expect(adapter.requestCount, 1); - }, - ); - - test( - 'forwards a token-expired error without retrying when the manager has no ' - 'identity left to load a token for', - () async { - final tokenManager = TokenManager( - userId: 'user-123', - tokenProvider: TokenProvider.dynamic( - (userId) async => generateTestUserToken(userId), + test('does not retry when a static provider is adopted mid-flight', () async { + // Starts on a dynamic provider and adopts a static one carrying the exchanged id once the + // request is in flight, mirroring the guest flow. `onError` sees the static provider and + // must forward the error rather than expire and retry. + late TokenManager tokens; + final subject = _subject( + userId: 'requested-id', + api: _FakeApi( + refusals: 10, + onRequest: () => tokens.setTokenProvider( + 'server-assigned-id', + tokenProvider: TokenProvider.static(generateTestUserToken('server-assigned-id')), + ), ), ); - - final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); - final adapter = _TokenExpiredHttpClientAdapter(onFetch: tokenManager.reset); - dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + tokens = subject.tokens; + + await expectLater(subject.dio.get('/test'), throwsA(_expiredTokenError)); + + expect(subject.api.count, 1); + }); + + test('forwards the error when the manager has no identity left to load a token for', () async { + late TokenManager tokens; + final subject = _subject(api: _FakeApi(refusals: 10, onRequest: () => tokens.reset())); + tokens = subject.tokens; + + // Retrying would replace this with the less useful failure to load a token for a user the + // manager no longer has. + await expectLater(subject.dio.get('/test'), throwsA(_expiredTokenError)); + + expect(subject.api.count, 1); + }); + }); + + test('retries a token-expired response the server sent as text rather than JSON', () async { + // A proxy or gateway can answer without a JSON content type, and Dio then hands the body over + // as a string. It is the same refusal, so it deserves the same replacement. + var attempt = 0; + final (:dio, :api, tokens: _, :loads) = _subject( + api: _FakeApi( + reply: (_) => switch (++attempt) { + 1 => ResponseBody.fromString( + jsonEncode(_expiredTokenBody()), + 401, + headers: { + Headers.contentTypeHeader: ['text/plain'], + }, + ), + _ => _json(const {}, 200), + }, + ), + ); + + final response = await dio.get('/test'); + + expect(response.statusCode, 200); + expect(api.count, 2); + expect(loads(), 2); + }); + + group('an error that is not a Stream token error', () { + test('is forwarded when its JSON body belongs to something else', () async { + // A proxy or gateway answers with a JSON body of its own. Parsing it as a Stream error + // throws, and an error thrown in `onError` never reaches the caller: the request used to + // hang for good instead of failing. + final (:dio, :api, tokens: _, loads: _) = _subject( + api: _FakeApi(reply: (_) => _json({'error': 'gateway timeout'}, 504)), + ); await expectLater( dio.get('/test'), - throwsA( - // Retrying would replace this with the failure to load a token for - // a user the manager no longer has, which says less. - isA().having( - (it) => (it.response?.data as Map?)?['code'], - 'the original token-expired error', - 40, - ), - ), + throwsA(isA().having((it) => it.response?.statusCode, 'response.statusCode', 504)), ); - expect(adapter.requestCount, 1); - }, - ); - - test( - 'forwards a token-expired error without retrying when the token manager ' - 'is pointed at a static provider after the request was dispatched ' - '(guest exchange resolving mid-flight)', - () async { - // Starts on a dynamic provider and adopts a static one carrying the - // exchanged id once the request is already in flight, mirroring the - // guest flow. onError sees the static provider and must forward the - // error rather than expire + retry. - final tokenManager = TokenManager( - userId: 'requested-id', - tokenProvider: TokenProvider.dynamic( - (_) async => generateTestUserToken('requested-id'), + expect(api.count, 1); + }); + + test('is forwarded when it carries no JSON at all', () async { + // A gateway answering HTML, which is the shape of most failures that are not the API's. + final (:dio, :api, tokens: _, :loads) = _subject( + api: _FakeApi( + reply: (_) => ResponseBody.fromString( + '502 Bad Gateway', + 502, + headers: { + Headers.contentTypeHeader: ['text/html'], + }, + ), ), ); - final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); - final adapter = _TokenExpiredHttpClientAdapter( - onFetch: () { - tokenManager.setTokenProvider( - 'server-assigned-id', - tokenProvider: TokenProvider.static( - generateTestUserToken('server-assigned-id'), - ), - ); - }, - ); - dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor(dio, tokenManager)); - await expectLater( dio.get('/test'), - throwsA(isA()), + throwsA(isA().having((it) => it.response?.statusCode, 'response.statusCode', 502)), ); - // The adopted provider is static, so the error is surfaced without a - // refresh-and-retry: the request is attempted exactly once. - expect(adapter.requestCount, 1); - }, - ); + // Nothing here says the token is spent, so it is neither expired nor reloaded. + expect(api.count, 1); + expect(loads(), 1); + }); + }); }); } From 55c8f6c872a8f6ca7a5dac4b8ab19b9df77d8676 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 11:55:45 +0200 Subject: [PATCH 55/97] test(llc): drop tests that restate the source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirteen filter tests asserted that a constructor stored its arguments — every one of those assertions is already made by the serialisation test beside it, since a wrong field, operator or value shows up as wrong JSON. The three real-world usage examples re-ran assertions their parts already make, and a single-item list is not an edge case. Line coverage of `filter.dart` is unchanged at 100%, which is the argument for removing them. Co-Authored-By: Claude Opus 5 (1M context) --- .../stream_core/test/query/filter_test.dart | 287 ------------------ .../connect_user_details_request_test.dart | 20 +- .../stream_core/test/utils/result_test.dart | 23 +- 3 files changed, 11 insertions(+), 319 deletions(-) diff --git a/packages/stream_core/test/query/filter_test.dart b/packages/stream_core/test/query/filter_test.dart index a949ca34..3ad814a2 100644 --- a/packages/stream_core/test/query/filter_test.dart +++ b/packages/stream_core/test/query/filter_test.dart @@ -51,19 +51,6 @@ class TestFilterField extends FilterField { void main() { group('Comparison', () { group('Equal', () { - test('should create equal filter correctly', () { - final field = TestFilterField.name; - const value = 'test'; - - var filter = Filter.equal(field, value); - - expect(filter, isA>()); - filter = filter as EqualOperator; - expect(filter.field, field); - expect(filter.value, value); - expect(filter.operator, FilterOperator.equal); - }); - test('should serialize to JSON correctly', () { final field = TestFilterField.name; const value = 'test'; @@ -90,19 +77,6 @@ void main() { }); group('Greater', () { - test('should create greater filter correctly', () { - final field = TestFilterField.createdAt; - final value = DateTime(2023); - - var filter = Filter.greater(field, value); - - expect(filter, isA>()); - filter = filter as GreaterOperator; - expect(filter.field, field); - expect(filter.value, value); - expect(filter.operator, FilterOperator.greater); - }); - test('should serialize to JSON correctly', () { final field = TestFilterField.id; const value = 100; @@ -117,19 +91,6 @@ void main() { }); group('GreaterOrEqual', () { - test('should create greater or equal filter correctly', () { - final field = TestFilterField.id; - const value = 50; - - var filter = Filter.greaterOrEqual(field, value); - - expect(filter, isA>()); - filter = filter as GreaterOrEqualOperator; - expect(filter.field, field); - expect(filter.value, value); - expect(filter.operator, FilterOperator.greaterOrEqual); - }); - test('should serialize to JSON correctly', () { final field = TestFilterField.id; const value = 50; @@ -144,19 +105,6 @@ void main() { }); group('Less', () { - test('should create less filter correctly', () { - final field = TestFilterField.id; - const value = 100; - - var filter = Filter.less(field, value); - - expect(filter, isA>()); - filter = filter as LessOperator; - expect(filter.field, field); - expect(filter.value, value); - expect(filter.operator, FilterOperator.less); - }); - test('should serialize to JSON correctly', () { final field = TestFilterField.id; const value = 100; @@ -171,19 +119,6 @@ void main() { }); group('LessOrEqual', () { - test('should create less or equal filter correctly', () { - final field = TestFilterField.id; - const value = 100; - - var filter = Filter.lessOrEqual(field, value); - - expect(filter, isA>()); - filter = filter as LessOrEqualOperator; - expect(filter.field, field); - expect(filter.value, value); - expect(filter.operator, FilterOperator.lessOrEqual); - }); - test('should serialize to JSON correctly', () { final field = TestFilterField.id; const value = 100; @@ -200,19 +135,6 @@ void main() { group('List', () { group('In', () { - test('should create in filter correctly', () { - final field = TestFilterField.tags; - final values = ['tag1', 'tag2', 'tag3']; - - var filter = Filter.in_(field, values); - - expect(filter, isA>()); - filter = filter as InOperator; - expect(filter.field, field); - expect(filter.value, values); - expect(filter.operator, FilterOperator.in_); - }); - test('should serialize to JSON correctly', () { final field = TestFilterField.tags; final values = ['tag1', 'tag2', 'tag3']; @@ -239,19 +161,6 @@ void main() { }); group('Contains', () { - test('should create contains filter correctly', () { - final field = TestFilterField.members; - const value = 'user123'; - - var filter = Filter.contains(field, value); - - expect(filter, isA>()); - filter = filter as ContainsOperator; - expect(filter.field, field); - expect(filter.value, value); - expect(filter.operator, FilterOperator.contains_); - }); - test('should serialize to JSON correctly', () { final field = TestFilterField.members; const value = 'user123'; @@ -267,19 +176,6 @@ void main() { }); group('Exists', () { - test('should create exists filter correctly', () { - final field = TestFilterField.metadata; - const exists = true; - - var filter = Filter.exists(field, exists: exists); - - expect(filter, isA>()); - filter = filter as ExistsOperator; - expect(filter.field, field); - expect(filter.exists, exists); - expect(filter.operator, FilterOperator.exists); - }); - test('should serialize to JSON correctly with exists=true', () { final field = TestFilterField.metadata; const exists = true; @@ -307,19 +203,6 @@ void main() { group('Evaluation', () { group('Query', () { - test('should create query filter correctly', () { - final field = TestFilterField.name; - const query = 'search term'; - - var filter = Filter.query(field, query); - - expect(filter, isA>()); - filter = filter as QueryOperator; - expect(filter.field, field); - expect(filter.query, query); - expect(filter.operator, FilterOperator.query); - }); - test('should serialize to JSON correctly', () { final field = TestFilterField.name; const query = 'search term'; @@ -334,19 +217,6 @@ void main() { }); group('AutoComplete', () { - test('should create autocomplete filter correctly', () { - final field = TestFilterField.name; - const query = 'prefix'; - - var filter = Filter.autoComplete(field, query); - - expect(filter, isA>()); - filter = filter as AutoCompleteOperator; - expect(filter.field, field); - expect(filter.query, query); - expect(filter.operator, FilterOperator.autoComplete); - }); - test('should serialize to JSON correctly', () { final field = TestFilterField.name; const query = 'prefix'; @@ -362,19 +232,6 @@ void main() { }); group('PathExists', () { - test('should create path exists filter correctly', () { - final field = TestFilterField.metadata; - const path = 'nested.field'; - - var filter = Filter.pathExists(field, path); - - expect(filter, isA>()); - filter = filter as PathExistsOperator; - expect(filter.field, field); - expect(filter.path, path); - expect(filter.operator, FilterOperator.pathExists); - }); - test('should serialize to JSON correctly', () { final field = TestFilterField.metadata; const path = 'nested.field'; @@ -390,19 +247,6 @@ void main() { group('Logical', () { group('And', () { - test('should create and filter correctly', () { - final filter1 = Filter.equal(TestFilterField.name, 'test'); - final filter2 = Filter.greater(TestFilterField.id, 100); - final filters = [filter1, filter2]; - - var andFilter = Filter.and(filters); - - expect(andFilter, isA>()); - andFilter = andFilter as AndOperator; - expect(andFilter.filters, filters); - expect(andFilter.operator, FilterOperator.and); - }); - test('should serialize to JSON correctly', () { final filter1 = Filter.equal(TestFilterField.name, 'test'); final filter2 = Filter.greater(TestFilterField.id, 100); @@ -454,19 +298,6 @@ void main() { }); group('Or', () { - test('should create or filter correctly', () { - final filter1 = Filter.equal(TestFilterField.name, 'test'); - final filter2 = Filter.greater(TestFilterField.id, 100); - final filters = [filter1, filter2]; - - var orFilter = Filter.or(filters); - - expect(orFilter, isA>()); - orFilter = orFilter as OrOperator; - expect(orFilter.filters, filters); - expect(orFilter.operator, FilterOperator.or); - }); - test('should serialize to JSON correctly', () { final filter1 = Filter.equal(TestFilterField.name, 'test'); final filter2 = Filter.greater(TestFilterField.id, 100); @@ -559,17 +390,6 @@ void main() { }); }); - test('should handle single item list in In', () { - final filter = Filter.in_(TestFilterField.tags, ['single']); - final json = filter.toJson(); - - expect(json, { - 'tags': { - r'$in': ['single'], - }, - }); - }); - test('should handle empty filters list in Logical', () { const andFilter = Filter.and(>[]); final json = andFilter.toJson(); @@ -578,113 +398,6 @@ void main() { }); }); - group('Type Safety', () { - test('should enforce FilterField type consistency', () { - // This test ensures that the generic type system works correctly - final filter1 = Filter.equal(TestFilterField.name, 'test'); - final filter2 = Filter.greater(TestFilterField.id, 100); - - var logicalFilter = Filter.and([filter1, filter2]); - - expect(logicalFilter, isA>()); - logicalFilter = logicalFilter as AndOperator; - expect(logicalFilter.filters.length, 2); - expect(logicalFilter.filters.elementAt(0), filter1); - expect(logicalFilter.filters.elementAt(1), filter2); - }); - }); - - group('Real-world Usage Examples', () { - test('should create a complex chat channel filter', () { - // Example: Find messaging channels where user is a member, created after a date, and has specific metadata - const userId = 'user123'; - const createdAfter = '2023-01-01T00:00:00Z'; - - final filter = Filter.and( - [ - Filter.equal(TestFilterField.type, 'messaging'), - Filter.contains(TestFilterField.members, userId), - Filter.greater( - TestFilterField.createdAt, - createdAfter, - ), - Filter.exists(TestFilterField.metadata, exists: true), - ], - ); - - final json = filter.toJson(); - - expect(json, { - r'$and': [ - { - 'type': {r'$eq': 'messaging'}, - }, - { - 'members': {r'$contains': 'user123'}, - }, - { - 'created_at': {r'$gt': '2023-01-01T00:00:00Z'}, - }, - { - 'metadata': {r'$exists': true}, - }, - ], - }); - }); - - test('should create a search filter with autocomplete', () { - const searchQuery = 'john'; - - final filter = Filter.or( - [ - Filter.query(TestFilterField.name, searchQuery), - Filter.autoComplete( - TestFilterField.name, - searchQuery, - ), - ], - ); - - final json = filter.toJson(); - - expect(json, { - r'$or': [ - { - 'name': {r'$q': 'john'}, - }, - { - 'name': {r'$autocomplete': 'john'}, - }, - ], - }); - }); - - test('should create a range filter', () { - const minId = 100; - const maxId = 200; - - final filter = Filter.and( - [ - Filter.greaterOrEqual(TestFilterField.id, minId), - Filter.lessOrEqual(TestFilterField.id, maxId), - ], - ); - - final json = filter.toJson(); - - expect(json, { - r'$and': [ - { - 'id': {r'$gte': 100}, - }, - { - 'id': {r'$lte': 200}, - }, - ], - }); - }); - }); - group('Filter.matches()', () { group('Equal', () { test('should match primitive values', () { diff --git a/packages/stream_core/test/user/connect_user_details_request_test.dart b/packages/stream_core/test/user/connect_user_details_request_test.dart index 2ff4858a..c29cc132 100644 --- a/packages/stream_core/test/user/connect_user_details_request_test.dart +++ b/packages/stream_core/test/user/connect_user_details_request_test.dart @@ -4,12 +4,7 @@ import 'package:test/test.dart'; void main() { group('ConnectUserDetailsRequest.fromUser', () { test('carries the fields the server accepts from a client', () { - const user = User( - id: 'user-1', - name: 'Bob', - image: 'https://example.com/bob.png', - custom: {'plan': 'pro'}, - ); + const user = User(id: 'user-1', name: 'Bob', image: 'https://example.com/bob.png', custom: {'plan': 'pro'}); final details = ConnectUserDetailsRequest.fromUser(user); @@ -24,18 +19,13 @@ void main() { final json = ConnectUserDetailsRequest.fromUser(user).toJson(); - // Sending either is pointless: the server ignores both from a client. + // Neither is accepted from a client, so sending them is pointless. expect(json, isNot(contains('role'))); expect(json, isNot(contains('teams'))); }); test('sends the id alone when details are excluded', () { - const user = User( - id: 'user-1', - name: 'Bob', - image: 'https://example.com/bob.png', - custom: {'plan': 'pro'}, - ); + const user = User(id: 'user-1', name: 'Bob', image: 'https://example.com/bob.png', custom: {'plan': 'pro'}); final details = ConnectUserDetailsRequest.fromUser(user, includeDetails: false); @@ -46,8 +36,8 @@ void main() { }); test('reports the name the user was created with, not the id fallback', () { - // `User.name` falls back to the id; the wire form must not, or a user - // with no name would be given the id as one. + // `User.name` falls back to the id; the wire form must not, or a user with no name would be + // given the id as one. const user = User(id: 'user-1'); final details = ConnectUserDetailsRequest.fromUser(user); diff --git a/packages/stream_core/test/utils/result_test.dart b/packages/stream_core/test/utils/result_test.dart index 92234e36..02934abe 100644 --- a/packages/stream_core/test/utils/result_test.dart +++ b/packages/stream_core/test/utils/result_test.dart @@ -15,10 +15,7 @@ void main() { test('throws what the fallback throws, so an error can be reworded', () { final result = Result.failure(Exception('original')); - expect( - () => result.getOrElse((error, _) => throw StateError('$error')), - throwsA(isA()), - ); + expect(() => result.getOrElse((error, _) => throw StateError('$error')), throwsA(isA())); }); test('returns what the fallback returns', () { @@ -47,9 +44,8 @@ void main() { group('Result widening', () { test('falls back to a supertype when the result is widened', () { - // Kotlin widens through a `` bound Dart has no equivalent for. - // Naming the wider type on the result gets there instead, since `Result` - // is covariant. + // Naming the wider type on the result widens the fallback with it, because `Result` is + // covariant. final Result widened = Result.failure(Exception('failed')); expect(widened.getOrElse((_, _) => 0.5), 0.5); @@ -91,10 +87,7 @@ void main() { test('rethrows an error from the transform', () { final result = Result.failure(Exception('failed')); - expect( - () => result.recover((_, _) => throw StateError('while recovering')), - throwsA(isA()), - ); + expect(() => result.recover((_, _) => throw StateError('while recovering')), throwsA(isA())); }); }); @@ -102,9 +95,7 @@ void main() { test('keeps the value when the transform only throws', () { const result = Result.success(42); - final recovered = result.recoverCatching( - (_, _) => throw StateError('unreachable'), - ); + final recovered = result.recoverCatching((_, _) => throw StateError('unreachable')); expect(recovered.getOrNull(), 42); }); @@ -112,9 +103,7 @@ void main() { test('reports an error from the transform as a failure', () { final result = Result.failure(Exception('failed')); - final recovered = result.recoverCatching( - (_, _) => throw StateError('while recovering'), - ); + final recovered = result.recoverCatching((_, _) => throw StateError('while recovering')); // Unlike `recover`, the error replaces the original rather than escaping. expect(recovered.exceptionOrNull(), isA()); From 0ad17b32d572e3b858b6e88f2c6c15d230182325 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 11:55:50 +0200 Subject: [PATCH 56/97] docs(llc): record the connection work in the changelog Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 50a812f5..6bbb1a98 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -6,10 +6,10 @@ - Removed the `TokenManager.tokenProvider` setter, use `setTokenProvider` instead - `StreamWebSocketClient` now takes an `optionsBuilder` instead of `options`, and calls it for every connection attempt - Renamed `StreamWebSocketClient.onConnectionEstablished` to `onAuthenticate` -- `StreamWebSocketClient.onAuthenticate` is now a `WebSocketAuthenticator`: it is handed a `WsRequestSender` and returns a `Result`, so a failure to authenticate can be observed +- `StreamWebSocketClient.onAuthenticate` is now a `WebSocketAuthenticator`: it is handed a `WsRequestSender` and throws to say the credentials did not go out, whether sending failed or it chose not to send them. That closes the connection as `AuthenticationFailed`, which is not retried - `WsRequestSender` and `WebSocketAuthenticator` now live in `web_socket_authentication_handler.dart` and are exported as before. The handler that runs them, and remembers what the server refused, is internal -- `WebSocketAuthenticator` returns `Future` rather than `Future>`: it throws to say the credentials did not go out, whether that is a failure or a deliberate refusal to send them. Returning a result described the same thing a second way, and both were funnelled into the same closure regardless -- `WebSocketAuthenticator` is handed the error the server closed the previous attempt with, as `previousError`, and null once a connection has been established. It says whether the credentials it last sent are the reason the attempt failed, so it can replace them — or return a failure when there is nothing to replace them with, which closes the connection as `AuthenticationFailed` and is not retried, rather than offering refused credentials for the life of the client +- `WebSocketAuthenticator` is also handed `previousError`, the error the server closed the previous attempt with, and null on a first attempt and once a connection has been established. It tells the authenticator whether the credentials it last sent are why the attempt failed, so it can replace them rather than offer refused ones for the life of the client. An attempt abandoned before its authenticator finished leaves the refusal behind for the attempt that replaces it, which has yet to answer it +- The `previousError` handed to a `WebSocketAuthenticator` is now forgotten when the caller disconnects, as well as when a connection is established. A caller that disconnects takes connecting back, and what they connect with next is theirs to decide — another user, whose credentials the refusal says nothing about. A connect made straight after a disconnect with credentials that are still spent is refused once, and the connect after that is told and succeeds - `TokenManager.userId` is now nullable, and is `null` until an identity is configured - `User` now requires a user of type `UserType.anonymous` to carry `User.anonymousUserId` as its id. A mismatch fails to compile in a const context, and throws in debug mode otherwise - `WebSocketConnectionState.isAutomaticReconnectionEnabled` is now `true` for an expired token, and remains `false` for token errors a fresh token cannot fix @@ -19,6 +19,7 @@ ### ✨ Features +- Added `DioException.apiError`, the Stream API error a response carried, read from a decoded body or from a string one, and `null` for anything that is not a Stream error payload - Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token - Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load - Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access; its `user_id` claim must be `!anon` @@ -28,16 +29,18 @@ - Added `TokenManager.reset`, which drops the configured identity and its cached token - Added `DisconnectionSource.connectTimeout`, reported when a connection attempt is abandoned before it is established, and eligible for automatic reconnection - Added `DisconnectionSource.authenticationFailed`, reported with its cause when a connection opens but cannot be authenticated -- Added `WsRequestSender`, the send capability handed to a `WebSocketAuthenticator` +- Added `WsRequestSender`, the send capability handed to a `WebSocketAuthenticator`. It belongs to the connection attempt it was handed to and fails once that attempt is no longer the one in flight, so an authenticator still awaiting credentials for an abandoned attempt cannot send them over the connection that replaced it, nor close it as `AuthenticationFailed` - Added `ConnectUserDetailsRequest.fromUser`, which builds the details a client may send from a `User` - Added `StreamWebSocketClient.dispose`, which closes the connection along with `events` and `connectionState`; the client is now `Disposable` - `StreamWebSocketClient.connect` now throws a `StateError` once the client has been disposed, in release builds as well as debug. It previously asserted and then returned, so a release build opened a socket nothing could observe or close: the emitters are shut, so no state change is reported, and the health monitor that would tear an idle connection down is stopped -- `StreamWebSocketClient.disconnect` records the request without going through `disconnecting` when there is no connection to close, so a `connect` made straight afterwards is not refused for racing a close that is not happening. An explicit disconnect also now takes effect on a connection that is already down, which is what calls off a scheduled reconnection +- `StreamWebSocketClient.disconnect` returns without reporting a closure when no connection was ever opened, so a `connect` made straight afterwards is not refused for racing a close that is not happening. An explicit disconnect also now takes over a closure already recorded or under way, which is what calls off a scheduled reconnection - Added `teams` field to `User` class - `StreamWebSocketClient` now honours `WebSocketOptions.connectTimeout`, no longer nullable and 30 seconds by default, so an attempt that never becomes usable is abandoned and reconnected rather than waited on indefinitely ### 🐛 Bug Fixes +- Fixed a token-expired response never being retried when the server sent it without a JSON content type, so Dio handed the body over as a string. The same body was already read that way when the error was surfaced to the caller, so a request was reported as refused for an expired token without the token ever being replaced +- `StreamWebSocketClient` no longer prints to the console. It announced every connection state change, every pong and every ping, which is noise a consumer cannot turn off and cannot act on - Fixed `StreamWebSocketClient.connect` leaking the socket of a connection whose handshake failed. The socket is opened before the handshake it fails, and the client reported the connection closed without closing it, so the socket stayed open and unreachable — `dispose` did not close it either, and the next attempt closed it instead, reporting a closure while that attempt was still connecting - Fixed a WebSocket engine that reported a closure to its listener only when the close succeeded. It now reports one however the close went, and even when there was no socket to close, so a client waiting to hear the connection is down is no longer left waiting on a socket it can never use. It also lets go of a socket that failed to close, rather than holding one it cannot use, and reports a closure once rather than again when the socket's stream ends - Fixed a request that met a second token-expired response never completing at all. A request is now retried at most once @@ -48,7 +51,7 @@ - Fixed `TokenManager` caching a token that finished loading after `expireToken` or `setTokenProvider` had invalidated it - Fixed `StreamWebSocketClient.disconnect` completing before the socket was closed, so a `connect` straight afterwards raced the closure - Fixed a failure to close the socket leaving `StreamWebSocketClient` reporting itself as disconnecting for good -- Fixed a `WebSocketAuthenticator` that throws rather than returning a failure escaping as an unhandled error and leaving the connection authenticating +- Fixed an error thrown by a `WebSocketAuthenticator` escaping unhandled and leaving the connection authenticating - Fixed `StreamWebSocketClient.disconnect` replacing the source of a closure already under way, turning a reconnectable error into a permanent `ConnectTimeout` - Fixed `isAutomaticReconnectionEnabled` refusing neither a deliberate close (code 1000) nor client errors, neither of which ever matched - Fixed a connection closed for a rate limit not being eligible for automatic reconnection, since a rate limit clears on its own From 863c88137b971ca64fe1b0dee0c7acc51919ff47 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 11:56:51 +0200 Subject: [PATCH 57/97] fix(llc): replace a cached token the server would refuse for having expired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getToken` handed out a token whose expiry had passed, so the server had to be the one to say so — costing a refused request on the HTTP path and a reconnect cycle on the websocket one. The client already knows the expiry, so it checks before presenting. Judged on the expiry alone, with no margin ahead of it: a margin would have to outrun a clock skew the client cannot measure, and any margin longer than a token's own life turns every caller into a load. A static provider is exempt — it has nothing fresher to give, and the server refusing its token is what tells a guest to exchange for a new identity. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 1 + .../lib/src/user/token_manager.dart | 18 ++-- .../test/user/token_manager_test.dart | 85 +++++++++++++++++++ 3 files changed, 97 insertions(+), 7 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 39c84e83..5eff95d8 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -21,6 +21,7 @@ ### 🐛 Bug Fixes - Fixed `TokenManager.getToken()` contacting the `TokenProvider` on every call instead of returning the cached token +- `TokenManager.getToken` now replaces a cached token that has expired, rather than handing it out and learning the same thing from a refused request. Judged on the expiry alone, so a token with life left in it is still cached. A static provider is left alone: it has nothing fresher to give, and the server refusing its token is what tells a guest to exchange for a new identity - Fixed `DynamicTokenProvider` accepting a token issued for a different user than the one requested - Fixed `TokenManager` caching a token that finished loading after `expireToken` or `setTokenProvider` had invalidated it diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index 25dc53a9..cf60d2ec 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -128,11 +128,9 @@ class TokenManager { /// `null` when nothing is cached, or when the cache was expired. UserToken? peekToken() => _cachedToken; - /// Whether this manager uses a static token provider. + /// Whether tokens come from a provider that always returns the same one. /// - /// Returns true if the token provider is static (doesn't refresh tokens), - /// false if it's dynamic (fetches fresh tokens on each call) or if no - /// identity is configured. + /// `false` when no identity is configured. bool get usesStaticProvider => _identity?.provider is StaticTokenProvider; /// Returns the cached token, loading one from the [TokenProvider] when nothing is cached. @@ -142,18 +140,24 @@ class TokenManager { /// /// Fails with a [ClientException] when no identity is configured, or when [reset] runs while the /// token is loading. - Future getToken() { + Future getToken() async { final cached = peekToken(); - if (cached != null) return Future.value(cached); + if (cached != null && !_isSpent(cached)) return cached; return synchronized(() { final currentToken = peekToken(); - if (currentToken != null) return Future.value(currentToken); + if (currentToken != null && !_isSpent(currentToken)) return currentToken; return _loadAndNotify(); }); } + bool _isSpent(UserToken token) { + // A static provider has nothing fresher to replace it with. + if (usesStaticProvider) return false; + return token.isExpired(); + } + // Loads a token from the provider and, unless the cached token was // invalidated while it loaded, caches it and notifies `onTokenUpdated`. Future _loadAndNotify() async { diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 0cb804b7..7ae0d585 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:clock/clock.dart'; import 'package:meta/meta.dart'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; @@ -55,6 +56,90 @@ void main() { expect(manager.peekToken(), generateTestUserToken('user-1')); }); + test('replaces a cached token that has expired', () async { + final expiry = DateTime.utc(2030); + final provider = _CountingProvider((id) async => generateTestUserToken(id, expiresAt: expiry)); + final manager = TokenManager(userId: 'user-1', tokenProvider: provider); + + await withClock(Clock.fixed(expiry.subtract(const Duration(hours: 1))), manager.getToken); + expect(provider.loadCount, 1); + + // Past its expiry the server would refuse it, so presenting it costs a request to find out + // what the token already said. + await withClock(Clock.fixed(expiry.add(const Duration(seconds: 1))), manager.getToken); + + expect(provider.loadCount, 2); + }); + + test('keeps a cached token that has not expired yet', () async { + final expiry = DateTime.utc(2030); + final provider = _CountingProvider((id) async => generateTestUserToken(id, expiresAt: expiry)); + final manager = TokenManager(userId: 'user-1', tokenProvider: provider); + + await withClock(Clock.fixed(expiry.subtract(const Duration(hours: 1))), manager.getToken); + await withClock(Clock.fixed(expiry.subtract(const Duration(seconds: 1))), manager.getToken); + + // A second of life left is still life: a margin ahead of the expiry would throw it away. + expect(provider.loadCount, 1); + }); + + test('contacts the provider once for a token that is short-lived, not once per call', () async { + // A backend issuing tokens that live for seconds. Treating anything near its expiry as spent + // makes every call a load, which is the caching this manager exists for, undone. + final issued = DateTime.utc(2030); + var loads = 0; + final provider = _CountingProvider( + (id) async => generateTestUserToken(id, expiresAt: issued.add(Duration(seconds: 30 + ++loads))), + ); + final manager = TokenManager(userId: 'user-1', tokenProvider: provider); + + await withClock(Clock.fixed(issued), () async { + for (var i = 0; i < 5; i++) { + await manager.getToken(); + } + }); + + expect(provider.loadCount, 1); + }); + + test('hands out a static provider token that has expired, rather than asking again', () async { + final expiry = DateTime.utc(2030); + final manager = TokenManager( + userId: 'user-1', + tokenProvider: TokenProvider.static(generateTestUserToken('user-1', expiresAt: expiry)), + ); + + // A static provider has nothing fresher to give. The server refusing it is what tells a + // guest to exchange for a new identity, and asking again only produces the same token. + final token = await withClock( + Clock.fixed(expiry.add(const Duration(hours: 1))), + manager.getToken, + ); + + expect(token.expiresAt, expiry); + expect(manager.peekToken(), isNotNull); + }); + + test('notifies once per load, not once per call for a token near its expiry', () async { + final expiry = DateTime.utc(2030); + final updated = []; + final provider = _CountingProvider((id) async => generateTestUserToken(id, expiresAt: expiry)); + final manager = TokenManager( + userId: 'user-1', + tokenProvider: provider, + onTokenUpdated: updated.add, + ); + + await withClock(Clock.fixed(expiry.subtract(const Duration(seconds: 1))), () async { + for (var i = 0; i < 4; i++) { + await manager.getToken(); + } + }); + + // A notification per call would have every listener re-running for a token that never moved. + expect(updated, hasLength(1)); + }); + test('passes the manager userId to the provider', () async { String? requestedUserId; final provider = _CountingProvider((userId) async { From ba53f98da18d9a623eed41b08e79954a24da708a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 12:44:07 +0200 Subject: [PATCH 58/97] fix(llc): stop an auth retry crossing a user switch A request signed for one user was retried with whoever the `TokenManager` held by then, so it was performed as them and the caller was answered as though their own request had succeeded. The retry now only goes out for the user the request was signed for. Also reports the stack trace of where a token load failed rather than where the failure was caught, and records the engine's refusal to open a second connection over a live one. Co-Authored-By: Claude Opus 5 --- packages/stream_core/CHANGELOG.md | 3 +++ .../api/interceptors/auth_interceptor.dart | 12 ++++++---- .../interceptors/auth_interceptor_test.dart | 23 +++++++++++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 099e0c6a..574ebe97 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -45,6 +45,7 @@ - Fixed a WebSocket engine that reported a closure to its listener only when the close succeeded. It now reports one however the close went, and even when there was no socket to close, so a client waiting to hear the connection is down is no longer left waiting on a socket it can never use. It also lets go of a socket that failed to close, rather than holding one it cannot use, and reports a closure once rather than again when the socket's stream ends - Fixed a request that met a second token-expired response never completing at all. A request is now retried at most once - Fixed a retried request re-sending a multipart body whose streams the refused attempt had already consumed +- Fixed the failure to load a token reporting the stack trace of where it was caught rather than where the load failed - Fixed a rejected request expiring a token that another request had already replaced; only the token a request actually carried is expired now - Fixed `TokenManager.getToken()` contacting the `TokenProvider` on every call instead of returning the cached token - `TokenManager.getToken` now replaces a cached token that has expired, rather than handing it out and learning the same thing from a refused request. Judged on the expiry alone, so a token with life left in it is still cached. A static provider is left alone: it has nothing fresher to give, and the server refusing its token is what tells a guest to exchange for a new identity @@ -68,6 +69,8 @@ - `TokenManager.getToken` fails when `reset` runs while the token is loading; a `setTokenProvider` during a load still serves the caller that started it - `TokenManager.getToken` rejects a token whose `user_id` is not the user it was loading for - `AuthInterceptor` no longer attempts a token refresh when the manager has no identity, so the original token-expired error is surfaced +- `AuthInterceptor` no longer retries a request signed for a user the `TokenManager` has since been pointed away from. The retry would have carried the new user's credentials and performed one user's request as another, answering the caller as though their own had succeeded +- `StreamWebSocketEngine.open` throws when a connection is already open, rather than closing it to make room. Closing it reported a closure from inside `connect`, which brought the new connection down as it was being established ## 0.4.0 diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index cd605905..c44c24f9 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -45,7 +45,7 @@ class AuthInterceptor extends Interceptor { final dioError = StreamDioException( exception: error, requestOptions: options, - stackTrace: StackTrace.current, + stackTrace: stackTrace, ); return handler.reject(dioError, true); @@ -63,9 +63,11 @@ class AuthInterceptor extends Interceptor { final options = err.requestOptions; - // Nothing to refresh with when there is no user to load a token for, or when the provider - // would only return the same one again. - final canRefresh = _tokenManager.userId != null && !_tokenManager.usesStaticProvider; + // Nothing to refresh with when the provider would only return the same token again, and nobody + // to refresh for when the manager has since been pointed at another user: the retry would carry + // their credentials and perform this request as them. + final signedFor = options.queryParameters['user_id']; + final canRefresh = signedFor == _tokenManager.userId && !_tokenManager.usesStaticProvider; if (!canRefresh) return handler.next(err); // And only once per request: if the replacement token is refused too, the error is surfaced to @@ -92,7 +94,7 @@ class AuthInterceptor extends Interceptor { final response = await _dio.fetch(retry); return handler.resolve(response); } on DioException catch (exception) { - return handler.next(exception); + return handler.reject(exception); } } } diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index ee852596..72725aa1 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -442,6 +442,29 @@ void main() { expect(subject.api.count, 1); }); + test('does not retry a request signed for a user the manager has moved on from', () async { + // The manager is pointed at another user while the request is in flight. Retrying would sign + // it with their token and perform one user's request as another, and answer the caller as + // though their own had succeeded. + late TokenManager tokens; + final subject = _subject( + userId: 'user-a', + api: _FakeApi( + refusals: 10, + onRequest: () => tokens.setTokenProvider( + 'user-b', + tokenProvider: TokenProvider.dynamic((id) async => generateTestUserToken(id)), + ), + ), + ); + tokens = subject.tokens; + + await expectLater(subject.dio.get('/test'), throwsA(_expiredTokenError)); + + expect(subject.api.count, 1); + expect(subject.api.sentTokens.single, isNot(contains('user-b'))); + }); + test('forwards the error when the manager has no identity left to load a token for', () async { late TokenManager tokens; final subject = _subject(api: _FakeApi(refusals: 10, onRequest: () => tokens.reset())); From f95b138cf8dcf0ea19f0f38a37392435e2f1bdab Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 12:44:29 +0200 Subject: [PATCH 59/97] fix(llc): record an authentication failure on a connection already down An authenticator that gave up after its attempt had been abandoned was discarded, because a closure was already recorded. The reason on record stayed `ConnectTimeout`, which is eligible for a reconnection, so the client reconnected and presented the credentials the authenticator had just given up on. A reason that rules a reconnection out is now recorded even on a connection already down, which is the rule `UserInitiated` was the only source using. Co-Authored-By: Claude Opus 5 --- packages/stream_core/CHANGELOG.md | 1 + .../ws/client/stream_web_socket_client.dart | 6 +-- .../connection_recovery_handler_test.dart | 46 +++++++++++++++++++ .../client/stream_web_socket_client_test.dart | 29 ++++++++++++ 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 574ebe97..98727cd6 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -43,6 +43,7 @@ - `StreamWebSocketClient` no longer prints to the console. It announced every connection state change, every pong and every ping, which is noise a consumer cannot turn off and cannot act on - Fixed `StreamWebSocketClient.connect` leaking the socket of a connection whose handshake failed. The socket is opened before the handshake it fails, and the client reported the connection closed without closing it, so the socket stayed open and unreachable — `dispose` did not close it either, and the next attempt closed it instead, reporting a closure while that attempt was still connecting - Fixed a WebSocket engine that reported a closure to its listener only when the close succeeded. It now reports one however the close went, and even when there was no socket to close, so a client waiting to hear the connection is down is no longer left waiting on a socket it can never use. It also lets go of a socket that failed to close, rather than holding one it cannot use, and reports a closure once rather than again when the socket's stream ends +- Fixed an authentication failure being discarded when it landed on a connection already recorded as closed for a reason worth retrying, leaving the connection eligible for a reconnection that would present the credentials the authenticator had just given up on - Fixed a request that met a second token-expired response never completing at all. A request is now retried at most once - Fixed a retried request re-sending a multipart body whose streams the refused attempt had already consumed - Fixed the failure to load a token reporting the stack trace of where it was caught rather than where the load failed diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index e41a1672..58654592 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -186,9 +186,9 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // If no connection was ever opened, there is nothing to close. if (connectionState.value case Initialized()) return; - // A disconnection the client decided on does not relabel one already recorded or under way, - // while one the caller asked for does, so nothing reconnects after it. - final forceDisconnect = source is UserInitiated; + // A disconnection the client decided on does not relabel one already recorded or under way. A + // reason that rules a reconnection out does, or something pending would reconnect past it. + final forceDisconnect = source is UserInitiated || source is AuthenticationFailed; if (connectionState.value case Disconnecting() when !forceDisconnect) return; if (connectionState.value case Disconnected() when !forceDisconnect) return; diff --git a/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart b/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart index 7b437779..b7429d55 100644 --- a/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart +++ b/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:fake_async/fake_async.dart'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; +import '../../../helpers/user_token.dart'; import '../../../helpers/ws_client_tester.dart'; /// Long enough for a health check to go unanswered, which is the drop this handler recovers from. @@ -114,6 +117,49 @@ void main() { }); }); + test('stops retrying once an authenticator gives up', () { + fakeAsync((async) { + final loaded = Completer(); + var attempts = 0; + final tester = buildTester( + recover: true, + // The first attempt authenticates. Every one after it waits on credentials that never + // arrive, and then reports that there are none to send. + authenticator: (send, _) async { + if (++attempts > 1) { + await loaded.future; + throw StateError('nothing left to offer'); + } + send(WsAuthMessageRequest(token: generateTestUserToken('luke_skywalker').rawValue)).getOrThrow(); + }, + ); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + // Dropped, so this handler retries. That attempt hangs and is abandoned by the connect + // timeout, which schedules another. + tester.server.hangUp(); + async.flushMicrotasks(); + async.elapse(WebSocketOptions.defaultConnectTimeout); + final retried = tester.attempts; + + loaded.complete(); + async.flushMicrotasks(); + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + + async.elapse(const Duration(minutes: 2)); + + // Credentials the authenticator has given up on cannot be repaired by trying again, so a + // retry already scheduled must not go ahead either. + expect(tester.attempts, retried); + }); + }); + test('does not retry a disconnect the caller asked for', () { fakeAsync((async) { final tester = buildTester(recover: true); diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index 58592845..c31ea160 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -366,6 +366,35 @@ void main() { }); }); + test('records the failure when nothing replaced it, so it is not reconnected', () { + fakeAsync((async) { + // A token load that outlives the attempt the timeout abandoned and then fails. Nothing has + // replaced that attempt, so this failure is the last word on it. + final loaded = Completer(); + final tester = buildTester( + authenticator: (_, _) async { + await loaded.future; + throw StateError('token load failed'); + }, + ); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + async.elapse(WebSocketOptions.defaultConnectTimeout); + expect(tester.connectionState, isA()); + + loaded.complete(); + async.flushMicrotasks(); + + // Nothing can repair credentials the authenticator has given up on, so an attempt left + // eligible for a reconnection would be refused the same way. + final state = tester.connectionState; + expect(state, isA().having((it) => it.source, 'source', isA())); + expect(state.isAutomaticReconnectionEnabled, isFalse); + }); + }); + test('does not close the connection that replaced it when its credentials fail', () { fakeAsync((async) { final loaded = Completer(); From b5c34eef02f7f848df416e8ea01e543b4d3cd720 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 12:53:38 +0200 Subject: [PATCH 60/97] refactor(llc): ask a disconnection source whether it is worth reconnecting `isAutomaticReconnectionEnabled` decided this from a switch over the source, so `ConnectionRecoveryHandler` had to read a state back off the client to learn something about a source it had just been handed. That switch is now `DisconnectionSource.isReconnectable`, and the state getter is a lookup of it. Which also fixes the handler retrying an attempt the caller made and was handed the outcome of. Only a disconnect the caller asked for handed connecting back to them, so after any other closure it stopped at, the caller's next failed attempt was retried behind them. Co-Authored-By: Claude Opus 5 --- packages/stream_core/CHANGELOG.md | 2 + .../connection_recovery_handler.dart | 8 ++-- .../client/web_socket_connection_state.dart | 32 +++++++++------ .../connection_recovery_handler_test.dart | 40 +++++++++++++++++++ 4 files changed, 66 insertions(+), 16 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 98727cd6..96f434c3 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -29,6 +29,7 @@ - Added `TokenManager.reset`, which drops the configured identity and its cached token - Added `DisconnectionSource.connectTimeout`, reported when a connection attempt is abandoned before it is established, and eligible for automatic reconnection - Added `DisconnectionSource.authenticationFailed`, reported with its cause when a connection opens but cannot be authenticated +- Added `DisconnectionSource.isReconnectable`, whether a connection closed for that reason is worth opening again. `WebSocketConnectionState.isAutomaticReconnectionEnabled` is this and nothing else, for a `Disconnected` state - Added `WsRequestSender`, the send capability handed to a `WebSocketAuthenticator`. It belongs to the connection attempt it was handed to and fails once that attempt is no longer the one in flight, so an authenticator still awaiting credentials for an abandoned attempt cannot send them over the connection that replaced it, nor close it as `AuthenticationFailed` - Added `ConnectUserDetailsRequest.fromUser`, which builds the details a client may send from a `User` - Added `StreamWebSocketClient.dispose`, which closes the connection along with `events` and `connectionState`; the client is now `Disposable` @@ -43,6 +44,7 @@ - `StreamWebSocketClient` no longer prints to the console. It announced every connection state change, every pong and every ping, which is noise a consumer cannot turn off and cannot act on - Fixed `StreamWebSocketClient.connect` leaking the socket of a connection whose handshake failed. The socket is opened before the handshake it fails, and the client reported the connection closed without closing it, so the socket stayed open and unreachable — `dispose` did not close it either, and the next attempt closed it instead, reporting a closure while that attempt was still connecting - Fixed a WebSocket engine that reported a closure to its listener only when the close succeeded. It now reports one however the close went, and even when there was no socket to close, so a client waiting to hear the connection is down is no longer left waiting on a socket it can never use. It also lets go of a socket that failed to close, rather than holding one it cannot use, and reports a closure once rather than again when the socket's stream ends +- Fixed `ConnectionRecoveryHandler` retrying an attempt the caller made and was handed the outcome of, when it followed a closure the handler had stopped at. Only a disconnect the caller asked for handed connecting back to them; any other reason that rules a reconnection out now does too - Fixed an authentication failure being discarded when it landed on a connection already recorded as closed for a reason worth retrying, leaving the connection eligible for a reconnection that would present the credentials the authenticator had just given up on - Fixed a request that met a second token-expired response never completing at all. A request is now retried at most once - Fixed a retried request re-sending a multipart body whose streams the refused attempt had already consumed diff --git a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart index 265b3829..280d86bb 100644 --- a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart +++ b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart @@ -172,10 +172,12 @@ class ConnectionRecoveryHandler extends Disposable { return _reconnectStrategy.resetConsecutiveFailures(); } - // A disconnect the caller asked for hands connecting back to them, - // so the next `connect` is a fresh attempt they await rather than a drop to recover. + // A closure this handler will never act on hands connecting back to the caller, so the next + // `connect` is a fresh attempt they await rather than a drop to recover. Only the source is + // consulted: the network and the lifecycle are checked when a reconnection is attempted, and a + // drop while either is against us is still one to recover once it is not. void _onConnectionLost(DisconnectionSource source) { - if (source is UserInitiated) { + if (!source.isReconnectable) { _hasEstablishedConnection = false; return _cancelReconnection(); } diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index 182a3d73..8acfc150 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -105,19 +105,7 @@ sealed class WebSocketConnectionState extends Equatable { /// - Client errors (4xx status codes), other than a rate limit or an expired token /// - A failure to load or send credentials ([AuthenticationFailed]) bool get isAutomaticReconnectionEnabled => switch (this) { - Disconnected(:final source) => switch (source) { - ServerInitiated(:final error) when error?.code == CloseCode.normalClosure => false, - ServerInitiated(:final error) => switch (error?.apiError) { - final it? when it.isInvalidTokenError => false, - final it? when it.isClientError && !it.isRateLimitError && !it.isTokenExpiredError => false, - _ => true, // Reconnect on other server initiated disconnections - }, - UnHealthyConnection() => true, - SystemInitiated() => true, - ConnectTimeout() => true, - UserInitiated() => false, - AuthenticationFailed() => false, - }, + Disconnected(:final source) => source.isReconnectable, _ => false, // No automatic reconnection for other states }; @@ -282,6 +270,24 @@ sealed class DisconnectionSource extends Equatable { }; } + /// Whether a connection closed for this reason is worth opening again. + /// + /// See [WebSocketConnectionState.isAutomaticReconnectionEnabled] for what is and is not + /// reconnected, which is decided by this alone. + bool get isReconnectable => switch (this) { + ServerInitiated(:final error) when error?.code == CloseCode.normalClosure => false, + ServerInitiated(:final error) => switch (error?.apiError) { + final it? when it.isInvalidTokenError => false, + final it? when it.isClientError && !it.isRateLimitError && !it.isTokenExpiredError => false, + _ => true, // Reconnect on other server initiated disconnections + }, + UnHealthyConnection() => true, + SystemInitiated() => true, + ConnectTimeout() => true, + UserInitiated() => false, + AuthenticationFailed() => false, + }; + @override List get props => []; } diff --git a/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart b/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart index b7429d55..b89f22fc 100644 --- a/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart +++ b/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart @@ -160,6 +160,46 @@ void main() { }); }); + test('hands connecting back after a closure it will not act on', () { + fakeAsync((async) { + var calls = 0; + final tester = buildTester( + recover: true, + // The retry this handler makes gives up on authentication, which is not reconnected. + authenticator: (send, _) async { + if (++calls == 2) throw StateError('nothing left to offer'); + send(WsAuthMessageRequest(token: generateTestUserToken('luke_skywalker').rawValue)).getOrThrow(); + }, + ); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + tester.server.hangUp(); + async.flushMicrotasks(); + async.elapse(const Duration(seconds: 1)); + async.flushMicrotasks(); + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + + // A fresh attempt, awaited by whoever made it, that never connects. + tester.server.onFrame = (_) => []; + tester.client.connect().ignore(); + async.flushMicrotasks(); + final made = tester.attempts; + async.elapse(WebSocketOptions.defaultConnectTimeout); + async.flushMicrotasks(); + + // Having connected before the closure this handler stopped at does not make this failure its + // to retry: the caller made this attempt and was handed the outcome. + async.elapse(const Duration(minutes: 2)); + expect(tester.attempts, made); + }); + }); + test('does not retry a disconnect the caller asked for', () { fakeAsync((async) { final tester = buildTester(recover: true); From 9c666e060a1418650b0f35f2933baaca0d9216b7 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 12:59:45 +0200 Subject: [PATCH 61/97] test(llc): pin the refusal handed on after a closure not worth retrying The existing tests refuse with an expired token, which is a closure the client reconnects from on its own. An invalid signature is not, and the refusal still has to reach the attempt a caller makes next, so they are told what the last credentials were refused for. Co-Authored-By: Claude Opus 5 --- .../client/stream_web_socket_client_test.dart | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index c31ea160..e1ab9a70 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -208,6 +208,25 @@ void main() { expect(seen, [null, isA().having((it) => it.code, 'code', 40)]); }); + test('is handed on even when the closure is not one to reconnect from', () async { + final (:authenticator, :seen) = watching(); + final tester = buildTester(authenticator: authenticator); + + await tester.client.connect(); + await tester.pumpEventQueue(); + + // A refusal no other token repairs, so nothing reconnects on the client's own initiative. + await tester.emit(invalidSignatureFrame()); + expect(tester.connectionState.isAutomaticReconnectionEnabled, isFalse); + + await tester.client.connect(); + await tester.pumpEventQueue(); + + // A caller who connects again is presenting credentials of their own, and needs to be told + // what the last ones were refused for however the closure was classified. + expect(seen, [null, isA().having((it) => it.code, 'code', 43)]); + }); + test('is absent once a connection has been established', () async { final (:authenticator, :seen) = watching(); final tester = buildTester(authenticator: authenticator); From cc4ae549436d38f0ad921b55ccd75215ac0e8faf Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 15:59:59 +0200 Subject: [PATCH 62/97] fix(llc): report why a handshake failed, so a retry does not end the recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `connect` closed the socket of a failed attempt through the engine's default close code. A closure carrying that code is never reconnected, so an established connection that dropped and then failed to upgrade on its retry had its recovery cancelled and stayed down for good — indistinguishable from a clean server close, with the original error discarded. The attempt now reports the error that actually failed it before letting go of the socket, which leaves the closure reconnectable and carrying its cause. Reproduced before fixing: established -> drop -> retry that cannot upgrade left `attempts` pinned at 2 forever. Both new tests fail without the fix. Also corrects the docs that described the old behaviour: `connect` says a failure is reported through `connectionState` rather than thrown, and `ConnectionRecoveryHandler` no longer claims the caller was handed it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 13 ++-- .../connection_recovery_handler.dart | 20 +++---- .../ws/client/stream_web_socket_client.dart | 60 ++++++++++--------- .../test/helpers/ws_client_tester.dart | 9 ++- .../connection_recovery_handler_test.dart | 26 ++++++++ .../client/stream_web_socket_client_test.dart | 19 ++++++ 6 files changed, 102 insertions(+), 45 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 96f434c3..71e26994 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -4,10 +4,12 @@ - Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `User.anonymousUserId` - Removed the `TokenManager.tokenProvider` setter, use `setTokenProvider` instead -- `StreamWebSocketClient` now takes an `optionsBuilder` instead of `options`, and calls it for every connection attempt +- `StreamWebSocketClient` now takes an `optionsBuilder` instead of `options`, a `WebSocketOptionsBuilder` it calls for every connection attempt - Renamed `StreamWebSocketClient.onConnectionEstablished` to `onAuthenticate` - `StreamWebSocketClient.onAuthenticate` is now a `WebSocketAuthenticator`: it is handed a `WsRequestSender` and throws to say the credentials did not go out, whether sending failed or it chose not to send them. That closes the connection as `AuthenticationFailed`, which is not retried -- `WsRequestSender` and `WebSocketAuthenticator` now live in `web_socket_authentication_handler.dart` and are exported as before. The handler that runs them, and remembers what the server refused, is internal +- `WsRequestSender` and `WebSocketAuthenticator` are new, and live in `web_socket_authentication_handler.dart`. The handler that runs them, and remembers what the server refused, is internal +- Removed `WebSocketEngineException.stopErrorCode`, use `CloseCode.normalClosure` +- `AuthInterceptor` extends `Interceptor` rather than `QueuedInterceptor`, so requests are no longer serialised against one another. A queue slot is only freed once a handler completes, so the retry sent from `onError` waited behind the request still holding one and neither finished. `TokenManager` serialises the token loads, which is the part that needs it - `WebSocketAuthenticator` is also handed `previousError`, the error the server closed the previous attempt with, and null on a first attempt and once a connection has been established. It tells the authenticator whether the credentials it last sent are why the attempt failed, so it can replace them rather than offer refused ones for the life of the client. An attempt abandoned before its authenticator finished leaves the refusal behind for the attempt that replaces it, which has yet to answer it - The `previousError` handed to a `WebSocketAuthenticator` is now forgotten when the caller disconnects, as well as when a connection is established. A caller that disconnects takes connecting back, and what they connect with next is theirs to decide — another user, whose credentials the refusal says nothing about. A connect made straight after a disconnect with credentials that are still spent is refused once, and the connect after that is told and succeeds - `TokenManager.userId` is now nullable, and is `null` until an identity is configured @@ -27,7 +29,7 @@ - Added `User.anonymousUserId`, the id every anonymous user has - Added `TokenManager.unconfigured`, for a client that exists before its user does - Added `TokenManager.reset`, which drops the configured identity and its cached token -- Added `DisconnectionSource.connectTimeout`, reported when a connection attempt is abandoned before it is established, and eligible for automatic reconnection +- Added `DisconnectionSource.connectTimeout`, reported when a connection attempt is abandoned before it is established. Eligible for automatic reconnection, which recovers a connection that timed out on its way back but not a first one that never landed — that attempt belongs to whoever made it - Added `DisconnectionSource.authenticationFailed`, reported with its cause when a connection opens but cannot be authenticated - Added `DisconnectionSource.isReconnectable`, whether a connection closed for that reason is worth opening again. `WebSocketConnectionState.isAutomaticReconnectionEnabled` is this and nothing else, for a `Disconnected` state - Added `WsRequestSender`, the send capability handed to a `WebSocketAuthenticator`. It belongs to the connection attempt it was handed to and fails once that attempt is no longer the one in flight, so an authenticator still awaiting credentials for an abandoned attempt cannot send them over the connection that replaced it, nor close it as `AuthenticationFailed` @@ -36,14 +38,14 @@ - `StreamWebSocketClient.connect` now throws a `StateError` once the client has been disposed, in release builds as well as debug. It previously asserted and then returned, so a release build opened a socket nothing could observe or close: the emitters are shut, so no state change is reported, and the health monitor that would tear an idle connection down is stopped - `StreamWebSocketClient.disconnect` returns without reporting a closure when no connection was ever opened, so a `connect` made straight afterwards is not refused for racing a close that is not happening. An explicit disconnect also now takes over a closure already recorded or under way, which is what calls off a scheduled reconnection - Added `teams` field to `User` class -- `StreamWebSocketClient` now honours `WebSocketOptions.connectTimeout`, no longer nullable and 30 seconds by default, so an attempt that never becomes usable is abandoned and reconnected rather than waited on indefinitely +- `StreamWebSocketClient` now honours `WebSocketOptions.connectTimeout`, no longer nullable and 30 seconds by default, so an attempt that never becomes usable is abandoned rather than waited on indefinitely. A connection lost this way on its way back is reconnected; a first one that never landed is reported through `connectionState` and left there ### 🐛 Bug Fixes - Fixed a token-expired response never being retried when the server sent it without a JSON content type, so Dio handed the body over as a string. The same body was already read that way when the error was surfaced to the caller, so a request was reported as refused for an expired token without the token ever being replaced - `StreamWebSocketClient` no longer prints to the console. It announced every connection state change, every pong and every ping, which is noise a consumer cannot turn off and cannot act on - Fixed `StreamWebSocketClient.connect` leaking the socket of a connection whose handshake failed. The socket is opened before the handshake it fails, and the client reported the connection closed without closing it, so the socket stayed open and unreachable — `dispose` did not close it either, and the next attempt closed it instead, reporting a closure while that attempt was still connecting -- Fixed a WebSocket engine that reported a closure to its listener only when the close succeeded. It now reports one however the close went, and even when there was no socket to close, so a client waiting to hear the connection is down is no longer left waiting on a socket it can never use. It also lets go of a socket that failed to close, rather than holding one it cannot use, and reports a closure once rather than again when the socket's stream ends +- Fixed a WebSocket engine that reported no closure at all when there was no socket to close, so a client waiting to hear the connection is down was left waiting on one it could never use. A close that fails is still reported as a failure rather than a closure, and `StreamWebSocketClient` is what announces the closure in that case. The engine also lets go of a socket that failed to close, rather than holding one it cannot use, and reports a closure once rather than again when the socket's stream ends - Fixed `ConnectionRecoveryHandler` retrying an attempt the caller made and was handed the outcome of, when it followed a closure the handler had stopped at. Only a disconnect the caller asked for handed connecting back to them; any other reason that rules a reconnection out now does too - Fixed an authentication failure being discarded when it landed on a connection already recorded as closed for a reason worth retrying, leaving the connection eligible for a reconnection that would present the credentials the authenticator had just given up on - Fixed a request that met a second token-expired response never completing at all. A request is now retried at most once @@ -61,6 +63,7 @@ - Fixed `isAutomaticReconnectionEnabled` refusing neither a deliberate close (code 1000) nor client errors, neither of which ever matched - Fixed a connection closed for a rate limit not being eligible for automatic reconnection, since a rate limit clears on its own - Fixed `ConnectionRecoveryHandler` retrying a first connection attempt, which reconnected behind the caller of `connect`; only established connections are recovered now +- Fixed a handshake that failed being recorded as a deliberate close, which ended the recovery it was part of. `connect` closed the socket of a failed attempt with the engine's default close code, and a closure with that code is never reconnected — so an established connection that dropped, then failed to upgrade on the retry, stayed down for good instead of trying again. The attempt now reports the error that actually failed it - Fixed a health check arriving while disconnecting reporting the connection as established again, turning a deliberate disconnect into a reconnect ### 🔄 Changed diff --git a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart index 280d86bb..d67a526f 100644 --- a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart +++ b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart @@ -18,9 +18,10 @@ import 'retry_strategy.dart'; /// when reconnection should occur, implementing exponential backoff with jitter for optimal /// retry behavior. /// -/// Only connections that were established are recovered. A first attempt that fails is left to -/// whoever called [StreamWebSocketClient.connect] and was handed the failure, so it is not retried -/// here, not even when the network returns. +/// Only connections that were established are recovered. A first attempt that fails is reported +/// through [StreamWebSocketClient.connectionState] and left there, so it is not retried here, not +/// even when the network returns; making another belongs to whoever called +/// [StreamWebSocketClient.connect]. /// /// ## Built-in Policies /// @@ -83,8 +84,8 @@ class ConnectionRecoveryHandler extends Disposable { late final _subscriptions = CompositeSubscription(); - // Whether a connection has been established since the caller last asked for one. Separates a drop - // this handler recovers from an attempt whose outcome the caller is still waiting on. + // Whether a connection has been established and has not since closed for a reason this handler + // will never act on. Separates a drop this handler recovers from an attempt that never landed. var _hasEstablishedConnection = false; /// Attempts reconnection if policies allow it. @@ -168,14 +169,13 @@ class ConnectionRecoveryHandler extends Disposable { // Keeping the connection is this handler's job from here, // and the accumulated backoff failures no longer apply. void _onConnectionEstablished() { - _hasEstablishedConnection = true; // Remember that a connection was established. + _hasEstablishedConnection = true; return _reconnectStrategy.resetConsecutiveFailures(); } - // A closure this handler will never act on hands connecting back to the caller, so the next - // `connect` is a fresh attempt they await rather than a drop to recover. Only the source is - // consulted: the network and the lifecycle are checked when a reconnection is attempted, and a - // drop while either is against us is still one to recover once it is not. + // A closure this handler will never act on leaves the next `connect` to whoever makes it. Only the + // source is consulted here; the network and lifecycle are checked when a reconnection is actually + // attempted, so a drop that lands while either is down is still one to recover once it is back. void _onConnectionLost(DisconnectionSource source) { if (!source.isReconnectable) { _hasEstablishedConnection = false; diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index 58654592..6c05267c 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -22,8 +22,6 @@ WsRequest _defaultPingRequestBuilder([HealthCheckInfo? info]) { /// A function that builds the options for a connection attempt. /// /// Called once per attempt, so the options can change between attempts. -/// -/// Returns the [WebSocketOptions] to open the connection with. typedef WebSocketOptionsBuilder = WebSocketOptions Function(); /// A WebSocket client with connection management and event handling. @@ -39,9 +37,12 @@ typedef WebSocketOptionsBuilder = WebSocketOptions Function(); /// ## Example /// ```dart /// final client = StreamWebSocketClient( -/// optionsBuilder: () => WebSocketOptions(url: 'wss://api.example.com'), -/// messageCodec: JsonMessageCodec(), -/// onAuthenticate: (send, _) async => send(AuthRequest(token: authToken)).getOrThrow(), +/// optionsBuilder: () => const WebSocketOptions(url: 'wss://api.example.com'), +/// messageCodec: const JsonCodec(), +/// onAuthenticate: (send, _) async { +/// final token = await tokenManager.getToken(); +/// send(WsAuthMessageRequest(token: token.rawValue)).getOrThrow(); +/// }, /// ); /// /// await client.connect(); @@ -82,8 +83,8 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, late final WebSocketAuthenticationHandler _authenticationHandler; late final _healthMonitor = WebSocketHealthMonitor(listener: this); - // Bounds an attempt while it is 'connecting' or 'authenticating'; - // the health monitor takes over once it is established. + // Bounds an attempt while it is `Connecting` or `Authenticating`; the health monitor takes over + // once it is established. Timer? _connectTimeoutTimer; void _startConnectTimeout(Duration timeout) { @@ -132,13 +133,13 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, /// Establishes a WebSocket connection. /// - /// The connection state can be monitored through [connectionState] for real-time updates. /// If the connection is already established or in progress, this method returns immediately, /// as it does while a previous connection is still closing. /// /// Returns a [Future] that completes once the socket is open, before the connection is /// authenticated and well before it is [Connected]. Watch [connectionState] to know when it - /// is usable. + /// is usable, and to see an attempt that failed: a failure is reported there rather than thrown, + /// so an attempt that never lands leaves the state [Disconnected] and nothing else. /// /// Throws a [StateError] once [dispose] has been called. Future connect() async { @@ -151,8 +152,6 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, if (connectionState.value is Connecting) return; if (connectionState.value is Authenticating) return; if (connectionState.value is Connected) return; - - // If the previous connection is still closing, do not initiate a new connection. if (connectionState.value is Disconnecting) return; // Update the connection state to 'connecting'. @@ -164,17 +163,22 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Bound the attempt, so one that never becomes usable is not waited on indefinitely. _startConnectTimeout(options.connectTimeout); final result = await _engine.open(options); - - // If some failure occurs, close the socket the attempt opened. - return result.recover((_, _) => _engine.close()).getOrThrow(); + if (result case Failure(:final error, :final stackTrace)) { + // Report the failure before letting go of the socket. Closed first, it would read as the + // deliberate close the engine defaults to, which is never reconnected — ending any recovery. + onError(error, stackTrace); + await _engine.close(); + } } /// Closes the WebSocket connection. /// /// When [closeCode] is provided, uses the specified close code for the disconnection. /// The [source] indicates the reason for disconnection and affects reconnection behavior. - /// A [UserInitiated] disconnection takes effect even on a connection that is already down, - /// which is what calls off a reconnection waiting to be made. + /// + /// A [UserInitiated] or [AuthenticationFailed] disconnection takes effect even on a connection + /// that is already down or on its way down, which is what calls off a reconnection waiting to be + /// made. Every other source leaves a closure already recorded as it is. /// /// Returns a [Future] that completes when the disconnection finishes. Future disconnect({ @@ -183,11 +187,10 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, }) async { _cancelConnectTimeout(); - // If no connection was ever opened, there is nothing to close. if (connectionState.value case Initialized()) return; - // A disconnection the client decided on does not relabel one already recorded or under way. A - // reason that rules a reconnection out does, or something pending would reconnect past it. + // A source that rules a reconnection out takes over a closure already recorded or under way, or + // something already pending would reconnect past it. Every other source leaves that closure be. final forceDisconnect = source is UserInitiated || source is AuthenticationFailed; if (connectionState.value case Disconnecting() when !forceDisconnect) return; if (connectionState.value case Disconnected() when !forceDisconnect) return; @@ -198,8 +201,9 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Close the connection using the engine. final result = await _engine.close(closeCode, source.closeReason); - // If the close fails, report the closure directly so nothing is left disconnecting. - return result.recover((_, _) => onClose(closeCode, source.closeReason)).getOrThrow(); + // The engine announces a closure only when the socket actually closed, so a close that failed is + // reported here instead, leaving nothing stuck disconnecting. + if (result.isFailure) onClose(closeCode, source.closeReason); } @override @@ -207,7 +211,7 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Update the connection state to 'authenticating'. _connectionState = const WebSocketConnectionState.authenticating(); - // The connection has been established, so authenticate before it can be used. + // The socket is open but not yet usable: the credentials go out before the server will serve it. unawaited(_authenticationHandler.authenticate()); } @@ -222,7 +226,7 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, error: WebSocketEngineException(code: closeCode, reason: closeReason), ), - // Not meaningful to transition from these; just log and bail. + // Not meaningful to transition from these. Initialized() || Disconnected() => null, }; @@ -241,8 +245,8 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Update the connection state to 'disconnecting' with the source. // - // Note: We don't have to use `Disconnected` state here because the socket - // automatically closes the connection after sending the error. + // The socket closes itself after reporting an error, so the closure that follows is what records + // the disconnection. _connectionState = WebSocketConnectionState.disconnecting(source: source); } @@ -271,7 +275,8 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, } void _handleHealthCheckEvent(WsEvent event, HealthCheckInfo info) { - // Ignore a pong that arrives once the connection is on its way down. + // Handled, a late pong would report the connection established again and replace the source of + // the disconnection, turning one the caller asked for into a server close that is reconnected. if (connectionState.value case Disconnecting()) return; if (connectionState.value case Disconnected()) return; @@ -286,8 +291,7 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Emit the health check event. // - // Note: We send the event even after handling it to allow - // listeners to react to it if needed. + // Emitted as well as handled, so a listener can react to it too. _events.emit(event); } diff --git a/packages/stream_core/test/helpers/ws_client_tester.dart b/packages/stream_core/test/helpers/ws_client_tester.dart index e47938ba..ab5efcb6 100644 --- a/packages/stream_core/test/helpers/ws_client_tester.dart +++ b/packages/stream_core/test/helpers/ws_client_tester.dart @@ -107,7 +107,7 @@ class WsClientTester { /// Releases everything this tester holds. /// - /// [wsClientTest] calls this for you. A test driving `fakeAsync` must not: these futures were + /// [wsClientTest] calls this. A test driving `fakeAsync` must not: these futures were /// created inside the fake zone, and awaiting them once it has been discarded never returns. /// Nothing outlives that zone, so there is nothing left to release. Future dispose() async { @@ -164,6 +164,7 @@ void wsClientTest( tokenLoader: tokenLoader, authenticator: authenticator, authenticates: authenticates, + tokens: tokens, recover: recover, connectTimeout: connectTimeout, handshakeFails: handshakeFails, @@ -192,6 +193,9 @@ Future _defaultConnect(WsClientTester tester) async { /// /// A test that drives timers wraps its own `fakeAsync` and cannot await a connect across it, so it /// calls this and connects by hand. Everything else should use [wsClientTest]. +/// +/// [handshakeFailsWhen] is asked before each attempt, for a test where only some of them fail. It +/// takes precedence over [handshakeFails], which applies to every attempt alike. WsClientTester buildTester({ String user = 'luke_skywalker', Future Function(String userId)? tokenLoader, @@ -201,6 +205,7 @@ WsClientTester buildTester({ bool recover = false, Duration? connectTimeout, bool handshakeFails = false, + bool Function()? handshakeFailsWhen, bool handshakeHangs = false, bool holdClose = false, Object? closeError, @@ -229,7 +234,7 @@ WsClientTester buildTester({ }; }, wsProvider: (_) => server.connect( - handshakeFails: handshakeFails, + handshakeFails: handshakeFailsWhen?.call() ?? handshakeFails, handshakeHangs: handshakeHangs, holdClose: holdClose, closeError: closeError, diff --git a/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart b/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart index b89f22fc..d44779bf 100644 --- a/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart +++ b/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart @@ -51,6 +51,32 @@ void main() { }); }); + test('keeps recovering after a retry fails its handshake', () { + fakeAsync((async) { + var handshakeFails = false; + final tester = buildTester(recover: true, handshakeFailsWhen: () => handshakeFails); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + // The connection drops, and no retry can upgrade from here. + handshakeFails = true; + tester.server.onFrame = (_) => []; + async.elapse(_untilUnhealthy); + async.flushMicrotasks(); + + final afterDrop = tester.attempts; + expect(afterDrop, greaterThan(1)); + + // Recorded as the deliberate close the engine defaults to, a retry that failed to upgrade + // would have ended the recovery it was part of, leaving the connection down for good. + async.elapse(const Duration(minutes: 1)); + async.flushMicrotasks(); + expect(tester.attempts, greaterThan(afterDrop)); + }); + }); + test('hands connecting back after the caller disconnected', () { fakeAsync((async) { final tester = buildTester(recover: true); diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index e1ab9a70..15837585 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -27,6 +27,25 @@ void main() { }, ); + wsClientTest( + 'reports a handshake that failed with the reason it failed', + handshakeFails: true, + connect: _justConnect, + body: (tester) { + // Closed without saying why, the attempt reads as the deliberate close the engine defaults + // to, and a closure with that code is never reconnected. + expect( + tester.connectionState, + isA().having( + (it) => it.source, + 'source', + isA().having((it) => it.error?.error, 'error', isNotNull), + ), + ); + expect(tester.connectionState.isAutomaticReconnectionEnabled, isTrue); + }, + ); + wsClientTest( 'closes a socket whose handshake failed', handshakeFails: true, From a79072144192f5adbe68ad39e9c9e495e74f0df9 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 16:00:10 +0200 Subject: [PATCH 63/97] docs(llc): correct the connection docs that describe what the code does not do - `WebSocketEngine.close` promised the listener is told "however the close went". It is not: a close that throws skips the notification and is reported as a failure instead, which `StreamWebSocketEngine`'s own test asserts deliberately. The client is what provides that guarantee. - `WebSocketEngine.open` never mentioned that it now fails when a connection is already open, rather than closing one to make room. - `disconnect` named only `UserInitiated` as taking effect on a connection already down; `AuthenticationFailed` does too. - `isAutomaticReconnectionEnabled` listed `ConnectTimeout` as reconnected without saying that `ConnectionRecoveryHandler` recovers only a connection that was established, so a first attempt that times out stays down. It also claimed an expired token reconnects "since a fresh one is loaded", which is the authenticator's business, not this class's. - `DisconnectionSource` listed four of its six subtypes. - The `StreamWebSocketClient` example named types that do not exist. - `ConnectUserDetailsRequest.fromUser` documented neither why the name comes from `originalName` nor why `role` and `teams` are left out. Also tightens the comments this branch added, and matches `AuthInterceptor` to the style of the interceptors beside it: a one-line constructor doc, and the static constant below the fields it was sitting above. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/interceptors/auth_interceptor.dart | 35 +++++++------------ .../lib/src/api/stream_core_dio_error.dart | 6 ++-- .../user/connect_user_details_request.dart | 5 ++- .../ws/client/engine/web_socket_engine.dart | 9 +++-- .../web_socket_authentication_handler.dart | 25 ++++++------- .../client/web_socket_connection_state.dart | 16 ++++++--- 6 files changed, 47 insertions(+), 49 deletions(-) diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index c44c24f9..fd372ea1 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -4,24 +4,21 @@ import '../../errors.dart'; import '../../user.dart'; import '../stream_core_dio_error.dart'; -/// Signs every request with the caller's token, and replaces one the server refused for having -/// expired before retrying the request once. +/// Interceptor that signs every request with the caller's token. +/// +/// A request the server refuses for an expired token is retried once, carrying a replacement. class AuthInterceptor extends Interceptor { - /// Creates an [AuthInterceptor] that signs requests with the tokens `tokenManager` holds, and - /// retries a refused one through `dio`. + /// Initialize a new [AuthInterceptor]. AuthInterceptor(this._dio, this._tokenManager); - // Not a `QueuedInterceptor`: that frees a queue slot only once a handler completes, so the retry - // sent from `onError` would wait behind the request still holding it and neither would finish. - // `TokenManager` serialises the token loads, which is the part that needs serialising. - - // Marks a request that has already been retried with a replaced token. - static const _retriedKey = 'stream_core.auth_token_retried'; - final Dio _dio; - final TokenManager _tokenManager; + // Not a `QueuedInterceptor`: it frees a slot only once a handler completes, so the retry sent from + // `onError` would wait behind the request holding it. `TokenManager` serialises the token loads. + + static const _retriedKey = 'stream_core.auth_token_retried'; + @override Future onRequest( RequestOptions options, @@ -57,32 +54,24 @@ class AuthInterceptor extends Interceptor { DioException err, ErrorInterceptorHandler handler, ) async { - // Only an expired token is worth replacing. final error = err.apiError; if (error == null || !error.isTokenExpiredError) return handler.next(err); final options = err.requestOptions; - // Nothing to refresh with when the provider would only return the same token again, and nobody - // to refresh for when the manager has since been pointed at another user: the retry would carry - // their credentials and perform this request as them. + // A retry after a user switch would perform this request as the new user. final signedFor = options.queryParameters['user_id']; final canRefresh = signedFor == _tokenManager.userId && !_tokenManager.usesStaticProvider; if (!canRefresh) return handler.next(err); - // And only once per request: if the replacement token is refused too, the error is surfaced to - // the caller. if (options.extra[_retriedKey] == true) return handler.next(err); - // Expire only the token this request actually carried. Another request may have replaced it - // already, and expiring the replacement would discard a valid token. + // Another request may have replaced it already, and expiring that would discard a valid token. if (options.headers['Authorization'] == _tokenManager.peekToken()?.rawValue) { _tokenManager.expireToken(); } - // The retry is a new request rather than the refused one modified, so the options the caller - // holds are left as they were. A multipart body is cloned because the refused attempt has - // already consumed its streams. + // The multipart body is cloned because the refused attempt already consumed its streams. final data = options.data; final retry = options.copyWith( extra: {...options.extra, _retriedKey: true}, diff --git a/packages/stream_core/lib/src/api/stream_core_dio_error.dart b/packages/stream_core/lib/src/api/stream_core_dio_error.dart index 0be2afd8..90dbdf0e 100644 --- a/packages/stream_core/lib/src/api/stream_core_dio_error.dart +++ b/packages/stream_core/lib/src/api/stream_core_dio_error.dart @@ -23,9 +23,9 @@ class StreamDioException extends DioException { extension StreamDioExceptionExtension on DioException { /// The Stream API error the response carried, or `null` when it carried something else. /// - /// The body arrives decoded when the response was typed as JSON, and as a string when it was - /// not, so both are read. Anything that is not a Stream error payload reads as `null` rather - /// than throwing: a proxy or gateway can answer with a JSON body of its own. + /// Read whether or not the response was typed as JSON, so an error sent as plain text is still + /// recognised. Anything that is not a Stream error payload reads as `null` rather than throwing: + /// a proxy or gateway can answer with a JSON body of its own. StreamApiError? get apiError { return runSafelySync(() { return switch (response?.data) { diff --git a/packages/stream_core/lib/src/user/connect_user_details_request.dart b/packages/stream_core/lib/src/user/connect_user_details_request.dart index e18be4bf..ff613352 100644 --- a/packages/stream_core/lib/src/user/connect_user_details_request.dart +++ b/packages/stream_core/lib/src/user/connect_user_details_request.dart @@ -19,11 +19,14 @@ class ConnectUserDetailsRequest { /// Creates the details to send when connecting as [user]. /// /// Pass [includeDetails] as `false` to send the id alone. + /// + /// The name comes from [User.originalName] rather than [User.name], so a user who was never given + /// one does not have their id sent as their name. [User.role] and [User.teams] are left out + /// deliberately: the server assigns both and ignores whatever a client claims for them. factory ConnectUserDetailsRequest.fromUser( User user, { bool includeDetails = true, }) { - // Only the id is sent when the details are not wanted. final details = user.takeIf((_) => includeDetails); return ConnectUserDetailsRequest( diff --git a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart index 6a46cb81..d3b5c8ea 100644 --- a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart @@ -19,6 +19,9 @@ abstract interface class WebSocketEngine { /// Creates a new WebSocket connection using the provided [options] and sets up /// event listeners. /// + /// Fails when a connection is already open; [close] it first. An implementation does not close one + /// to make room, so a caller never has a closure announced against an attempt it is still making. + /// /// Returns a [Result] indicating success or failure of the connection attempt. Future> open(WebSocketOptions options); @@ -26,9 +29,9 @@ abstract interface class WebSocketEngine { /// /// Closes the active WebSocket connection with the specified [closeCode] and [closeReason]. /// - /// The listener is told the connection closed before this completes — however the close went, and - /// even when there was no connection to close. Callers may rely on that, rather than reporting the - /// closure themselves. + /// The listener is told the connection closed before this completes, including when there was no + /// connection to close. A close that fails announces nothing and is reported as a failure instead, + /// leaving the caller to tell anyone waiting what became of the connection. /// /// Returns a [Result] indicating success or failure of the close operation. Future> close([int? closeCode, String? closeReason]); diff --git a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart index b81cadd5..613151f7 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart @@ -13,9 +13,9 @@ typedef WsRequestSender = Result Function(WsRequest request); /// /// Called while the state is [Authenticating], to send the credentials the connection requires. /// -/// `previousError` is the error the server closed the previous attempt with, or null if there was -/// none. Only the next attempt after a refusal sees it; later ones see null. Use it to replace -/// credentials that were refused. +/// `previousError` is the error the server closed the previous attempt with, and null when there was +/// none, once a connection has been established, or once the caller has disconnected. Only the next +/// attempt after a refusal sees it. Use it to replace credentials that were refused. /// /// Throw when the credentials did not go out, whether because sending failed or because this /// function chose not to send them. The connection is then closed with [AuthenticationFailed], and @@ -41,9 +41,8 @@ class WebSocketAuthenticationHandler { final WsRequestSender _send; final void Function(Object error) _onFailure; - // Identifies the attempt in flight. An authenticator awaiting credentials can outlive the attempt - // that started it, and holds a sender and a failure path that would otherwise reach whichever - // connection is current by then. + // Identifies the attempt in flight. An authenticator can outlive the attempt that started it, and + // holds a sender and a failure path that would otherwise reach whichever connection is current. var _attempt = 0; /// The error the server closed the previous attempt with, if it sent one. @@ -66,8 +65,7 @@ class WebSocketAuthenticationHandler { _previousError = switch (state) { Connected() => null, - // The caller took control, so whatever they connect with next is a decision of their own and - // may have nothing to do with the credentials that were refused. + // The caller took control; what they connect with next may have nothing to do with the refusal. Disconnected(source: UserInitiated()) => null, // The server closed without sending an error, so the last one still applies. Disconnected(source: ServerInitiated(:final error)) => error?.apiError ?? _previousError, @@ -93,15 +91,12 @@ class WebSocketAuthenticationHandler { // Guarded because nothing awaits this: an error thrown here would go unhandled. final result = await runSafely(() => authenticate(_senderFor(attempt), previousError)); - // The connection this failure belongs to is already closed, and the one that replaced it did - // not fail: reported against that one it would close a usable connection as - // `AuthenticationFailed`, which is never reconnected. The refusal is left behind with it, for - // the attempt that replaced it and has yet to answer it. + // A stale attempt. Reported now, its failure would close the connection that replaced it as + // `AuthenticationFailed`, which is never reconnected; the refusal stays armed for that one. if (attempt != _attempt) return; - // Spent, unless the server has refused something newer since: either the credentials went out, - // or the authenticator saw the refusal and had nothing else to offer, and one left armed would - // be declined again without anything being sent. + // Answered by this attempt, so it is spent — unless the server refused something newer while it + // ran, which the attempt after this one still has to see. if (_previousError == previousError) _previousError = null; if (result case Failure(:final error)) return _onFailure(error); diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index 8acfc150..03dcbc15 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -92,8 +92,8 @@ sealed class WebSocketConnectionState extends Equatable { /// the current state and disconnection source. Only applies to [Disconnected] states. /// /// ## Reconnection is enabled for: - /// - Server-initiated disconnections (except authentication and client errors) - /// - An expired token, since a fresh one is loaded before the next attempt + /// - Server-initiated disconnections, other than the cases listed below + /// - An expired token, and a rate limit, both of which a later attempt can get past /// - System-initiated disconnections (network changes, app lifecycle, etc.) /// - Unhealthy connection disconnections (missing pong responses) /// - A connection attempt abandoned for taking too long ([ConnectTimeout]) @@ -104,6 +104,11 @@ sealed class WebSocketConnectionState extends Equatable { /// - Token errors a fresh token would not fix, and a wrong API key /// - Client errors (4xx status codes), other than a rate limit or an expired token /// - A failure to load or send credentials ([AuthenticationFailed]) + /// + /// Necessary, but not on its own sufficient: `ConnectionRecoveryHandler` recovers only a + /// connection that was established, and only while the network and the app lifecycle allow it. An + /// attempt that never landed is not retried for whoever made it, whatever this reports — so a + /// first connection that times out stays down, where one that times out on the way back does not. bool get isAutomaticReconnectionEnabled => switch (this) { Disconnected(:final source) => source.isReconnectable, _ => false, // No automatic reconnection for other states @@ -212,6 +217,8 @@ final class Disconnected extends WebSocketConnectionState { /// - [ServerInitiated]: Server closed the connection, possibly with an error /// - [SystemInitiated]: System-level disconnection (network, app lifecycle) /// - [UnHealthyConnection]: Connection closed due to failed health checks +/// - [ConnectTimeout]: Attempt abandoned before the connection was established +/// - [AuthenticationFailed]: Socket opened, but its credentials never went out sealed class DisconnectionSource extends Equatable { const DisconnectionSource(); @@ -272,8 +279,9 @@ sealed class DisconnectionSource extends Equatable { /// Whether a connection closed for this reason is worth opening again. /// - /// See [WebSocketConnectionState.isAutomaticReconnectionEnabled] for what is and is not - /// reconnected, which is decided by this alone. + /// This is the whole of [WebSocketConnectionState.isAutomaticReconnectionEnabled], which lists + /// what is and is not reconnected. Whether a reconnection is then actually made is decided by + /// `ConnectionRecoveryHandler` on top of this. bool get isReconnectable => switch (this) { ServerInitiated(:final error) when error?.code == CloseCode.normalClosure => false, ServerInitiated(:final error) => switch (error?.apiError) { From 03d5648697eb11b3455de1f31092b17012fae59d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 16:00:17 +0200 Subject: [PATCH 64/97] docs(repo): follow Effective Dart on referring to parameters The guide asked for backticks on parameters that are not also properties, while Effective Dart uses square brackets so `dart doc` resolves them. Nothing in the repo followed the local rule and `comment_references` is disabled, so nothing enforced it either. Co-Authored-By: Claude Opus 5 (1M context) --- STYLE_GUIDE.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 0fe5ce0f..04191471 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -528,8 +528,12 @@ explaining the purpose of the item. Subsequent paragraphs elaborate. Avoid multi sentence first paragraphs — the first paragraph gets extracted for tables of contents. -When referencing a parameter, use backticks. When referencing a parameter that also -corresponds to a property, use square brackets instead. +When referencing a parameter, use square brackets, as +[Effective Dart](https://dart.dev/effective-dart/documentation#do-use-square-brackets-in-doc-comments-to-refer-to-in-scope-identifiers) +does — `dart doc` resolves them and links to the declaration. + +Reserve backticks for names that are *not* in scope where the comment sits: a private +constructor argument named from a class-level doc, or a type from another package. Avoid using "above" or "below" to reference other dartdoc sections. Dartdoc pages are often viewed in isolation. From ece0b67bd9863f8eda5697f18d9cbcb6a8aa2823 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 16:08:41 +0200 Subject: [PATCH 65/97] docs(llc): document what a Dio error converts into, not how it is read `toClientException` had no doc at all. `apiError` described how it reads the body rather than what a caller can rely on: that a Stream error is recognised whichever way the server sent it, and that anything else reads as null. Co-Authored-By: Claude Opus 5 (1M context) --- .../stream_core/lib/src/api/stream_core_dio_error.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/stream_core/lib/src/api/stream_core_dio_error.dart b/packages/stream_core/lib/src/api/stream_core_dio_error.dart index 90dbdf0e..19130848 100644 --- a/packages/stream_core/lib/src/api/stream_core_dio_error.dart +++ b/packages/stream_core/lib/src/api/stream_core_dio_error.dart @@ -23,9 +23,8 @@ class StreamDioException extends DioException { extension StreamDioExceptionExtension on DioException { /// The Stream API error the response carried, or `null` when it carried something else. /// - /// Read whether or not the response was typed as JSON, so an error sent as plain text is still - /// recognised. Anything that is not a Stream error payload reads as `null` rather than throwing: - /// a proxy or gateway can answer with a JSON body of its own. + /// Recognised whether the server sent it as JSON or as plain text. A body that is not a Stream + /// error — a proxy or gateway answering with one of its own — reads as `null` rather than throwing. StreamApiError? get apiError { return runSafelySync(() { return switch (response?.data) { @@ -36,6 +35,11 @@ extension StreamDioExceptionExtension on DioException { }).getOrNull(); } + /// This exception as an [HttpClientException]. + /// + /// The message and status code come from the [apiError] the response carried, falling back to what + /// the transport reported when there was none. The cause is that error, or this exception when the + /// response carried none. A request the caller cancelled is marked as such. HttpClientException toClientException() { final apiError = this.apiError; From 0763f3f419cc90b02ab64bb16011fe7aa99bf79d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 16:13:19 +0200 Subject: [PATCH 66/97] docs(llc): name whose response, and say the fallback once `apiError` said "the response" without naming whose, which Effective Dart asks for, and opened its second paragraph with a subjectless participle. `toClientException` stated the same fallback twice in consecutive sentences. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/api/stream_core_dio_error.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/stream_core/lib/src/api/stream_core_dio_error.dart b/packages/stream_core/lib/src/api/stream_core_dio_error.dart index 19130848..4a8961cb 100644 --- a/packages/stream_core/lib/src/api/stream_core_dio_error.dart +++ b/packages/stream_core/lib/src/api/stream_core_dio_error.dart @@ -21,10 +21,11 @@ class StreamDioException extends DioException { } extension StreamDioExceptionExtension on DioException { - /// The Stream API error the response carried, or `null` when it carried something else. + /// The Stream API error this exception's response carried, or `null` when it carried something else. /// - /// Recognised whether the server sent it as JSON or as plain text. A body that is not a Stream - /// error — a proxy or gateway answering with one of its own — reads as `null` rather than throwing. + /// A Stream error is recognised whether the server sent it as JSON or as plain text. A body that is + /// not one — a proxy or gateway answering with an error of its own — reads as `null` rather than + /// throwing. StreamApiError? get apiError { return runSafelySync(() { return switch (response?.data) { @@ -37,9 +38,8 @@ extension StreamDioExceptionExtension on DioException { /// This exception as an [HttpClientException]. /// - /// The message and status code come from the [apiError] the response carried, falling back to what - /// the transport reported when there was none. The cause is that error, or this exception when the - /// response carried none. A request the caller cancelled is marked as such. + /// Takes its message, status code and cause from [apiError] when the response carried one, and + /// from what the transport reported otherwise. A request the caller cancelled is marked as such. HttpClientException toClientException() { final apiError = this.apiError; From bd9b2ddd42889210da3803451c4d45537ecac52d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 16:20:32 +0200 Subject: [PATCH 67/97] refactor(llc): read a Stream error from one place, whichever way it arrived The switch parsed the body in two arms, so `StreamApiError.fromJson` and the cast to a JSON map were each written twice. Normalising a string body first leaves one parse and one shape check, and drops a nesting level with it. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/api/stream_core_dio_error.dart | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/stream_core/lib/src/api/stream_core_dio_error.dart b/packages/stream_core/lib/src/api/stream_core_dio_error.dart index 4a8961cb..a975a924 100644 --- a/packages/stream_core/lib/src/api/stream_core_dio_error.dart +++ b/packages/stream_core/lib/src/api/stream_core_dio_error.dart @@ -26,15 +26,13 @@ extension StreamDioExceptionExtension on DioException { /// A Stream error is recognised whether the server sent it as JSON or as plain text. A body that is /// not one — a proxy or gateway answering with an error of its own — reads as `null` rather than /// throwing. - StreamApiError? get apiError { - return runSafelySync(() { - return switch (response?.data) { - final Map data => StreamApiError.fromJson(data), - final String data => StreamApiError.fromJson(jsonDecode(data) as Map), - _ => null, - }; - }).getOrNull(); - } + StreamApiError? get apiError => runSafelySync(() { + final data = response?.data; + final json = data is String ? jsonDecode(data) : data; + if (json is! Map) return null; + + return StreamApiError.fromJson(json); + }).getOrNull(); /// This exception as an [HttpClientException]. /// From 616fc9cbbf83ad50cd1667e139708f45bc8cf6f1 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 16:26:12 +0200 Subject: [PATCH 68/97] docs(llc): drop what the field these two methods write already says `_hasEstablishedConnection` carries its own explanation, which left `_onConnectionEstablished`'s comment restating the assignment and the reset below it, and the first sentence of `_onConnectionLost`'s saying what the field comment already covers. What survives is the part neither says: why the network and lifecycle are not consulted here. Co-Authored-By: Claude Opus 5 (1M context) --- .../ws/client/reconnect/connection_recovery_handler.dart | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart index d67a526f..a63bebff 100644 --- a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart +++ b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart @@ -166,16 +166,13 @@ class ConnectionRecoveryHandler extends Disposable { }; } - // Keeping the connection is this handler's job from here, - // and the accumulated backoff failures no longer apply. void _onConnectionEstablished() { _hasEstablishedConnection = true; return _reconnectStrategy.resetConsecutiveFailures(); } - // A closure this handler will never act on leaves the next `connect` to whoever makes it. Only the - // source is consulted here; the network and lifecycle are checked when a reconnection is actually - // attempted, so a drop that lands while either is down is still one to recover once it is back. + // Only the source is consulted here; the network and lifecycle are checked when a reconnection is + // attempted, so a drop during an outage is still one to recover once it clears. void _onConnectionLost(DisconnectionSource source) { if (!source.isReconnectable) { _hasEstablishedConnection = false; From d08dfd36899ee37d59684be4340d8d300866fbb3 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 16:29:01 +0200 Subject: [PATCH 69/97] docs(llc): say it plainly The comments this branch added had drifted into a written-down register: participles inverted to the front of a sentence ("Handled, a late pong would...", "Closed first, it would...", "Reported now, its failure would..."), the passive where a subject would do ("the source is consulted", "is reported here"), and nouns standing in for verbs ("leaving nothing stuck", "one to recover once it clears"). Same facts, said the way someone would say them. Co-Authored-By: Claude Opus 5 (1M context) --- .../connection_recovery_handler.dart | 8 +++--- .../ws/client/stream_web_socket_client.dart | 26 +++++++++---------- .../web_socket_authentication_handler.dart | 16 ++++++------ 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart index a63bebff..714fa916 100644 --- a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart +++ b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart @@ -84,8 +84,8 @@ class ConnectionRecoveryHandler extends Disposable { late final _subscriptions = CompositeSubscription(); - // Whether a connection has been established and has not since closed for a reason this handler - // will never act on. Separates a drop this handler recovers from an attempt that never landed. + // True once a connection has been established, and false again if one closes for a reason this + // handler will not act on. Tells a drop worth recovering apart from an attempt that never landed. var _hasEstablishedConnection = false; /// Attempts reconnection if policies allow it. @@ -171,8 +171,8 @@ class ConnectionRecoveryHandler extends Disposable { return _reconnectStrategy.resetConsecutiveFailures(); } - // Only the source is consulted here; the network and lifecycle are checked when a reconnection is - // attempted, so a drop during an outage is still one to recover once it clears. + // Only the source matters here. The network and lifecycle are checked later, when a reconnect is + // actually attempted, so a drop during an outage still counts as one worth recovering. void _onConnectionLost(DisconnectionSource source) { if (!source.isReconnectable) { _hasEstablishedConnection = false; diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index 6c05267c..f7e387d3 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -83,8 +83,8 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, late final WebSocketAuthenticationHandler _authenticationHandler; late final _healthMonitor = WebSocketHealthMonitor(listener: this); - // Bounds an attempt while it is `Connecting` or `Authenticating`; the health monitor takes over - // once it is established. + // Bounds an attempt while it is `Connecting` or `Authenticating`. Once the connection is + // established, the health monitor takes over. Timer? _connectTimeoutTimer; void _startConnectTimeout(Duration timeout) { @@ -143,8 +143,8 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, /// /// Throws a [StateError] once [dispose] has been called. Future connect() async { - // A disposed client cannot report a state change or tear an idle connection down, so a socket - // opened here would be unobservable. + // A disposed client cannot report a state change or close an idle connection, so a socket opened + // here would be invisible to everyone. if (isDisposed) throw StateError('Cannot connect a disposed StreamWebSocketClient'); // If the connection is already established or in the process of connecting, @@ -160,12 +160,12 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Open the connection using the engine, with options built for this attempt. final options = optionsBuilder.call(); - // Bound the attempt, so one that never becomes usable is not waited on indefinitely. + // Bound the attempt, so one that never becomes usable is not waited on forever. _startConnectTimeout(options.connectTimeout); final result = await _engine.open(options); if (result case Failure(:final error, :final stackTrace)) { - // Report the failure before letting go of the socket. Closed first, it would read as the - // deliberate close the engine defaults to, which is never reconnected — ending any recovery. + // Report the failure before closing the socket. Close it first and the engine's default close + // code makes this look deliberate, which never reconnects and ends any recovery in progress. onError(error, stackTrace); await _engine.close(); } @@ -189,8 +189,8 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, if (connectionState.value case Initialized()) return; - // A source that rules a reconnection out takes over a closure already recorded or under way, or - // something already pending would reconnect past it. Every other source leaves that closure be. + // A source that blocks reconnection overwrites a closure already recorded or underway, or a + // pending reconnect could fire past it. Any other source leaves the existing closure alone. final forceDisconnect = source is UserInitiated || source is AuthenticationFailed; if (connectionState.value case Disconnecting() when !forceDisconnect) return; if (connectionState.value case Disconnected() when !forceDisconnect) return; @@ -201,8 +201,8 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Close the connection using the engine. final result = await _engine.close(closeCode, source.closeReason); - // The engine announces a closure only when the socket actually closed, so a close that failed is - // reported here instead, leaving nothing stuck disconnecting. + // The engine only announces a closure when the socket really closed, so report it here when the + // close failed. Otherwise the connection stays stuck disconnecting. if (result.isFailure) onClose(closeCode, source.closeReason); } @@ -275,8 +275,8 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, } void _handleHealthCheckEvent(WsEvent event, HealthCheckInfo info) { - // Handled, a late pong would report the connection established again and replace the source of - // the disconnection, turning one the caller asked for into a server close that is reconnected. + // A late pong would set the state back to connected and overwrite the disconnection's source, + // turning a deliberate disconnect into a server close that gets reconnected. if (connectionState.value case Disconnecting()) return; if (connectionState.value case Disconnected()) return; diff --git a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart index 613151f7..2d355c5a 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart @@ -41,8 +41,8 @@ class WebSocketAuthenticationHandler { final WsRequestSender _send; final void Function(Object error) _onFailure; - // Identifies the attempt in flight. An authenticator can outlive the attempt that started it, and - // holds a sender and a failure path that would otherwise reach whichever connection is current. + // Identifies the attempt in flight, because an authenticator can outlive the attempt that started + // it and still hold a sender and a failure path aimed at whatever connection is current by then. var _attempt = 0; /// The error the server closed the previous attempt with, if it sent one. @@ -91,19 +91,19 @@ class WebSocketAuthenticationHandler { // Guarded because nothing awaits this: an error thrown here would go unhandled. final result = await runSafely(() => authenticate(_senderFor(attempt), previousError)); - // A stale attempt. Reported now, its failure would close the connection that replaced it as - // `AuthenticationFailed`, which is never reconnected; the refusal stays armed for that one. + // This attempt is stale. Reporting its failure now would close the connection that replaced it + // as `AuthenticationFailed`, which never reconnects. The refusal stays armed for that one. if (attempt != _attempt) return; - // Answered by this attempt, so it is spent — unless the server refused something newer while it - // ran, which the attempt after this one still has to see. + // This attempt answered it, so it is spent — unless the server refused something newer while the + // authenticator ran, which the next attempt still needs to see. if (_previousError == previousError) _previousError = null; if (result case Failure(:final error)) return _onFailure(error); } - // An authenticator holds its sender across its own awaits, so the attempt is checked when a - // request is sent rather than once before the authenticator is called. + // An authenticator holds its sender across its own awaits, so check the attempt when a request is + // actually sent rather than once up front. WsRequestSender _senderFor(int attempt) => (request) { if (attempt == _attempt) return _send(request); From c4c083950a12c73b3f4114f5edb408203389b898 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 16:45:47 +0200 Subject: [PATCH 70/97] refactor(llc): abandon a failed attempt as a chain, not a branch Same two steps in the shape the rest of the file uses for a `Result`: report the failure, then close the socket the attempt opened. `onFailure` takes the listener callback as-is and hands the result on, so the pattern match and its destructuring are no longer needed. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/ws/client/stream_web_socket_client.dart | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index f7e387d3..258679aa 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -163,12 +163,8 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Bound the attempt, so one that never becomes usable is not waited on forever. _startConnectTimeout(options.connectTimeout); final result = await _engine.open(options); - if (result case Failure(:final error, :final stackTrace)) { - // Report the failure before closing the socket. Close it first and the engine's default close - // code makes this look deliberate, which never reconnects and ends any recovery in progress. - onError(error, stackTrace); - await _engine.close(); - } + // Report the failure first; closing without a reason looks deliberate, and that never reconnects. + result.onFailure(onError).recover((_, _) => _engine.close()); } /// Closes the WebSocket connection. From 990707a4c579b0bf4fcbe2ef5930032f860cd4d6 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 16:48:14 +0200 Subject: [PATCH 71/97] refactor(llc): report a failed close as a chain too Matches `connect`: `onFailure` in place of an `if` on `isFailure`, which is what the rest of the file does with a `Result`. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/ws/client/stream_web_socket_client.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index 258679aa..4a84cb8f 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -163,7 +163,9 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Bound the attempt, so one that never becomes usable is not waited on forever. _startConnectTimeout(options.connectTimeout); final result = await _engine.open(options); - // Report the failure first; closing without a reason looks deliberate, and that never reconnects. + + // Report the failure first; closing without a reason looks deliberate, + // and that never reconnects. result.onFailure(onError).recover((_, _) => _engine.close()); } @@ -197,9 +199,8 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Close the connection using the engine. final result = await _engine.close(closeCode, source.closeReason); - // The engine only announces a closure when the socket really closed, so report it here when the - // close failed. Otherwise the connection stays stuck disconnecting. - if (result.isFailure) onClose(closeCode, source.closeReason); + // The engine announces nothing when a close fails, which would leave this stuck disconnecting. + result.onFailure((_, _) => onClose(closeCode, source.closeReason)); } @override From 049e1a1492dbdb4876b7e3419fc55ce2bfe47f8a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 16:51:47 +0200 Subject: [PATCH 72/97] fix(llc): do not leave a failed attempt disconnecting when its socket will not close Reporting the failure moves the state to `Disconnecting`, and the engine announces nothing when a close fails, so the closure was never recorded. The connect timeout could not rescue it either: `ConnectTimeout` is not a source that takes over a closure already under way, so `disconnect` returned early. The client stayed `Disconnecting` for good, and `connect` refuses that state, so it could never be reconnected. Introduced two commits ago, when reporting the cause was added ahead of the close. Before that the state stayed `Connecting` and the timeout did rescue it. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/ws/client/stream_web_socket_client.dart | 7 ++++--- .../ws/client/stream_web_socket_client_test.dart | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index 4a84cb8f..c27a8237 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -164,9 +164,10 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, _startConnectTimeout(options.connectTimeout); final result = await _engine.open(options); - // Report the failure first; closing without a reason looks deliberate, - // and that never reconnects. - result.onFailure(onError).recover((_, _) => _engine.close()); + // Closing without a reason looks deliberate, and that never reconnects. A close that fails + // announces nothing either, so report the closure here too or this is left disconnecting. + result.onFailure(onError); + if (result.isFailure) (await _engine.close()).onFailure((_, _) => onClose()); } /// Closes the WebSocket connection. diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index 15837585..0b03a471 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -46,6 +46,22 @@ void main() { }, ); + wsClientTest( + 'reports the closure when the socket of a failed handshake refuses to close', + handshakeFails: true, + closeError: Exception('close failed'), + connect: _justConnect, + body: (tester) async { + // The engine announces nothing when a close fails, and the connect timeout cannot rescue a + // state that is already `Disconnecting`, so this would stay there for good. + expect(tester.connectionState, isA()); + + await tester.client.connect(); + await tester.pumpEventQueue(); + expect(tester.attempts, 2); + }, + ); + wsClientTest( 'closes a socket whose handshake failed', handshakeFails: true, From 49679be3a5cf90dd5f4494c1d43523cb43ad6a6e Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 16:54:34 +0200 Subject: [PATCH 73/97] refactor(llc): abandon a failed attempt through disconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `connect` was doing three of the four things `disconnect` already does — reporting the reason, closing the socket, and recording the closure when the close fails — and it had drifted from the fourth, which is how it came to leave the client disconnecting for good. One call keeps them from drifting again. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/ws/client/stream_web_socket_client.dart | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index c27a8237..5387d05e 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -164,10 +164,13 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, _startConnectTimeout(options.connectTimeout); final result = await _engine.open(options); - // Closing without a reason looks deliberate, and that never reconnects. A close that fails - // announces nothing either, so report the closure here too or this is left disconnecting. - result.onFailure(onError); - if (result.isFailure) (await _engine.close()).onFailure((_, _) => onClose()); + // Hand a failed attempt to `disconnect`, which already reports the reason, closes the socket, and + // records the closure even when the close itself fails. + if (result case Failure(:final error)) { + await disconnect( + source: .serverInitiated(error: WebSocketEngineException(error: error)), + ); + } } /// Closes the WebSocket connection. From 32ff783493ef7483e1ba6a578aef6cbb31cbfccf Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 16:56:48 +0200 Subject: [PATCH 74/97] refactor(llc): read the error off the result rather than destructuring it `exceptionOrNull` gives the error directly, so the failure path is a null guard and a tail call instead of a pattern match wrapping an awaited block. Sits with the guards already at the top of the method. `onFailure` would drop the `if` entirely but cannot be used here: its callback returns void, so the disconnect would not be awaited, `connect` would resolve while still disconnecting, and a caller retrying straight away would be refused. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/ws/client/stream_web_socket_client.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index 5387d05e..4009a242 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -164,13 +164,14 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, _startConnectTimeout(options.connectTimeout); final result = await _engine.open(options); + final error = result.exceptionOrNull(); + if (error == null) return; + // Hand a failed attempt to `disconnect`, which already reports the reason, closes the socket, and // records the closure even when the close itself fails. - if (result case Failure(:final error)) { - await disconnect( - source: .serverInitiated(error: WebSocketEngineException(error: error)), - ); - } + return disconnect( + source: .serverInitiated(error: WebSocketEngineException(error: error)), + ); } /// Closes the WebSocket connection. From 8421c014bf9038bbba09d0a85eaad1388d96e33b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 17:02:21 +0200 Subject: [PATCH 75/97] refactor(llc): abandon a failed attempt with getOrElse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the null guard and the local. `getOrElse`'s static type here is `void`, so returning it is what awaits the disconnect — the async return flattens the future at runtime, and `await` on it does not compile. Dropping the `return` would silently make it fire-and-forget, so a test now connects twice with nothing pumped in between, which fails without it. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/ws/client/stream_web_socket_client.dart | 12 ++++++------ .../test/helpers/ws_client_tester.dart | 2 +- .../ws/client/stream_web_socket_client_test.dart | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index 4009a242..cb4ede07 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -164,13 +164,13 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, _startConnectTimeout(options.connectTimeout); final result = await _engine.open(options); - final error = result.exceptionOrNull(); - if (error == null) return; - // Hand a failed attempt to `disconnect`, which already reports the reason, closes the socket, and - // records the closure even when the close itself fails. - return disconnect( - source: .serverInitiated(error: WebSocketEngineException(error: error)), + // records the closure even when the close itself fails. Returned, not discarded: a caller that + // connects again straight away would otherwise be refused for racing a close still under way. + return result.getOrElse( + (error, _) => disconnect( + source: .serverInitiated(error: WebSocketEngineException(error: error)), + ), ); } diff --git a/packages/stream_core/test/helpers/ws_client_tester.dart b/packages/stream_core/test/helpers/ws_client_tester.dart index ab5efcb6..57709269 100644 --- a/packages/stream_core/test/helpers/ws_client_tester.dart +++ b/packages/stream_core/test/helpers/ws_client_tester.dart @@ -136,7 +136,7 @@ class WsClientTester { /// /// [connect] runs before [body] and defaults to connecting and asserting the connection was /// established. Pass a callback of your own for a test that starts from somewhere else, or -/// `(_) async {}` to start from a client that has never connected. +/// `(_) {}` to start from a client that has never connected. @isTest void wsClientTest( String description, { diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index 0b03a471..86c6cc04 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -46,6 +46,21 @@ void main() { }, ); + wsClientTest( + 'lets a caller connect again straight after a failed handshake', + handshakeFails: true, + connect: (_) {}, + body: (tester) async { + // Nothing is pumped between the two calls, so `connect` has to finish abandoning the first + // attempt before it returns; otherwise the second is refused for racing a close still + // under way, and the caller is left with a connection nobody is trying to make. + await tester.client.connect(); + await tester.client.connect(); + + expect(tester.attempts, 2); + }, + ); + wsClientTest( 'reports the closure when the socket of a failed handshake refuses to close', handshakeFails: true, From 86ab178359f64233c0691475a30939e812988ea7 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 17:04:19 +0200 Subject: [PATCH 76/97] refactor(llc): use getOrElse on both failure paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `disconnect` now reads the same as `connect`. `onClose` is synchronous, so unlike in `connect` the `return` carries nothing here — it is there so the two paths look alike. Co-Authored-By: Claude Opus 5 (1M context) --- .../stream_core/lib/src/ws/client/stream_web_socket_client.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index cb4ede07..135b0f39 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -205,7 +205,7 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, final result = await _engine.close(closeCode, source.closeReason); // The engine announces nothing when a close fails, which would leave this stuck disconnecting. - result.onFailure((_, _) => onClose(closeCode, source.closeReason)); + return result.getOrElse((_, _) => onClose(closeCode, source.closeReason)); } @override From c8807dc229b5f0e58b2a40133e26cee5484ad9b9 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 17:09:28 +0200 Subject: [PATCH 77/97] feat(llc): report an attempt that never opened a socket as its own source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `connect` labelled a failed `open` as `ServerInitiated`, which was only sometimes true. Nothing was open for a server to close, and the handshake fails just as often for local reasons: a network that is down, options a socket cannot be opened with, or a provider that throws before a socket exists. `ConnectionFailed` carries the cause and stays eligible for automatic reconnection, which is what `ServerInitiated` with no close code did before, so recovery behaviour is unchanged — the usual cause clears on its own, and a first attempt is still never retried for the caller. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 1 + .../ws/client/stream_web_socket_client.dart | 4 +-- .../client/web_socket_connection_state.dart | 29 +++++++++++++++++++ .../client/stream_web_socket_client_test.dart | 6 ++-- .../web_socket_connection_state_test.dart | 8 +++++ 5 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 71e26994..22ce5c83 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -31,6 +31,7 @@ - Added `TokenManager.reset`, which drops the configured identity and its cached token - Added `DisconnectionSource.connectTimeout`, reported when a connection attempt is abandoned before it is established. Eligible for automatic reconnection, which recovers a connection that timed out on its way back but not a first one that never landed — that attempt belongs to whoever made it - Added `DisconnectionSource.authenticationFailed`, reported with its cause when a connection opens but cannot be authenticated +- Added `DisconnectionSource.connectionFailed`, reported with its cause when an attempt never gets a socket open. Previously such an attempt was reported as `ServerInitiated`, which was only sometimes true: the handshake fails just as often because the network is down or the options cannot be opened with, and nothing was ever open for a server to close. Eligible for automatic reconnection, as it was before, since the usual cause clears on its own - Added `DisconnectionSource.isReconnectable`, whether a connection closed for that reason is worth opening again. `WebSocketConnectionState.isAutomaticReconnectionEnabled` is this and nothing else, for a `Disconnected` state - Added `WsRequestSender`, the send capability handed to a `WebSocketAuthenticator`. It belongs to the connection attempt it was handed to and fails once that attempt is no longer the one in flight, so an authenticator still awaiting credentials for an abandoned attempt cannot send them over the connection that replaced it, nor close it as `AuthenticationFailed` - Added `ConnectUserDetailsRequest.fromUser`, which builds the details a client may send from a `User` diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index 135b0f39..2b89928f 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -168,9 +168,7 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // records the closure even when the close itself fails. Returned, not discarded: a caller that // connects again straight away would otherwise be refused for racing a close still under way. return result.getOrElse( - (error, _) => disconnect( - source: .serverInitiated(error: WebSocketEngineException(error: error)), - ), + (error, _) => disconnect(source: .connectionFailed(error: error)), ); } diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index 03dcbc15..d94c79c3 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -97,6 +97,8 @@ sealed class WebSocketConnectionState extends Equatable { /// - System-initiated disconnections (network changes, app lifecycle, etc.) /// - Unhealthy connection disconnections (missing pong responses) /// - A connection attempt abandoned for taking too long ([ConnectTimeout]) + /// - An attempt that never opened a socket ([ConnectionFailed]), since the cause is usually a + /// network that clears /// /// ## Reconnection is disabled for: /// - User-initiated disconnections (explicit disconnect calls) @@ -219,6 +221,7 @@ final class Disconnected extends WebSocketConnectionState { /// - [UnHealthyConnection]: Connection closed due to failed health checks /// - [ConnectTimeout]: Attempt abandoned before the connection was established /// - [AuthenticationFailed]: Socket opened, but its credentials never went out +/// - [ConnectionFailed]: Attempt never got a socket open sealed class DisconnectionSource extends Equatable { const DisconnectionSource(); @@ -260,6 +263,12 @@ sealed class DisconnectionSource extends Equatable { /// was closed without ever being usable. const factory DisconnectionSource.authenticationFailed({Object? error}) = AuthenticationFailed; + /// Creates a [ConnectionFailed] disconnection source. + /// + /// Indicates that the attempt never got a connection open, so there was never + /// anything to close. + const factory DisconnectionSource.connectionFailed({Object? error}) = ConnectionFailed; + /// A human-readable description of the disconnection source. /// /// Provides a descriptive string that explains why the connection was closed. @@ -274,6 +283,7 @@ sealed class DisconnectionSource extends Equatable { UnHealthyConnection() => 'Unhealthy connection (no pong received)', ConnectTimeout() => 'Timed out before the connection was established', AuthenticationFailed() => 'Authentication failed', + ConnectionFailed() => 'Connection could not be opened', }; } @@ -294,6 +304,9 @@ sealed class DisconnectionSource extends Equatable { ConnectTimeout() => true, UserInitiated() => false, AuthenticationFailed() => false, + // The cause may be local and permanent, such as options a socket cannot be opened with, but + // it is more often a network that is down, and that clears. + ConnectionFailed() => true, }; @override @@ -374,3 +387,19 @@ final class AuthenticationFailed extends DisconnectionSource { @override List get props => [error]; } + +/// A disconnection caused by an attempt that never opened a socket. +/// +/// Distinct from [ServerInitiated], which closes a connection that was open. The cause may be the +/// server refusing the handshake, but it may equally be local: a network that is down, or options a +/// socket cannot be opened with. +final class ConnectionFailed extends DisconnectionSource { + /// Creates a [ConnectionFailed] disconnection source. + const ConnectionFailed({this.error}); + + /// The error that prevented the socket from opening. + final Object? error; + + @override + List get props => [error]; +} diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index 86c6cc04..a80931fe 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -32,14 +32,14 @@ void main() { handshakeFails: true, connect: _justConnect, body: (tester) { - // Closed without saying why, the attempt reads as the deliberate close the engine defaults - // to, and a closure with that code is never reconnected. + // Not `ServerInitiated`: nothing was ever open for the server to close, and the cause may + // just as well have been local. expect( tester.connectionState, isA().having( (it) => it.source, 'source', - isA().having((it) => it.error?.error, 'error', isNotNull), + isA().having((it) => it.error, 'error', isNotNull), ), ); expect(tester.connectionState.isAutomaticReconnectionEnabled, isTrue); diff --git a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart index be9e1c9b..0a5c4f09 100644 --- a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart @@ -89,6 +89,13 @@ void main() { expect(state.isAutomaticReconnectionEnabled, isFalse); }); + test('automatic reconnection is enabled when an attempt never opened a socket, since the cause is ' + 'usually a network that clears', () { + const state = Disconnected(source: ConnectionFailed(error: 'no route to host')); + + expect(state.isAutomaticReconnectionEnabled, isTrue); + }); + test('automatic reconnection is enabled when a connected socket stops answering health checks', () { const state = Disconnected(source: UnHealthyConnection()); @@ -103,6 +110,7 @@ void main() { UnHealthyConnection(), ConnectTimeout(), AuthenticationFailed(error: 'no token'), + ConnectionFailed(error: 'no socket'), ]; final reasons = sources.map((it) => it.closeReason).toSet(); From 927bdeecb3b6229cdf7891a6a5a1fde5095c6235 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 17:15:45 +0200 Subject: [PATCH 78/97] Revert "feat(llc): report an attempt that never opened a socket as its own source" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts c8807dc. `stream-core-swift` has no equivalent case — its `DisconnectionSource` is `userInitiated`, `timeout(from:)`, `serverInitiated(error:)`, `systemInitiated` and `noPongReceived`, and `WebSocketClient.webSocketDidDisconnect` maps any active state to `serverInitiated(error:)` exactly as `onClose` does here. Android has the concept as `DisconnectCause.WebSocketNotAvailable`, but shaped differently: it carries no cause, and recoverability lives in the source rather than being derived from it. `DisconnectionSource` is public vocabulary these SDKs share, so a case only Flutter has would leave product code with an arm the other platforms cannot write. Reconnectability was identical either way, so nothing behavioural is lost — a failed attempt still reports the error that caused it, which is what was actually missing before this branch. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 1 - .../ws/client/stream_web_socket_client.dart | 4 ++- .../client/web_socket_connection_state.dart | 29 ------------------- .../client/stream_web_socket_client_test.dart | 6 ++-- .../web_socket_connection_state_test.dart | 8 ----- 5 files changed, 6 insertions(+), 42 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 22ce5c83..71e26994 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -31,7 +31,6 @@ - Added `TokenManager.reset`, which drops the configured identity and its cached token - Added `DisconnectionSource.connectTimeout`, reported when a connection attempt is abandoned before it is established. Eligible for automatic reconnection, which recovers a connection that timed out on its way back but not a first one that never landed — that attempt belongs to whoever made it - Added `DisconnectionSource.authenticationFailed`, reported with its cause when a connection opens but cannot be authenticated -- Added `DisconnectionSource.connectionFailed`, reported with its cause when an attempt never gets a socket open. Previously such an attempt was reported as `ServerInitiated`, which was only sometimes true: the handshake fails just as often because the network is down or the options cannot be opened with, and nothing was ever open for a server to close. Eligible for automatic reconnection, as it was before, since the usual cause clears on its own - Added `DisconnectionSource.isReconnectable`, whether a connection closed for that reason is worth opening again. `WebSocketConnectionState.isAutomaticReconnectionEnabled` is this and nothing else, for a `Disconnected` state - Added `WsRequestSender`, the send capability handed to a `WebSocketAuthenticator`. It belongs to the connection attempt it was handed to and fails once that attempt is no longer the one in flight, so an authenticator still awaiting credentials for an abandoned attempt cannot send them over the connection that replaced it, nor close it as `AuthenticationFailed` - Added `ConnectUserDetailsRequest.fromUser`, which builds the details a client may send from a `User` diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index 2b89928f..135b0f39 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -168,7 +168,9 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // records the closure even when the close itself fails. Returned, not discarded: a caller that // connects again straight away would otherwise be refused for racing a close still under way. return result.getOrElse( - (error, _) => disconnect(source: .connectionFailed(error: error)), + (error, _) => disconnect( + source: .serverInitiated(error: WebSocketEngineException(error: error)), + ), ); } diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index d94c79c3..03dcbc15 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -97,8 +97,6 @@ sealed class WebSocketConnectionState extends Equatable { /// - System-initiated disconnections (network changes, app lifecycle, etc.) /// - Unhealthy connection disconnections (missing pong responses) /// - A connection attempt abandoned for taking too long ([ConnectTimeout]) - /// - An attempt that never opened a socket ([ConnectionFailed]), since the cause is usually a - /// network that clears /// /// ## Reconnection is disabled for: /// - User-initiated disconnections (explicit disconnect calls) @@ -221,7 +219,6 @@ final class Disconnected extends WebSocketConnectionState { /// - [UnHealthyConnection]: Connection closed due to failed health checks /// - [ConnectTimeout]: Attempt abandoned before the connection was established /// - [AuthenticationFailed]: Socket opened, but its credentials never went out -/// - [ConnectionFailed]: Attempt never got a socket open sealed class DisconnectionSource extends Equatable { const DisconnectionSource(); @@ -263,12 +260,6 @@ sealed class DisconnectionSource extends Equatable { /// was closed without ever being usable. const factory DisconnectionSource.authenticationFailed({Object? error}) = AuthenticationFailed; - /// Creates a [ConnectionFailed] disconnection source. - /// - /// Indicates that the attempt never got a connection open, so there was never - /// anything to close. - const factory DisconnectionSource.connectionFailed({Object? error}) = ConnectionFailed; - /// A human-readable description of the disconnection source. /// /// Provides a descriptive string that explains why the connection was closed. @@ -283,7 +274,6 @@ sealed class DisconnectionSource extends Equatable { UnHealthyConnection() => 'Unhealthy connection (no pong received)', ConnectTimeout() => 'Timed out before the connection was established', AuthenticationFailed() => 'Authentication failed', - ConnectionFailed() => 'Connection could not be opened', }; } @@ -304,9 +294,6 @@ sealed class DisconnectionSource extends Equatable { ConnectTimeout() => true, UserInitiated() => false, AuthenticationFailed() => false, - // The cause may be local and permanent, such as options a socket cannot be opened with, but - // it is more often a network that is down, and that clears. - ConnectionFailed() => true, }; @override @@ -387,19 +374,3 @@ final class AuthenticationFailed extends DisconnectionSource { @override List get props => [error]; } - -/// A disconnection caused by an attempt that never opened a socket. -/// -/// Distinct from [ServerInitiated], which closes a connection that was open. The cause may be the -/// server refusing the handshake, but it may equally be local: a network that is down, or options a -/// socket cannot be opened with. -final class ConnectionFailed extends DisconnectionSource { - /// Creates a [ConnectionFailed] disconnection source. - const ConnectionFailed({this.error}); - - /// The error that prevented the socket from opening. - final Object? error; - - @override - List get props => [error]; -} diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index a80931fe..86c6cc04 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -32,14 +32,14 @@ void main() { handshakeFails: true, connect: _justConnect, body: (tester) { - // Not `ServerInitiated`: nothing was ever open for the server to close, and the cause may - // just as well have been local. + // Closed without saying why, the attempt reads as the deliberate close the engine defaults + // to, and a closure with that code is never reconnected. expect( tester.connectionState, isA().having( (it) => it.source, 'source', - isA().having((it) => it.error, 'error', isNotNull), + isA().having((it) => it.error?.error, 'error', isNotNull), ), ); expect(tester.connectionState.isAutomaticReconnectionEnabled, isTrue); diff --git a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart index 0a5c4f09..be9e1c9b 100644 --- a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart @@ -89,13 +89,6 @@ void main() { expect(state.isAutomaticReconnectionEnabled, isFalse); }); - test('automatic reconnection is enabled when an attempt never opened a socket, since the cause is ' - 'usually a network that clears', () { - const state = Disconnected(source: ConnectionFailed(error: 'no route to host')); - - expect(state.isAutomaticReconnectionEnabled, isTrue); - }); - test('automatic reconnection is enabled when a connected socket stops answering health checks', () { const state = Disconnected(source: UnHealthyConnection()); @@ -110,7 +103,6 @@ void main() { UnHealthyConnection(), ConnectTimeout(), AuthenticationFailed(error: 'no token'), - ConnectionFailed(error: 'no socket'), ]; final reasons = sources.map((it) => it.closeReason).toSet(); From 223d6431cb21f611b0eb3e22f4b5027193a56b21 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 17:34:57 +0200 Subject: [PATCH 79/97] docs(llc): drop a changelog entry for a bug that never shipped, and fix the example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "recorded as a deliberate close" entry described a regression introduced and fixed inside this branch, so a reader of the release notes would look for it in 0.4.0 and not find it. What is real against the last release — that the closure now names the error that failed the handshake — moves into the entry for the socket leak that introduced it. The example also named `JsonCodec`, which is a test helper in `test/helpers/fake_server.dart`, not public API. A consumer brings its own codec. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 3 +-- .../lib/src/ws/client/stream_web_socket_client.dart | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 71e26994..5fa35f0f 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -44,7 +44,7 @@ - Fixed a token-expired response never being retried when the server sent it without a JSON content type, so Dio handed the body over as a string. The same body was already read that way when the error was surfaced to the caller, so a request was reported as refused for an expired token without the token ever being replaced - `StreamWebSocketClient` no longer prints to the console. It announced every connection state change, every pong and every ping, which is noise a consumer cannot turn off and cannot act on -- Fixed `StreamWebSocketClient.connect` leaking the socket of a connection whose handshake failed. The socket is opened before the handshake it fails, and the client reported the connection closed without closing it, so the socket stayed open and unreachable — `dispose` did not close it either, and the next attempt closed it instead, reporting a closure while that attempt was still connecting +- Fixed `StreamWebSocketClient.connect` leaking the socket of a connection whose handshake failed. The socket is opened before the handshake it fails, and the client reported the connection closed without closing it, so the socket stayed open and unreachable — `dispose` did not close it either, and the next attempt closed it instead, reporting a closure while that attempt was still connecting. The closure now also carries the error that failed the handshake, where before it named no cause at all - Fixed a WebSocket engine that reported no closure at all when there was no socket to close, so a client waiting to hear the connection is down was left waiting on one it could never use. A close that fails is still reported as a failure rather than a closure, and `StreamWebSocketClient` is what announces the closure in that case. The engine also lets go of a socket that failed to close, rather than holding one it cannot use, and reports a closure once rather than again when the socket's stream ends - Fixed `ConnectionRecoveryHandler` retrying an attempt the caller made and was handed the outcome of, when it followed a closure the handler had stopped at. Only a disconnect the caller asked for handed connecting back to them; any other reason that rules a reconnection out now does too - Fixed an authentication failure being discarded when it landed on a connection already recorded as closed for a reason worth retrying, leaving the connection eligible for a reconnection that would present the credentials the authenticator had just given up on @@ -63,7 +63,6 @@ - Fixed `isAutomaticReconnectionEnabled` refusing neither a deliberate close (code 1000) nor client errors, neither of which ever matched - Fixed a connection closed for a rate limit not being eligible for automatic reconnection, since a rate limit clears on its own - Fixed `ConnectionRecoveryHandler` retrying a first connection attempt, which reconnected behind the caller of `connect`; only established connections are recovered now -- Fixed a handshake that failed being recorded as a deliberate close, which ended the recovery it was part of. `connect` closed the socket of a failed attempt with the engine's default close code, and a closure with that code is never reconnected — so an established connection that dropped, then failed to upgrade on the retry, stayed down for good instead of trying again. The attempt now reports the error that actually failed it - Fixed a health check arriving while disconnecting reporting the connection as established again, turning a deliberate disconnect into a reconnect ### 🔄 Changed diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index 135b0f39..51443aac 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -38,7 +38,8 @@ typedef WebSocketOptionsBuilder = WebSocketOptions Function(); /// ```dart /// final client = StreamWebSocketClient( /// optionsBuilder: () => const WebSocketOptions(url: 'wss://api.example.com'), -/// messageCodec: const JsonCodec(), +/// // A WebSocketMessageCodec for the event and request types this SDK puts on the wire. +/// messageCodec: const AppWsCodec(), /// onAuthenticate: (send, _) async { /// final token = await tokenManager.getToken(); /// send(WsAuthMessageRequest(token: token.rawValue)).getOrThrow(); From c1f707a82b49720ff869c2f21736e7fb3e29b268 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 17:38:12 +0200 Subject: [PATCH 80/97] docs(llc): name the right product, and key the reconnection rules to their sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `StreamDioException` was documented as "specific to StreamChat", in the package video and feeds also depend on. Constructor docs across the interceptors said Initialize, Initializes a new instance of, and Initialize a new ; they all say Creates now, which is the third-person form Effective Dart asks for. `isAutomaticReconnectionEnabled` carried two five-bullet lists that had to be kept in sync with a switch in another member. The rules now sit on `isReconnectable`, beside that switch, as one list keyed by source — so a source with no line is visible, and the state getter just says it defers. Co-Authored-By: Claude Opus 5 (1M context) --- .../interceptors/api_error_interceptor.dart | 2 +- .../api/interceptors/api_key_interceptor.dart | 2 +- .../api/interceptors/auth_interceptor.dart | 2 +- .../connection_id_interceptor.dart | 4 +- .../api/interceptors/headers_interceptor.dart | 2 +- .../api/interceptors/logging_interceptor.dart | 2 +- .../lib/src/api/stream_core_dio_error.dart | 4 +- .../client/web_socket_connection_state.dart | 37 +++++++------------ 8 files changed, 22 insertions(+), 33 deletions(-) diff --git a/packages/stream_core/lib/src/api/interceptors/api_error_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/api_error_interceptor.dart index 96eb5343..4b1a53a1 100644 --- a/packages/stream_core/lib/src/api/interceptors/api_error_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/api_error_interceptor.dart @@ -3,7 +3,7 @@ import 'package:dio/dio.dart'; import '../stream_core_dio_error.dart'; class ApiErrorInterceptor extends Interceptor { - /// Initializes a new instance of [ApiErrorInterceptor]. + /// Creates a new [ApiErrorInterceptor]. const ApiErrorInterceptor(); @override diff --git a/packages/stream_core/lib/src/api/interceptors/api_key_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/api_key_interceptor.dart index 06975de7..a9113e3f 100644 --- a/packages/stream_core/lib/src/api/interceptors/api_key_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/api_key_interceptor.dart @@ -1,7 +1,7 @@ import 'package:dio/dio.dart'; class ApiKeyInterceptor extends Interceptor { - /// Initialize a new API key interceptor + /// Creates a new [ApiKeyInterceptor]. const ApiKeyInterceptor(this.apiKey); /// The API key to be added to the request headers diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index fd372ea1..4e5697fb 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -8,7 +8,7 @@ import '../stream_core_dio_error.dart'; /// /// A request the server refuses for an expired token is retried once, carrying a replacement. class AuthInterceptor extends Interceptor { - /// Initialize a new [AuthInterceptor]. + /// Creates a new [AuthInterceptor]. AuthInterceptor(this._dio, this._tokenManager); final Dio _dio; diff --git a/packages/stream_core/lib/src/api/interceptors/connection_id_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/connection_id_interceptor.dart index fcb436a9..b2f72535 100644 --- a/packages/stream_core/lib/src/api/interceptors/connection_id_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/connection_id_interceptor.dart @@ -2,9 +2,9 @@ import 'package:dio/dio.dart'; typedef ConnectionIdGetter = String? Function(); -/// Interceptor that injects the connection id in the request params +/// Interceptor that injects the connection id into the request params. class ConnectionIdInterceptor extends Interceptor { - /// Initialize a new [ConnectionIdInterceptor]. + /// Creates a new [ConnectionIdInterceptor]. const ConnectionIdInterceptor(this._connectionId); /// The getter for the connection id. diff --git a/packages/stream_core/lib/src/api/interceptors/headers_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/headers_interceptor.dart index f2b94872..7e951f7b 100644 --- a/packages/stream_core/lib/src/api/interceptors/headers_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/headers_interceptor.dart @@ -4,7 +4,7 @@ import '../system_environment_manager.dart'; /// Interceptor that sets additional headers for all requests. class HeadersInterceptor extends Interceptor { - /// Initialize a new [HeadersInterceptor]. + /// Creates a new [HeadersInterceptor]. const HeadersInterceptor(this._systemEnvironmentManager); final SystemEnvironmentManager _systemEnvironmentManager; diff --git a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart index f74b7844..46d14e98 100644 --- a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart @@ -23,7 +23,7 @@ void _defaultLogPrint(InterceptStep step, Object object) => print(object); /// Interceptor dedicated to logging class LoggingInterceptor extends Interceptor { - /// Initialize a new logging interceptor + /// Creates a new [LoggingInterceptor]. LoggingInterceptor({ this.request = true, this.requestHeader = false, diff --git a/packages/stream_core/lib/src/api/stream_core_dio_error.dart b/packages/stream_core/lib/src/api/stream_core_dio_error.dart index a975a924..018fc8db 100644 --- a/packages/stream_core/lib/src/api/stream_core_dio_error.dart +++ b/packages/stream_core/lib/src/api/stream_core_dio_error.dart @@ -2,9 +2,9 @@ import 'dart:convert'; import '../../stream_core.dart'; -/// Error class specific to StreamChat and Dio +/// A [DioException] carrying the Stream [ClientException] that caused it. class StreamDioException extends DioException { - /// Initialize a stream chat dio error + /// Creates a [StreamDioException] for [exception]. StreamDioException({ required this.exception, required super.requestOptions, diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index 03dcbc15..8f4f2d82 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -88,27 +88,8 @@ sealed class WebSocketConnectionState extends Equatable { /// Whether automatic reconnection is enabled for this connection state. /// - /// Determines if the connection should automatically attempt to reconnect based on - /// the current state and disconnection source. Only applies to [Disconnected] states. - /// - /// ## Reconnection is enabled for: - /// - Server-initiated disconnections, other than the cases listed below - /// - An expired token, and a rate limit, both of which a later attempt can get past - /// - System-initiated disconnections (network changes, app lifecycle, etc.) - /// - Unhealthy connection disconnections (missing pong responses) - /// - A connection attempt abandoned for taking too long ([ConnectTimeout]) - /// - /// ## Reconnection is disabled for: - /// - User-initiated disconnections (explicit disconnect calls) - /// - A connection closed deliberately (close code 1000) - /// - Token errors a fresh token would not fix, and a wrong API key - /// - Client errors (4xx status codes), other than a rate limit or an expired token - /// - A failure to load or send credentials ([AuthenticationFailed]) - /// - /// Necessary, but not on its own sufficient: `ConnectionRecoveryHandler` recovers only a - /// connection that was established, and only while the network and the app lifecycle allow it. An - /// attempt that never landed is not retried for whoever made it, whatever this reports — so a - /// first connection that times out stays down, where one that times out on the way back does not. + /// `false` for every state but [Disconnected], where it is the source's + /// [DisconnectionSource.isReconnectable] and nothing more — see that for which sources reconnect. bool get isAutomaticReconnectionEnabled => switch (this) { Disconnected(:final source) => source.isReconnectable, _ => false, // No automatic reconnection for other states @@ -279,9 +260,17 @@ sealed class DisconnectionSource extends Equatable { /// Whether a connection closed for this reason is worth opening again. /// - /// This is the whole of [WebSocketConnectionState.isAutomaticReconnectionEnabled], which lists - /// what is and is not reconnected. Whether a reconnection is then actually made is decided by - /// `ConnectionRecoveryHandler` on top of this. + /// - [UserInitiated] — no, the caller asked for the connection to close. + /// - [AuthenticationFailed] — no, credentials that never went out will not go out on a retry. + /// - [ServerInitiated] — no for a deliberate close (code 1000), for a token error a fresh token + /// would not fix, and for a client error that is neither a rate limit nor an expired token. Yes + /// otherwise, including an expired token and a rate limit, which a later attempt can get past. + /// - [SystemInitiated], [UnHealthyConnection], [ConnectTimeout] — yes. + /// + /// Necessary, but not on its own sufficient. Whether a reconnection is then actually made is + /// decided by `ConnectionRecoveryHandler`, which recovers only a connection that was established, + /// and only while the network and the app lifecycle allow it — so a first connection that times + /// out stays down, where one that times out on the way back does not. bool get isReconnectable => switch (this) { ServerInitiated(:final error) when error?.code == CloseCode.normalClosure => false, ServerInitiated(:final error) => switch (error?.apiError) { From d4c9de2d3159f329782ac3685a88577191dcd9bb Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 17:41:10 +0200 Subject: [PATCH 81/97] docs(llc): share the reconnection rules through a macro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isAutomaticReconnectionEnabled` pointed at `isReconnectable` for the rules, which meant reading two members to answer one question. Both now render the same list from one definition beside the switch it describes. Verified with `dart doc`: the text appears on both pages, nothing is left unexpanded, and a typo in the name is caught — `warning: undefined macro`. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/ws/client/web_socket_connection_state.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index 8f4f2d82..aa1f003e 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -89,7 +89,9 @@ sealed class WebSocketConnectionState extends Equatable { /// Whether automatic reconnection is enabled for this connection state. /// /// `false` for every state but [Disconnected], where it is the source's - /// [DisconnectionSource.isReconnectable] and nothing more — see that for which sources reconnect. + /// [DisconnectionSource.isReconnectable] and nothing more. + /// + /// {@macro webSocketReconnectionRules} bool get isAutomaticReconnectionEnabled => switch (this) { Disconnected(:final source) => source.isReconnectable, _ => false, // No automatic reconnection for other states @@ -260,6 +262,7 @@ sealed class DisconnectionSource extends Equatable { /// Whether a connection closed for this reason is worth opening again. /// + /// {@template webSocketReconnectionRules} /// - [UserInitiated] — no, the caller asked for the connection to close. /// - [AuthenticationFailed] — no, credentials that never went out will not go out on a retry. /// - [ServerInitiated] — no for a deliberate close (code 1000), for a token error a fresh token @@ -271,6 +274,7 @@ sealed class DisconnectionSource extends Equatable { /// decided by `ConnectionRecoveryHandler`, which recovers only a connection that was established, /// and only while the network and the app lifecycle allow it — so a first connection that times /// out stays down, where one that times out on the way back does not. + /// {@endtemplate} bool get isReconnectable => switch (this) { ServerInitiated(:final error) when error?.code == CloseCode.normalClosure => false, ServerInitiated(:final error) => switch (error?.apiError) { From 33deddb1598e29ec61fe371843baafdda3e8f82a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 17:45:06 +0200 Subject: [PATCH 82/97] style(llc): name the engine exception through the dot shorthand The source's `error` parameter already fixes the type, so `.new` says what `WebSocketEngineException(...)` said, in the same shorthand the surrounding `.serverInitiated` and `.authenticationFailed` use. Co-Authored-By: Claude Opus 5 (1M context) --- .../stream_core/lib/src/ws/client/stream_web_socket_client.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index 51443aac..a6e485ab 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -170,7 +170,7 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // connects again straight away would otherwise be refused for racing a close still under way. return result.getOrElse( (error, _) => disconnect( - source: .serverInitiated(error: WebSocketEngineException(error: error)), + source: .serverInitiated(error: .new(error: error)), ), ); } From be4bc4add93e327e297ef54cfaa10ed78ddebf65 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 17:51:53 +0200 Subject: [PATCH 83/97] docs(llc): trim the changelog to what a consumer needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 43 entries to 35, and half the prose. Dropped eight that describe fixes to API this same release introduces — `WebSocketAuthenticator` throwing, an `AuthenticationFailed` landing on a closed connection, a `ConnectTimeout` replacing a source. Nobody upgrading from 0.4.0 ever saw those broken, so they are how the new API works, not fixes. The rest lose their rationale. Why `AuthInterceptor` stopped being a `QueuedInterceptor`, how the attempt-identity check works, what the previous `print` calls announced — that belongs in the commits, not in release notes. Entries that carry a migration keep their second sentence. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 42 +++++++++++++------------------ 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 5fa35f0f..db347374 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -4,14 +4,10 @@ - Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `User.anonymousUserId` - Removed the `TokenManager.tokenProvider` setter, use `setTokenProvider` instead -- `StreamWebSocketClient` now takes an `optionsBuilder` instead of `options`, a `WebSocketOptionsBuilder` it calls for every connection attempt -- Renamed `StreamWebSocketClient.onConnectionEstablished` to `onAuthenticate` -- `StreamWebSocketClient.onAuthenticate` is now a `WebSocketAuthenticator`: it is handed a `WsRequestSender` and throws to say the credentials did not go out, whether sending failed or it chose not to send them. That closes the connection as `AuthenticationFailed`, which is not retried -- `WsRequestSender` and `WebSocketAuthenticator` are new, and live in `web_socket_authentication_handler.dart`. The handler that runs them, and remembers what the server refused, is internal +- `StreamWebSocketClient` now takes an `optionsBuilder` instead of `options`, called once per connection attempt +- Renamed `StreamWebSocketClient.onConnectionEstablished` to `onAuthenticate`, now a `WebSocketAuthenticator`. It is handed a `WsRequestSender` and the error the server closed the previous attempt with, and throws to say the credentials did not go out - Removed `WebSocketEngineException.stopErrorCode`, use `CloseCode.normalClosure` -- `AuthInterceptor` extends `Interceptor` rather than `QueuedInterceptor`, so requests are no longer serialised against one another. A queue slot is only freed once a handler completes, so the retry sent from `onError` waited behind the request still holding one and neither finished. `TokenManager` serialises the token loads, which is the part that needs it -- `WebSocketAuthenticator` is also handed `previousError`, the error the server closed the previous attempt with, and null on a first attempt and once a connection has been established. It tells the authenticator whether the credentials it last sent are why the attempt failed, so it can replace them rather than offer refused ones for the life of the client. An attempt abandoned before its authenticator finished leaves the refusal behind for the attempt that replaces it, which has yet to answer it -- The `previousError` handed to a `WebSocketAuthenticator` is now forgotten when the caller disconnects, as well as when a connection is established. A caller that disconnects takes connecting back, and what they connect with next is theirs to decide — another user, whose credentials the refusal says nothing about. A connect made straight after a disconnect with credentials that are still spent is refused once, and the connect after that is told and succeeds +- `AuthInterceptor` extends `Interceptor` rather than `QueuedInterceptor`, so requests are no longer serialised against one another - `TokenManager.userId` is now nullable, and is `null` until an identity is configured - `User` now requires a user of type `UserType.anonymous` to carry `User.anonymousUserId` as its id. A mismatch fails to compile in a const context, and throws in debug mode otherwise - `WebSocketConnectionState.isAutomaticReconnectionEnabled` is now `true` for an expired token, and remains `false` for token errors a fresh token cannot fix @@ -21,7 +17,7 @@ ### ✨ Features -- Added `DioException.apiError`, the Stream API error a response carried, read from a decoded body or from a string one, and `null` for anything that is not a Stream error payload +- Added `DioException.apiError`, the Stream API error a response carried, or `null` for anything else - Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token - Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load - Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access; its `user_id` claim must be `!anon` @@ -29,26 +25,24 @@ - Added `User.anonymousUserId`, the id every anonymous user has - Added `TokenManager.unconfigured`, for a client that exists before its user does - Added `TokenManager.reset`, which drops the configured identity and its cached token -- Added `DisconnectionSource.connectTimeout`, reported when a connection attempt is abandoned before it is established. Eligible for automatic reconnection, which recovers a connection that timed out on its way back but not a first one that never landed — that attempt belongs to whoever made it +- Added `DisconnectionSource.connectTimeout`, reported when an attempt is abandoned before it is established - Added `DisconnectionSource.authenticationFailed`, reported with its cause when a connection opens but cannot be authenticated -- Added `DisconnectionSource.isReconnectable`, whether a connection closed for that reason is worth opening again. `WebSocketConnectionState.isAutomaticReconnectionEnabled` is this and nothing else, for a `Disconnected` state -- Added `WsRequestSender`, the send capability handed to a `WebSocketAuthenticator`. It belongs to the connection attempt it was handed to and fails once that attempt is no longer the one in flight, so an authenticator still awaiting credentials for an abandoned attempt cannot send them over the connection that replaced it, nor close it as `AuthenticationFailed` +- Added `DisconnectionSource.isReconnectable`, whether a connection closed for that reason is worth opening again +- Added `WsRequestSender`, the send capability handed to a `WebSocketAuthenticator`, which fails once the attempt it belongs to is no longer the one in flight - Added `ConnectUserDetailsRequest.fromUser`, which builds the details a client may send from a `User` - Added `StreamWebSocketClient.dispose`, which closes the connection along with `events` and `connectionState`; the client is now `Disposable` -- `StreamWebSocketClient.connect` now throws a `StateError` once the client has been disposed, in release builds as well as debug. It previously asserted and then returned, so a release build opened a socket nothing could observe or close: the emitters are shut, so no state change is reported, and the health monitor that would tear an idle connection down is stopped -- `StreamWebSocketClient.disconnect` returns without reporting a closure when no connection was ever opened, so a `connect` made straight afterwards is not refused for racing a close that is not happening. An explicit disconnect also now takes over a closure already recorded or under way, which is what calls off a scheduled reconnection +- `StreamWebSocketClient.connect` now throws a `StateError` once the client has been disposed +- `StreamWebSocketClient.disconnect` now takes effect on a connection already closing or closed, which is what calls off a scheduled reconnection - Added `teams` field to `User` class -- `StreamWebSocketClient` now honours `WebSocketOptions.connectTimeout`, no longer nullable and 30 seconds by default, so an attempt that never becomes usable is abandoned rather than waited on indefinitely. A connection lost this way on its way back is reconnected; a first one that never landed is reported through `connectionState` and left there +- `StreamWebSocketClient` now honours `WebSocketOptions.connectTimeout`, no longer nullable and 30 seconds by default, so an attempt that never becomes usable is abandoned ### 🐛 Bug Fixes -- Fixed a token-expired response never being retried when the server sent it without a JSON content type, so Dio handed the body over as a string. The same body was already read that way when the error was surfaced to the caller, so a request was reported as refused for an expired token without the token ever being replaced -- `StreamWebSocketClient` no longer prints to the console. It announced every connection state change, every pong and every ping, which is noise a consumer cannot turn off and cannot act on -- Fixed `StreamWebSocketClient.connect` leaking the socket of a connection whose handshake failed. The socket is opened before the handshake it fails, and the client reported the connection closed without closing it, so the socket stayed open and unreachable — `dispose` did not close it either, and the next attempt closed it instead, reporting a closure while that attempt was still connecting. The closure now also carries the error that failed the handshake, where before it named no cause at all -- Fixed a WebSocket engine that reported no closure at all when there was no socket to close, so a client waiting to hear the connection is down was left waiting on one it could never use. A close that fails is still reported as a failure rather than a closure, and `StreamWebSocketClient` is what announces the closure in that case. The engine also lets go of a socket that failed to close, rather than holding one it cannot use, and reports a closure once rather than again when the socket's stream ends -- Fixed `ConnectionRecoveryHandler` retrying an attempt the caller made and was handed the outcome of, when it followed a closure the handler had stopped at. Only a disconnect the caller asked for handed connecting back to them; any other reason that rules a reconnection out now does too -- Fixed an authentication failure being discarded when it landed on a connection already recorded as closed for a reason worth retrying, leaving the connection eligible for a reconnection that would present the credentials the authenticator had just given up on -- Fixed a request that met a second token-expired response never completing at all. A request is now retried at most once +- Fixed a token-expired response never being retried when the server sent it without a JSON content type +- `StreamWebSocketClient` no longer prints to the console +- Fixed `StreamWebSocketClient.connect` leaking the socket of a connection whose handshake failed, and the closure now names the error that failed it +- Fixed a WebSocket engine that reported no closure when there was no socket to close, leaving a client waiting to hear the connection is down +- Fixed a request that met a second token-expired response never completing at all; a request is now retried at most once - Fixed a retried request re-sending a multipart body whose streams the refused attempt had already consumed - Fixed the failure to load a token reporting the stack trace of where it was caught rather than where the load failed - Fixed a rejected request expiring a token that another request had already replaced; only the token a request actually carried is expired now @@ -58,8 +52,6 @@ - Fixed `TokenManager` caching a token that finished loading after `expireToken` or `setTokenProvider` had invalidated it - Fixed `StreamWebSocketClient.disconnect` completing before the socket was closed, so a `connect` straight afterwards raced the closure - Fixed a failure to close the socket leaving `StreamWebSocketClient` reporting itself as disconnecting for good -- Fixed an error thrown by a `WebSocketAuthenticator` escaping unhandled and leaving the connection authenticating -- Fixed `StreamWebSocketClient.disconnect` replacing the source of a closure already under way, turning a reconnectable error into a permanent `ConnectTimeout` - Fixed `isAutomaticReconnectionEnabled` refusing neither a deliberate close (code 1000) nor client errors, neither of which ever matched - Fixed a connection closed for a rate limit not being eligible for automatic reconnection, since a rate limit clears on its own - Fixed `ConnectionRecoveryHandler` retrying a first connection attempt, which reconnected behind the caller of `connect`; only established connections are recovered now @@ -74,8 +66,8 @@ - `TokenManager.getToken` fails when `reset` runs while the token is loading; a `setTokenProvider` during a load still serves the caller that started it - `TokenManager.getToken` rejects a token whose `user_id` is not the user it was loading for - `AuthInterceptor` no longer attempts a token refresh when the manager has no identity, so the original token-expired error is surfaced -- `AuthInterceptor` no longer retries a request signed for a user the `TokenManager` has since been pointed away from. The retry would have carried the new user's credentials and performed one user's request as another, answering the caller as though their own had succeeded -- `StreamWebSocketEngine.open` throws when a connection is already open, rather than closing it to make room. Closing it reported a closure from inside `connect`, which brought the new connection down as it was being established +- `AuthInterceptor` no longer retries a request signed for a user the `TokenManager` has since been pointed away from, which would have performed one user's request as another +- `StreamWebSocketEngine.open` fails when a connection is already open, rather than closing it to make room ## 0.4.0 From dd17daec68f857df29467625bd1b7d3cca598d02 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 17:57:14 +0200 Subject: [PATCH 84/97] docs(llc): fold the changelog entries that share a cause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 35 entries to 20. Four separate faults in the token-expired retry read as one entry, as do four in how a connection was opened and closed, and two in reconnection eligibility — a reader upgrading wants to know the retry and the close were reworked, not to audit each symptom. The two `StreamApiError` classification changes, the two `AuthInterceptor` behaviour changes and the two new disconnection sources likewise go together. Dropped `WsRequestSender` and the `StateError` on a disposed client as standalone entries; both are already stated where a reader meets them, on `onAuthenticate` and on `dispose`. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 29 +++++++---------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index db347374..aea78e88 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -11,8 +11,7 @@ - `TokenManager.userId` is now nullable, and is `null` until an identity is configured - `User` now requires a user of type `UserType.anonymous` to carry `User.anonymousUserId` as its id. A mismatch fails to compile in a const context, and throws in debug mode otherwise - `WebSocketConnectionState.isAutomaticReconnectionEnabled` is now `true` for an expired token, and remains `false` for token errors a fresh token cannot fix -- `StreamApiError.isTokenExpiredError` now means code 40 only; the other token codes and a wrong API key are `isInvalidTokenError` -- `StreamApiError.isClientError` compares the HTTP `statusCode` against 400..499 rather than the Stream error `code`, which never falls in that range +- `StreamApiError.isTokenExpiredError` now means code 40 only; the other token codes and a wrong API key are `isInvalidTokenError`. `isClientError` compares the HTTP `statusCode` against 400..499, rather than the Stream error `code`, which never falls in that range - `Result.getOrElse`, `getOrDefault`, `recover` and `recoverCatching` return the result's own type and no longer take a type parameter. To widen, widen the result (`Result widened = intResult`) or use `fold` ### ✨ Features @@ -25,35 +24,22 @@ - Added `User.anonymousUserId`, the id every anonymous user has - Added `TokenManager.unconfigured`, for a client that exists before its user does - Added `TokenManager.reset`, which drops the configured identity and its cached token -- Added `DisconnectionSource.connectTimeout`, reported when an attempt is abandoned before it is established -- Added `DisconnectionSource.authenticationFailed`, reported with its cause when a connection opens but cannot be authenticated -- Added `DisconnectionSource.isReconnectable`, whether a connection closed for that reason is worth opening again -- Added `WsRequestSender`, the send capability handed to a `WebSocketAuthenticator`, which fails once the attempt it belongs to is no longer the one in flight +- Added `DisconnectionSource.connectTimeout` and `authenticationFailed`, and `isReconnectable`, whether a connection closed for that reason is worth opening again - Added `ConnectUserDetailsRequest.fromUser`, which builds the details a client may send from a `User` -- Added `StreamWebSocketClient.dispose`, which closes the connection along with `events` and `connectionState`; the client is now `Disposable` -- `StreamWebSocketClient.connect` now throws a `StateError` once the client has been disposed -- `StreamWebSocketClient.disconnect` now takes effect on a connection already closing or closed, which is what calls off a scheduled reconnection +- Added `StreamWebSocketClient.dispose`, which closes the connection along with `events` and `connectionState`; the client is now `Disposable`, and `connect` throws a `StateError` afterwards - Added `teams` field to `User` class - `StreamWebSocketClient` now honours `WebSocketOptions.connectTimeout`, no longer nullable and 30 seconds by default, so an attempt that never becomes usable is abandoned ### 🐛 Bug Fixes -- Fixed a token-expired response never being retried when the server sent it without a JSON content type +- Fixed several faults in the token-expired retry: it was skipped when the response carried no JSON content type, never completed at all when the replacement was refused too, re-sent a multipart body whose streams the refused attempt had consumed, and expired a token another request had already replaced - `StreamWebSocketClient` no longer prints to the console -- Fixed `StreamWebSocketClient.connect` leaking the socket of a connection whose handshake failed, and the closure now names the error that failed it -- Fixed a WebSocket engine that reported no closure when there was no socket to close, leaving a client waiting to hear the connection is down -- Fixed a request that met a second token-expired response never completing at all; a request is now retried at most once -- Fixed a retried request re-sending a multipart body whose streams the refused attempt had already consumed -- Fixed the failure to load a token reporting the stack trace of where it was caught rather than where the load failed -- Fixed a rejected request expiring a token that another request had already replaced; only the token a request actually carried is expired now +- Fixed a connection that could be left open, or left disconnecting for good: `connect` leaked the socket of a failed handshake, `disconnect` completed before the socket had closed, and a close that failed or found no socket reported no closure at all - Fixed `TokenManager.getToken()` contacting the `TokenProvider` on every call instead of returning the cached token - `TokenManager.getToken` now replaces a cached token that has expired, rather than handing it out and learning the same thing from a refused request. Judged on the expiry alone, so a token with life left in it is still cached. A static provider is left alone: it has nothing fresher to give, and the server refusing its token is what tells a guest to exchange for a new identity - Fixed `DynamicTokenProvider` accepting a token issued for a different user than the one requested - Fixed `TokenManager` caching a token that finished loading after `expireToken` or `setTokenProvider` had invalidated it -- Fixed `StreamWebSocketClient.disconnect` completing before the socket was closed, so a `connect` straight afterwards raced the closure -- Fixed a failure to close the socket leaving `StreamWebSocketClient` reporting itself as disconnecting for good -- Fixed `isAutomaticReconnectionEnabled` refusing neither a deliberate close (code 1000) nor client errors, neither of which ever matched -- Fixed a connection closed for a rate limit not being eligible for automatic reconnection, since a rate limit clears on its own +- Fixed reconnection eligibility: the deliberate-close and client-error checks never matched, and a rate limit was treated as permanent when it clears on its own - Fixed `ConnectionRecoveryHandler` retrying a first connection attempt, which reconnected behind the caller of `connect`; only established connections are recovered now - Fixed a health check arriving while disconnecting reporting the connection as established again, turning a deliberate disconnect into a reconnect @@ -65,8 +51,7 @@ - `TokenManager.setTokenProvider` does nothing when handed the identity it already has; providers are compared with `==` - `TokenManager.getToken` fails when `reset` runs while the token is loading; a `setTokenProvider` during a load still serves the caller that started it - `TokenManager.getToken` rejects a token whose `user_id` is not the user it was loading for -- `AuthInterceptor` no longer attempts a token refresh when the manager has no identity, so the original token-expired error is surfaced -- `AuthInterceptor` no longer retries a request signed for a user the `TokenManager` has since been pointed away from, which would have performed one user's request as another +- `AuthInterceptor` no longer refreshes a token when the manager has no identity, so the original error is surfaced, and no longer retries a request signed for a user it has since been pointed away from, which would have performed one user's request as another - `StreamWebSocketEngine.open` fails when a connection is already open, rather than closing it to make room ## 0.4.0 From 24fd7a97aaccdc4d2a2db0004e926c9dd498bd87 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 18:14:27 +0200 Subject: [PATCH 85/97] docs(llc): state the engine contract as obligations, not as observations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `WebSocketEngine` is an interface, so `open` and `close` describe what an implementation must do. Both read as descriptions of the one implementation instead — "an implementation does not close one to make room", "the listener is told the connection closed" — and both trailed off into things outside the interface: why a caller wants the rule, and what the client does with a failed close. That last one is already recorded where it is acted on, in `disconnect`. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/ws/client/engine/web_socket_engine.dart | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart index d3b5c8ea..7d9c72a3 100644 --- a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart @@ -19,8 +19,8 @@ abstract interface class WebSocketEngine { /// Creates a new WebSocket connection using the provided [options] and sets up /// event listeners. /// - /// Fails when a connection is already open; [close] it first. An implementation does not close one - /// to make room, so a caller never has a closure announced against an attempt it is still making. + /// Must fail when a connection is already open, rather than closing it to make room. Call [close] + /// first. /// /// Returns a [Result] indicating success or failure of the connection attempt. Future> open(WebSocketOptions options); @@ -29,9 +29,8 @@ abstract interface class WebSocketEngine { /// /// Closes the active WebSocket connection with the specified [closeCode] and [closeReason]. /// - /// The listener is told the connection closed before this completes, including when there was no - /// connection to close. A close that fails announces nothing and is reported as a failure instead, - /// leaving the caller to tell anyone waiting what became of the connection. + /// Must notify the listener that the connection closed before completing, including when there was + /// no connection to close. A close that fails notifies nothing and reports the failure instead. /// /// Returns a [Result] indicating success or failure of the close operation. Future> close([int? closeCode, String? closeReason]); From d096fe45ff5c399b15dd417458bd9f0c2ec0dea7 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 18:19:41 +0200 Subject: [PATCH 86/97] docs(llc): describe the engine's behaviour, not the implementer's duty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Sink` in dart:core is the model: indicative for what a member does — "Closes the sink", "Calling this method more than once is allowed, but does nothing" — and "must" only where the caller has an obligation, never the implementer. The previous "Must fail" and "Must notify" addressed whoever writes an engine, who is not who reads this. Also drops "rather than closing it to make room", which prescribed how to satisfy the contract and said nothing "fails when a connection is already open" does not already imply. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/ws/client/engine/web_socket_engine.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart index 7d9c72a3..aa0742b8 100644 --- a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart @@ -19,8 +19,7 @@ abstract interface class WebSocketEngine { /// Creates a new WebSocket connection using the provided [options] and sets up /// event listeners. /// - /// Must fail when a connection is already open, rather than closing it to make room. Call [close] - /// first. + /// Fails when a connection is already open. Call [close] before opening another. /// /// Returns a [Result] indicating success or failure of the connection attempt. Future> open(WebSocketOptions options); @@ -29,8 +28,8 @@ abstract interface class WebSocketEngine { /// /// Closes the active WebSocket connection with the specified [closeCode] and [closeReason]. /// - /// Must notify the listener that the connection closed before completing, including when there was - /// no connection to close. A close that fails notifies nothing and reports the failure instead. + /// Notifies the listener that the connection closed before completing, including when there was no + /// connection to close. A close that fails notifies nothing and reports the failure instead. /// /// Returns a [Result] indicating success or failure of the close operation. Future> close([int? closeCode, String? closeReason]); From 885a3faf13207c9da521155b56851a93e87c0a5b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 19:22:36 +0200 Subject: [PATCH 87/97] fix(llc): keep a stale handshake or closure from reporting on its successor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both callbacks the engine hands the listener could describe a socket the engine had already let go of. `open` returned the handshake future without waiting on it, so a `close` that landed first did not stop it: the handshake completed against the discarded socket and reported a connection open. The client would authenticate a socket it no longer holds, and a send that fails relabels the closure `AuthenticationFailed` — which never reconnects, so a `ConnectTimeout` that should have been retried is lost. The awaited handshake now reports only while its own socket is current. `close` announced its closure unconditionally, even though closing yields and a new socket can open in that window. The closure of the old socket would then be recorded against the new one, bringing down a connection that is being established. It is announced only while nothing has taken its place. `_subject` in the engine test reuses one socket, so a test about two of them failed on a stream already listened to rather than on anything the engine decided; `_subjectWithFreshSockets` gives each `open` its own. That also fixes "refuses to open a socket while one is still open", which asserted the live socket was untouched while looking at the socket a second `open` would have created — now it asserts no second socket exists at all. Co-Authored-By: Claude Opus 5 (1M context) --- .../engine/stream_web_socket_engine.dart | 14 +++-- .../stream_core/test/helpers/web_socket.dart | 9 ++- .../engine/stream_web_socket_engine_test.dart | 58 ++++++++++++++++++- .../client/stream_web_socket_client_test.dart | 23 ++++++++ 4 files changed, 95 insertions(+), 9 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart index 0c6fe146..a908bdc8 100644 --- a/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart @@ -53,21 +53,24 @@ class StreamWebSocketEngine implements WebSocketEngine { @override Future> open(WebSocketOptions options) { - return runSafely(() { + return runSafely(() async { if (_ws != null) { throw StateError('WebSocket is already open. Call close() first.'); } // Create a new WebSocket connection. - _ws = _wsProvider.call(options); - _wsSubscription = _ws?.stream.listen( + final ws = _ws = _wsProvider.call(options); + _wsSubscription = ws.stream.listen( _onData, onDone: _onDone, cancelOnError: false, onError: _listener?.onError, ); - return _ws?.ready.then((_) => _listener?.onOpen()); + await ws.ready; + + // A handshake already in flight outlives `close`, so a late one must not report a stale socket. + if (_ws == ws) _listener?.onOpen(); }); } @@ -109,7 +112,8 @@ class StreamWebSocketEngine implements WebSocketEngine { await subscription?.cancel(); await ws?.sink.close(closeCode, closeReason); - _listener?.onClose(closeCode, closeReason); + // A new socket can open while this one closes, and must not be brought down by its closure. + if (_ws == null) _listener?.onClose(closeCode, closeReason); }); } diff --git a/packages/stream_core/test/helpers/web_socket.dart b/packages/stream_core/test/helpers/web_socket.dart index 80626f0d..b767c2fb 100644 --- a/packages/stream_core/test/helpers/web_socket.dart +++ b/packages/stream_core/test/helpers/web_socket.dart @@ -120,10 +120,17 @@ class FakeWebSocketChannel extends StreamChannelMixin implements WebSoc @override Stream get stream => _incoming.stream; + final _ready = Completer(); + + /// Lets a handshake held by `holdReady` finish, for one that lands late. + void completeReady() { + if (!_ready.isCompleted) _ready.complete(); + } + @override Future get ready { if (readyError case final error?) return Future.error(error); - if (holdReady) return Completer().future; + if (holdReady) return _ready.future; return Future.value(); } diff --git a/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart b/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart index d3b7f04b..b409f049 100644 --- a/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart +++ b/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart @@ -60,6 +60,38 @@ _subject({bool closeFails = false}) { return (engine: engine, listener: listener, socket: socket); } +/// Builds an engine that opens a fresh socket each time, for a test about more than one of them. +/// +/// [_subject] reuses one socket, so a second `open` there fails on a stream already listened to +/// rather than on anything the engine decided. +({ + StreamWebSocketEngine engine, + _RecordingListener listener, + List sockets, +}) +_subjectWithFreshSockets({bool holdFirstClose = false}) { + final sockets = []; + final listener = _RecordingListener(); + + final engine = StreamWebSocketEngine( + listener: listener, + messageCodec: const _StringCodec(), + wsProvider: (_) { + final socket = FakeWebSocketChannel(holdClose: holdFirstClose && sockets.isEmpty); + sockets.add(socket); + return socket; + }, + ); + + addTearDown(() { + for (final socket in sockets) { + socket.endStream(); + } + }); + + return (engine: engine, listener: listener, sockets: sockets); +} + const _options = WebSocketOptions(url: 'wss://example.com'); void main() { @@ -143,6 +175,25 @@ void main() { expect(listener.closures, [(code: CloseCode.normalClosure, reason: 'done')]); }); + test('does not report the closure of a socket that has already been replaced', () async { + // Only the first socket holds its close, so it is still closing when the next one opens. + final (:engine, :listener, :sockets) = _subjectWithFreshSockets(holdFirstClose: true); + + await engine.open(_options); + final closing = engine.close(); + await pumpEventQueue(); + await engine.open(_options); + + sockets.first.sink.completeClose(); + await closing; + await pumpEventQueue(); + + // Closing yields, so the second socket is already open by the time the first one finishes. + // Reported now, its closure would bring down a connection that is being established. + expect(listener.opened, 2); + expect(listener.closures, isEmpty); + }); + test('reports the closure for every close it is asked for', () async { final (:engine, :listener, socket: _) = _subject(); await engine.open(_options); @@ -166,15 +217,16 @@ void main() { }); test('refuses to open a socket while one is still open', () async { - final (:engine, :listener, :socket) = _subject(); + final (:engine, :listener, :sockets) = _subjectWithFreshSockets(); await engine.open(_options); final result = await engine.open(_options); // Closing the live socket to make room would hide a caller opening a second connection over a - // connection it still has. + // connection it still has. Refused before a second socket is even created. expect(result.isFailure, isTrue); - expect(socket.sink.closedWith, isNull); + expect(sockets, hasLength(1)); + expect(sockets.single.sink.closedWith, isNull); expect(listener.closures, isEmpty); }); diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index 86c6cc04..e9a01e64 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -77,6 +77,29 @@ void main() { }, ); + wsClientTest( + 'ignores a handshake that finishes after the connection was closed', + handshakeHangs: true, + connect: (_) {}, + body: (tester) async { + tester.client.connect().ignore(); + await tester.pumpEventQueue(); + + await tester.client.disconnect(); + await tester.pumpEventQueue(); + expect(tester.connectionState, isA().having((it) => it.source, 'source', isA())); + + // Acted on, it would authenticate a socket the client has let go of, and failing to send + // would relabel the closure as `AuthenticationFailed`. That costs a caller their own reason + // here; on the connect-timeout path it turns a reconnectable `ConnectTimeout` into a state + // that never reconnects. + tester.server.sockets.last.completeReady(); + await tester.pumpEventQueue(); + + expect(tester.connectionState, isA().having((it) => it.source, 'source', isA())); + }, + ); + wsClientTest( 'closes a socket whose handshake failed', handshakeFails: true, From c2b79590e50040c1bb8cf3f42abcceb0d78671f6 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 19:22:51 +0200 Subject: [PATCH 88/97] fix(llc): ignore a pong that lands before the credentials go out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine subscribes to the socket before the handshake completes, so a frame can be decoded and handed to the client while it is still `Connecting`. Acted on, it reports a connection established that has never presented credentials, and the authenticator then runs against a state that already says `Connected`. The two guards below it were untested. Both were written for a late pong, but a close cancels the subscription before the socket yields, so nothing sent through the fake server ever reached them — the existing tests passed on the state not having changed for a reason that had nothing to do with the guards. The new pair call `onMessage` directly, which is how the engine delivers a frame already in its queue when the state flipped, and each fails without the guard it covers. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/logger/impl/tagged_logger.dart | 40 ----- .../stream_core/lib/src/logger/logger.dart | 3 - .../lib/src/logger/stream_log.dart | 151 ------------------ .../ws/client/stream_web_socket_client.dart | 5 +- .../client/stream_web_socket_client_test.dart | 36 +++++ 5 files changed, 39 insertions(+), 196 deletions(-) delete mode 100644 packages/stream_core/lib/src/logger/impl/tagged_logger.dart delete mode 100644 packages/stream_core/lib/src/logger/logger.dart delete mode 100644 packages/stream_core/lib/src/logger/stream_log.dart diff --git a/packages/stream_core/lib/src/logger/impl/tagged_logger.dart b/packages/stream_core/lib/src/logger/impl/tagged_logger.dart deleted file mode 100644 index 5b9bb926..00000000 --- a/packages/stream_core/lib/src/logger/impl/tagged_logger.dart +++ /dev/null @@ -1,40 +0,0 @@ -import '../stream_log.dart'; -import '../stream_logger.dart'; - -TaggedLogger taggedLogger({required Tag tag}) { - return TaggedLogger(tag); -} - -class TaggedLogger { - const TaggedLogger(this.tag); - - final Tag tag; - - void v(MessageBuilder message) { - streamLog.v(tag, message); - } - - void d(MessageBuilder message) { - streamLog.d(tag, message); - } - - void i(MessageBuilder message) { - streamLog.i(tag, message); - } - - void w(MessageBuilder message) { - streamLog.w(tag, message); - } - - void e(MessageBuilder message) { - streamLog.e(tag, message); - } - - void log(Priority priority, MessageBuilder message) { - streamLog.log(priority, tag, message); - } - - void logConditional(String? Function(Priority priority) messageBuilder) { - streamLog.logConditional(tag, messageBuilder); - } -} diff --git a/packages/stream_core/lib/src/logger/logger.dart b/packages/stream_core/lib/src/logger/logger.dart deleted file mode 100644 index 3f614f87..00000000 --- a/packages/stream_core/lib/src/logger/logger.dart +++ /dev/null @@ -1,3 +0,0 @@ -export 'impl/tagged_logger.dart'; -export 'stream_log.dart'; -export 'stream_logger.dart'; diff --git a/packages/stream_core/lib/src/logger/stream_log.dart b/packages/stream_core/lib/src/logger/stream_log.dart deleted file mode 100644 index f4709d12..00000000 --- a/packages/stream_core/lib/src/logger/stream_log.dart +++ /dev/null @@ -1,151 +0,0 @@ -// ignore_for_file: omit_obvious_property_types - -import 'stream_logger.dart'; - -StreamLog get streamLog => StreamLog(); - -class StreamLog { - factory StreamLog() { - return _instance; - } - - StreamLog._(); - - static final _instance = StreamLog._(); - - StreamLogger _logger = const SilentStreamLogger(); - IsLoggableValidator _validator = (Priority priority, Tag tag) => false; - Finder _finder = _defaultFinder; - Priority _priority = Priority.none; - - static StreamLog get instance => _instance; - static List excludeTags = []; - static List includeOnlyTags = []; - - set logger(StreamLogger logger) { - _logger = logger; - } - - set priority(Priority priority) { - _priority = priority; - _validator = (logPriority, tag) { - if (excludeTags.isNotEmpty && excludeTags.contains(tag)) { - return false; - } - - if (includeOnlyTags.isNotEmpty && !includeOnlyTags.contains(tag)) { - return false; - } - - return logPriority.index >= priority.index; - }; - } - - set validator(IsLoggableValidator validator) { - _validator = validator; - } - - set finder(Finder finder) { - _finder = finder; - } - - T? find([dynamic criteria]) { - return _finder.call(criteria); - } - - void v(Tag tag, MessageBuilder message) { - if (_validator.call(Priority.verbose, tag)) { - _logger.log(Priority.verbose, tag, message); - } - } - - void d(Tag tag, MessageBuilder message) { - if (_validator.call(Priority.debug, tag)) { - _logger.log(Priority.debug, tag, message); - } - } - - void i(Tag tag, MessageBuilder message) { - if (_validator.call(Priority.info, tag)) { - _logger.log(Priority.info, tag, message); - } - } - - void w(Tag tag, MessageBuilder message) { - if (_validator.call(Priority.warning, tag)) { - _logger.log(Priority.warning, tag, message); - } - } - - void e(Tag tag, MessageBuilder message) { - if (_validator.call(Priority.error, tag)) { - _logger.log(Priority.error, tag, message); - } - } - - void log(Priority priority, Tag tag, MessageBuilder message) { - if (_validator.call(priority, tag)) { - _logger.log(priority, tag, message); - } - } - - void logConditional( - Tag tag, - String? Function(Priority priority) messageBuilder, - ) { - final message = messageBuilder(_priority); - if (message != null && message.isNotEmpty) { - _logger.log( - _priority, - tag, - () => message, - ); - } - } - - static T? _defaultFinder([dynamic criteria]) { - final logger = _instance._logger; - if (logger is T) return logger; - - if (logger is CompositeStreamLogger) { - for (final child in logger.children) { - if (child is T) return child; - } - } - return null; - } -} - -class SilentStreamLogger extends StreamLogger { - const SilentStreamLogger(); - - @override - void log( - Priority priority, - String tag, - MessageBuilder message, [ - Object? error, - StackTrace? stk, - ]) { - /* no-op */ - } -} - -class CompositeStreamLogger extends StreamLogger { - const CompositeStreamLogger(this.children); - - final List children; - - @override - void log( - Priority priority, - String tag, - MessageBuilder message, [ - Object? error, - StackTrace? stk, - ]) { - for (final child in children) { - child.log(priority, tag, message, error, stk); - } - } -} diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index a6e485ab..c592b03f 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -278,8 +278,9 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, } void _handleHealthCheckEvent(WsEvent event, HealthCheckInfo info) { - // A late pong would set the state back to connected and overwrite the disconnection's source, - // turning a deliberate disconnect into a server close that gets reconnected. + // A pong counts only once credentials have gone out. Earlier it would report a connection + // established before it was authenticated; later it would overwrite why the connection closed. + if (connectionState.value case Connecting()) return; if (connectionState.value case Disconnecting()) return; if (connectionState.value case Disconnected()) return; diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index e9a01e64..7d2b9ef7 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -1020,6 +1020,42 @@ void main() { }, ); + wsClientTest( + 'ignores one that arrives before the credentials have gone out', + handshakeHangs: true, + connect: (tester) async { + tester.client.connect().ignore(); + await tester.pumpEventQueue(); + }, + body: (tester) { + // The engine subscribes before the handshake completes, so a frame can reach the client + // while it is still connecting. Acted on, it would report a connection established that + // has never presented credentials. + expect(tester.connectionState, isA()); + + tester.client.onMessage(const HealthCheck(connectionId: 'early')); + + expect(tester.connectionState, isA()); + }, + ); + + wsClientTest( + 'ignores one the engine delivers after the state has moved on', + holdClose: true, + body: (tester) { + tester.client.disconnect().ignore(); + expect(tester.connectionState, isA()); + + // Delivered straight to the listener, as the engine does for a frame already in its queue + // when the state flipped. Closing cancels the subscription, so a frame sent through the + // socket is dropped before it gets here and cannot reach this guard. + tester.client.onMessage(const HealthCheck(connectionId: 'late')); + + expect(tester.connectionState, isA()); + tester.server.socket.sink.completeClose(); + }, + ); + wsClientTest( 'leaves the disconnection source intact once the socket closes', holdClose: true, From e7c5236d8a3f5a83e8da8e923264104706d9ce42 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 19:23:19 +0200 Subject: [PATCH 89/97] fix(llc): tell a newer refusal from the one being answered by identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_previousError` is cleared once the attempt that was handed it returns, so the next attempt does not present credentials the server has already refused. It was cleared by comparing values, and `StreamApiError` is a value type — a second refusal arriving while the authenticator ran compares equal to the first, so the attempt answering the older one spent the newer one too. The attempt after it then had nothing to answer and presented the same refused credentials again. Comparing by identity distinguishes them: the same refusal is the same object, an equal one is not. Co-Authored-By: Claude Opus 5 (1M context) --- .../web_socket_authentication_handler.dart | 6 +++--- ...eb_socket_authentication_handler_test.dart | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart index 2d355c5a..6941e416 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart @@ -95,9 +95,9 @@ class WebSocketAuthenticationHandler { // as `AuthenticationFailed`, which never reconnects. The refusal stays armed for that one. if (attempt != _attempt) return; - // This attempt answered it, so it is spent — unless the server refused something newer while the - // authenticator ran, which the next attempt still needs to see. - if (_previousError == previousError) _previousError = null; + // Spent, unless the server refused something newer while the authenticator ran. By identity, + // not equality: a newer refusal of the same kind compares equal to this one. + if (identical(_previousError, previousError)) _previousError = null; if (result case Failure(:final error)) return _onFailure(error); } diff --git a/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart b/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart index 9afd3f94..cc47d802 100644 --- a/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart @@ -88,6 +88,27 @@ void main() { expect(authentication.previousError, isNull); }); + test('previousError survives a newer refusal equal to the one being answered', () async { + // `StreamApiError` compares by value, so a second refusal of the same kind cannot be told from + // the first by comparing them. Spent on the strength of that, it would leave the attempt after + // this one with nothing to answer, and it would present the same refused credentials again. + final held = Completer(); + final (:authentication, asked: _, failures: _) = _subject( + authenticator: (send, previousError) => held.future, + ); + + authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); + authentication.onConnectionStateChanged(const Connecting()); + final running = authentication.authenticate(); + + // The server refuses again while this attempt is still awaiting its credentials. + authentication.onConnectionStateChanged(_serverClosure(_apiError(code: 40))); + held.complete(); + await running; + + expect(authentication.previousError, _expiredToken); + }); + test('previousError is forgotten once the caller has disconnected', () { final (:authentication, asked: _, failures: _) = _subject(); authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); From 8756fa47c7423ba7e3c8d0d4be1a926cc104b170 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 19:23:35 +0200 Subject: [PATCH 90/97] docs(changelog): log the Dart SDK bump as a breaking change A consumer on an older SDK cannot take this release at all, which is the strongest thing the entry says and is not what "Changed" conveys. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 568ea572..f0821860 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -2,6 +2,7 @@ ### 💥 BREAKING CHANGES +- Raised the minimum Dart SDK to `^3.12.0` - Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `User.anonymousUserId` - Removed the `TokenManager.tokenProvider` setter, use `setTokenProvider` instead - `TokenManager.userId` is now nullable, and is `null` until an identity is configured @@ -42,7 +43,6 @@ ### 🔄 Changed -- Raised the minimum Dart SDK to `^3.12.0` - Anonymous requests now always send `user_id=!anon`, rather than whatever id the `TokenManager` was configured with - `DynamicTokenProvider` checks the token type before its user id, so a token of the wrong type is reported as such instead of as a mismatched user - `TokenManager.getToken` fails when `reset` runs while the token is loading, and rejects a token whose `user_id` is not the user it was loading for; a `setTokenProvider` during a load still serves the caller that started it From 1bb50a41977872e3657083507c73c852d4a9b7b4 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 19:23:44 +0200 Subject: [PATCH 91/97] fix(llc): leave the unset connect details out of the payload `ConnectUserDetailsRequest.fromUser` documents `includeDetails: false` as sending the id alone, and it did not: every other field went out as an explicit null. The server is then asked to tell "no opinion" from "clear this", off a request that meant neither. `includeIfNull: false` drops them, so the wire form matches what the factory says it sends. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 1 + .../lib/src/user/connect_user_details_request.dart | 2 +- .../lib/src/user/connect_user_details_request.g.dart | 10 +++++----- .../test/user/connect_user_details_request_test.dart | 10 ++++++++++ 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index f0821860..86da232f 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -43,6 +43,7 @@ ### 🔄 Changed +- `ConnectUserDetailsRequest` leaves its unset fields out of the JSON it serialises, rather than sending them as nulls - Anonymous requests now always send `user_id=!anon`, rather than whatever id the `TokenManager` was configured with - `DynamicTokenProvider` checks the token type before its user id, so a token of the wrong type is reported as such instead of as a mismatched user - `TokenManager.getToken` fails when `reset` runs while the token is loading, and rejects a token whose `user_id` is not the user it was loading for; a `setTokenProvider` during a load still serves the caller that started it diff --git a/packages/stream_core/lib/src/user/connect_user_details_request.dart b/packages/stream_core/lib/src/user/connect_user_details_request.dart index ff613352..6bfea720 100644 --- a/packages/stream_core/lib/src/user/connect_user_details_request.dart +++ b/packages/stream_core/lib/src/user/connect_user_details_request.dart @@ -5,7 +5,7 @@ import 'user.dart'; part 'connect_user_details_request.g.dart'; -@JsonSerializable(createFactory: false) +@JsonSerializable(createFactory: false, includeIfNull: false) class ConnectUserDetailsRequest { const ConnectUserDetailsRequest({ required this.id, diff --git a/packages/stream_core/lib/src/user/connect_user_details_request.g.dart b/packages/stream_core/lib/src/user/connect_user_details_request.g.dart index 1d97036f..7ec0d0af 100644 --- a/packages/stream_core/lib/src/user/connect_user_details_request.g.dart +++ b/packages/stream_core/lib/src/user/connect_user_details_request.g.dart @@ -10,9 +10,9 @@ Map _$ConnectUserDetailsRequestToJson( ConnectUserDetailsRequest instance, ) => { 'id': instance.id, - 'image': instance.image, - 'invisible': instance.invisible, - 'language': instance.language, - 'name': instance.name, - 'custom': instance.custom, + 'image': ?instance.image, + 'invisible': ?instance.invisible, + 'language': ?instance.language, + 'name': ?instance.name, + 'custom': ?instance.custom, }; diff --git a/packages/stream_core/test/user/connect_user_details_request_test.dart b/packages/stream_core/test/user/connect_user_details_request_test.dart index c29cc132..f77fd92f 100644 --- a/packages/stream_core/test/user/connect_user_details_request_test.dart +++ b/packages/stream_core/test/user/connect_user_details_request_test.dart @@ -14,6 +14,16 @@ void main() { expect(details.custom, {'plan': 'pro'}); }); + test('sends nothing but the id when the details are excluded', () { + const user = User(id: 'user-1', name: 'Bob', image: 'https://example.com/bob.png', custom: {'plan': 'pro'}); + + final json = ConnectUserDetailsRequest.fromUser(user, includeDetails: false).toJson(); + + // An unset field is left out rather than sent as a null, so the server is not asked to + // distinguish "no opinion" from "clear this". + expect(json, {'id': 'user-1'}); + }); + test('leaves out the fields the server decides itself', () { const user = User(id: 'user-1', role: 'admin', teams: ['red']); From 61a0045912d70c8da0f03b8eb964046075c28f6e Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 19:23:44 +0200 Subject: [PATCH 92/97] test(llc): cover how a refused request is turned into an exception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `apiError` and `toClientException` had no tests of their own, and the interceptor tests that reach them only ever see one shape of failure. Both pick between the API's account of a failure and the transport's, so each test makes the two disagree — a 429 in the body against a 500 on the response — and names which one won. Asserting on a value both sources would produce is not a test of the choice. Co-Authored-By: Claude Opus 5 (1M context) --- .../test/api/stream_core_dio_error_test.dart | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 packages/stream_core/test/api/stream_core_dio_error_test.dart diff --git a/packages/stream_core/test/api/stream_core_dio_error_test.dart b/packages/stream_core/test/api/stream_core_dio_error_test.dart new file mode 100644 index 00000000..d07fcbd1 --- /dev/null +++ b/packages/stream_core/test/api/stream_core_dio_error_test.dart @@ -0,0 +1,119 @@ +import 'dart:convert'; + +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +/// The body the API returns when it refuses a request. +Map _errorBody({int code = 40, int statusCode = 401, String message = 'token expired'}) => { + 'code': code, + 'details': [], + 'duration': '0ms', + 'message': message, + 'more_info': '', + 'StatusCode': statusCode, +}; + +DioException _failure({ + Object? body, + int? statusCode, + String? statusMessage, + String? message, + DioExceptionType type = DioExceptionType.badResponse, +}) { + final options = RequestOptions(path: '/test'); + return DioException( + requestOptions: options, + type: type, + message: message, + response: statusCode == null + ? null + : Response( + requestOptions: options, + statusCode: statusCode, + statusMessage: statusMessage, + data: body, + ), + ); +} + +void main() { + group('DioException.apiError', () { + test('reads the Stream error from a decoded body', () { + final error = _failure(body: _errorBody(), statusCode: 401).apiError; + + expect(error?.code, 40); + expect(error?.statusCode, 401); + }); + + test('reads the Stream error from a body the server sent as text', () { + // Without a JSON content type Dio hands the body over as a string, and the refusal is the + // same one either way. + final error = _failure(body: jsonEncode(_errorBody()), statusCode: 401).apiError; + + expect(error?.code, 40); + }); + + test('reads null from a body that is not a Stream error', () { + // A proxy or gateway can answer with JSON of its own, which must not throw on the way out. + expect(_failure(body: {'error': 'gateway timeout'}, statusCode: 504).apiError, isNull); + expect(_failure(body: 'not json at all', statusCode: 502).apiError, isNull); + expect(_failure(statusCode: 500).apiError, isNull); + expect(_failure().apiError, isNull); + }); + }); + + group('DioException.toClientException', () { + test('takes its message, status code and cause from the Stream error', () { + // The error and the response deliberately disagree, so each assertion says which one won. + // Given the same values, either source would satisfy them. + final exception = _failure( + body: _errorBody(statusCode: 429), + statusCode: 500, + statusMessage: 'Internal Server Error', + ).toClientException(); + + // The API's own account of the failure says more than the transport's, so it wins. + expect(exception.message, 'token expired'); + expect(exception.statusCode, 429); + expect(exception.apiError?.code, 40); + }); + + test('falls back to what the transport reported when the response carried no Stream error', () { + final dioException = _failure( + body: {'error': 'gateway timeout'}, + statusCode: 504, + statusMessage: 'Gateway Timeout', + ); + + final exception = dioException.toClientException(); + + expect(exception.message, 'Gateway Timeout'); + expect(exception.statusCode, 504); + // Nothing better to blame, so the transport failure is the cause. + expect(exception.underlyingError, same(dioException)); + expect(exception.apiError, isNull); + }); + + test('falls back to the exception message when there is no response at all', () { + final exception = _failure(message: 'connection refused').toClientException(); + + expect(exception.message, 'connection refused'); + expect(exception.statusCode, isNull); + }); + + test('never leaves the message null, so a caller always has something to show', () { + final exception = _failure().toClientException(); + + expect(exception.message, isEmpty); + }); + + test('marks a request the caller cancelled as such', () { + final cancelled = _failure(type: DioExceptionType.cancel).toClientException(); + final refused = _failure(body: _errorBody(), statusCode: 401).toClientException(); + + // A caller that called the request off should not be shown it as a failure. + expect(cancelled.isRequestCancelledError, isTrue); + expect(refused.isRequestCancelledError, isFalse); + }); + }); +} From a0e5762bc5eca6ac0d45171cbccb1bb84384779d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 19:24:02 +0200 Subject: [PATCH 93/97] docs(llc): say the connection comments in one line where one will do Each of these ran to two or three lines to explain a guard whose point fits on one. `// Return early if the emitter is closed.` above `if (isClosed) return` went entirely; it restated the line under it. Two dropped a claim rather than shortening it. `onFailure receives the cause when authentication fails` says no more than the parameter's name and type. The `previousError` paragraph on `onConnectionStateChanged` described when the field is set and cleared, which is documented on the field itself; what the method owes a reader is that every state other than `Connecting` only updates it. Co-Authored-By: Claude Opus 5 (1M context) --- .../ws/client/stream_web_socket_client.dart | 19 +++++++------------ .../web_socket_authentication_handler.dart | 19 ++++++------------- .../client/stream_web_socket_client_test.dart | 4 ++-- 3 files changed, 15 insertions(+), 27 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index c592b03f..e9997f4a 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -84,8 +84,7 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, late final WebSocketAuthenticationHandler _authenticationHandler; late final _healthMonitor = WebSocketHealthMonitor(listener: this); - // Bounds an attempt while it is `Connecting` or `Authenticating`. Once the connection is - // established, the health monitor takes over. + // Bounds an attempt while `Connecting` or `Authenticating`; the health monitor takes over after. Timer? _connectTimeoutTimer; void _startConnectTimeout(Duration timeout) { @@ -114,7 +113,6 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, late final _connectionStateEmitter = MutableConnectionStateEmitter(const .initialized()); set _connectionState(WebSocketConnectionState connectionState) { - // Return early if the emitter is closed. if (_connectionStateEmitter.isClosed) return; // Return early if the state hasn't changed. @@ -144,8 +142,7 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, /// /// Throws a [StateError] once [dispose] has been called. Future connect() async { - // A disposed client cannot report a state change or close an idle connection, so a socket opened - // here would be invisible to everyone. + // A socket opened here would be invisible: a disposed client reports no state and closes nothing. if (isDisposed) throw StateError('Cannot connect a disposed StreamWebSocketClient'); // If the connection is already established or in the process of connecting, @@ -165,9 +162,8 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, _startConnectTimeout(options.connectTimeout); final result = await _engine.open(options); - // Hand a failed attempt to `disconnect`, which already reports the reason, closes the socket, and - // records the closure even when the close itself fails. Returned, not discarded: a caller that - // connects again straight away would otherwise be refused for racing a close still under way. + // Handed to `disconnect`, which reports the reason, closes the socket, and records the closure + // even when the close fails. Returned, so a caller connecting again is not refused for the race. return result.getOrElse( (error, _) => disconnect( source: .serverInitiated(error: .new(error: error)), @@ -193,8 +189,8 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, if (connectionState.value case Initialized()) return; - // A source that blocks reconnection overwrites a closure already recorded or underway, or a - // pending reconnect could fire past it. Any other source leaves the existing closure alone. + // A source that blocks reconnection overwrites one already recorded, or a pending reconnect + // fires past it. final forceDisconnect = source is UserInitiated || source is AuthenticationFailed; if (connectionState.value case Disconnecting() when !forceDisconnect) return; if (connectionState.value case Disconnected() when !forceDisconnect) return; @@ -248,8 +244,7 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Update the connection state to 'disconnecting' with the source. // - // The socket closes itself after reporting an error, so the closure that follows is what records - // the disconnection. + // The socket closes itself after an error, so the closure that follows records the disconnection. _connectionState = WebSocketConnectionState.disconnecting(source: source); } diff --git a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart index 6941e416..499ed3e1 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart @@ -29,8 +29,7 @@ typedef WebSocketAuthenticator = Future Function(WsRequestSender send, Str class WebSocketAuthenticationHandler { /// Creates a [WebSocketAuthenticationHandler]. /// - /// `authenticator` may be null, for a connection that needs nothing sent. `onFailure` receives - /// the cause when authentication fails. + /// `authenticator` may be null, for a connection that needs nothing sent. WebSocketAuthenticationHandler({ required this._authenticator, required this._send, @@ -41,8 +40,7 @@ class WebSocketAuthenticationHandler { final WsRequestSender _send; final void Function(Object error) _onFailure; - // Identifies the attempt in flight, because an authenticator can outlive the attempt that started - // it and still hold a sender and a failure path aimed at whatever connection is current by then. + // Identifies the attempt in flight: an authenticator can outlive the one that started it. var _attempt = 0; /// The error the server closed the previous attempt with, if it sent one. @@ -55,11 +53,8 @@ class WebSocketAuthenticationHandler { /// Takes in a connection state change. /// /// A [Connecting] state begins an attempt, after which an authenticator still running for an - /// earlier one can neither send nor report a failure. - /// - /// [previousError] is set when the server closes the connection with an error, and cleared when a - /// connection is established or the caller disconnects. It is otherwise left alone, so a refusal - /// outlives the states an attempt passes through. + /// earlier one can neither send nor report a failure. Every other state only updates + /// [previousError], which is left alone unless the server refused or the caller took over. void onConnectionStateChanged(WebSocketConnectionState state) { if (state case Connecting()) _attempt++; @@ -91,8 +86,7 @@ class WebSocketAuthenticationHandler { // Guarded because nothing awaits this: an error thrown here would go unhandled. final result = await runSafely(() => authenticate(_senderFor(attempt), previousError)); - // This attempt is stale. Reporting its failure now would close the connection that replaced it - // as `AuthenticationFailed`, which never reconnects. The refusal stays armed for that one. + // Stale: its failure would close the connection that replaced it, and never be reconnected. if (attempt != _attempt) return; // Spent, unless the server refused something newer while the authenticator ran. By identity, @@ -102,8 +96,7 @@ class WebSocketAuthenticationHandler { if (result case Failure(:final error)) return _onFailure(error); } - // An authenticator holds its sender across its own awaits, so check the attempt when a request is - // actually sent rather than once up front. + // The sender is held across the authenticator's own awaits, so the attempt is checked on each send. WsRequestSender _senderFor(int attempt) => (request) { if (attempt == _attempt) return _send(request); diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index 7d2b9ef7..77b28916 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -32,8 +32,8 @@ void main() { handshakeFails: true, connect: _justConnect, body: (tester) { - // Closed without saying why, the attempt reads as the deliberate close the engine defaults - // to, and a closure with that code is never reconnected. + // Reported with its cause, so the closure stays reconnectable. Closed without one it would + // read as the deliberate close the engine defaults to, which never is. expect( tester.connectionState, isA().having( From df49bb85a161215b7094eb5cc765b983b3011fa2 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 19:31:42 +0200 Subject: [PATCH 94/97] revert(llc): restore the logger files this PR never meant to remove `fix(llc): ignore a pong that lands before the credentials go out` also deleted `stream_log.dart`, `logger/logger.dart` and `impl/tagged_logger.dart`, which have nothing to do with a pong. They were swept in from a staged deletion that belonged to another branch. Nothing referenced them, so their removal changed no behaviour and broke no public API, but retiring the logger is its own change and does not belong in this PR. --- .../lib/src/logger/impl/tagged_logger.dart | 40 +++++ .../stream_core/lib/src/logger/logger.dart | 3 + .../lib/src/logger/stream_log.dart | 151 ++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 packages/stream_core/lib/src/logger/impl/tagged_logger.dart create mode 100644 packages/stream_core/lib/src/logger/logger.dart create mode 100644 packages/stream_core/lib/src/logger/stream_log.dart diff --git a/packages/stream_core/lib/src/logger/impl/tagged_logger.dart b/packages/stream_core/lib/src/logger/impl/tagged_logger.dart new file mode 100644 index 00000000..5b9bb926 --- /dev/null +++ b/packages/stream_core/lib/src/logger/impl/tagged_logger.dart @@ -0,0 +1,40 @@ +import '../stream_log.dart'; +import '../stream_logger.dart'; + +TaggedLogger taggedLogger({required Tag tag}) { + return TaggedLogger(tag); +} + +class TaggedLogger { + const TaggedLogger(this.tag); + + final Tag tag; + + void v(MessageBuilder message) { + streamLog.v(tag, message); + } + + void d(MessageBuilder message) { + streamLog.d(tag, message); + } + + void i(MessageBuilder message) { + streamLog.i(tag, message); + } + + void w(MessageBuilder message) { + streamLog.w(tag, message); + } + + void e(MessageBuilder message) { + streamLog.e(tag, message); + } + + void log(Priority priority, MessageBuilder message) { + streamLog.log(priority, tag, message); + } + + void logConditional(String? Function(Priority priority) messageBuilder) { + streamLog.logConditional(tag, messageBuilder); + } +} diff --git a/packages/stream_core/lib/src/logger/logger.dart b/packages/stream_core/lib/src/logger/logger.dart new file mode 100644 index 00000000..3f614f87 --- /dev/null +++ b/packages/stream_core/lib/src/logger/logger.dart @@ -0,0 +1,3 @@ +export 'impl/tagged_logger.dart'; +export 'stream_log.dart'; +export 'stream_logger.dart'; diff --git a/packages/stream_core/lib/src/logger/stream_log.dart b/packages/stream_core/lib/src/logger/stream_log.dart new file mode 100644 index 00000000..f4709d12 --- /dev/null +++ b/packages/stream_core/lib/src/logger/stream_log.dart @@ -0,0 +1,151 @@ +// ignore_for_file: omit_obvious_property_types + +import 'stream_logger.dart'; + +StreamLog get streamLog => StreamLog(); + +class StreamLog { + factory StreamLog() { + return _instance; + } + + StreamLog._(); + + static final _instance = StreamLog._(); + + StreamLogger _logger = const SilentStreamLogger(); + IsLoggableValidator _validator = (Priority priority, Tag tag) => false; + Finder _finder = _defaultFinder; + Priority _priority = Priority.none; + + static StreamLog get instance => _instance; + static List excludeTags = []; + static List includeOnlyTags = []; + + set logger(StreamLogger logger) { + _logger = logger; + } + + set priority(Priority priority) { + _priority = priority; + _validator = (logPriority, tag) { + if (excludeTags.isNotEmpty && excludeTags.contains(tag)) { + return false; + } + + if (includeOnlyTags.isNotEmpty && !includeOnlyTags.contains(tag)) { + return false; + } + + return logPriority.index >= priority.index; + }; + } + + set validator(IsLoggableValidator validator) { + _validator = validator; + } + + set finder(Finder finder) { + _finder = finder; + } + + T? find([dynamic criteria]) { + return _finder.call(criteria); + } + + void v(Tag tag, MessageBuilder message) { + if (_validator.call(Priority.verbose, tag)) { + _logger.log(Priority.verbose, tag, message); + } + } + + void d(Tag tag, MessageBuilder message) { + if (_validator.call(Priority.debug, tag)) { + _logger.log(Priority.debug, tag, message); + } + } + + void i(Tag tag, MessageBuilder message) { + if (_validator.call(Priority.info, tag)) { + _logger.log(Priority.info, tag, message); + } + } + + void w(Tag tag, MessageBuilder message) { + if (_validator.call(Priority.warning, tag)) { + _logger.log(Priority.warning, tag, message); + } + } + + void e(Tag tag, MessageBuilder message) { + if (_validator.call(Priority.error, tag)) { + _logger.log(Priority.error, tag, message); + } + } + + void log(Priority priority, Tag tag, MessageBuilder message) { + if (_validator.call(priority, tag)) { + _logger.log(priority, tag, message); + } + } + + void logConditional( + Tag tag, + String? Function(Priority priority) messageBuilder, + ) { + final message = messageBuilder(_priority); + if (message != null && message.isNotEmpty) { + _logger.log( + _priority, + tag, + () => message, + ); + } + } + + static T? _defaultFinder([dynamic criteria]) { + final logger = _instance._logger; + if (logger is T) return logger; + + if (logger is CompositeStreamLogger) { + for (final child in logger.children) { + if (child is T) return child; + } + } + return null; + } +} + +class SilentStreamLogger extends StreamLogger { + const SilentStreamLogger(); + + @override + void log( + Priority priority, + String tag, + MessageBuilder message, [ + Object? error, + StackTrace? stk, + ]) { + /* no-op */ + } +} + +class CompositeStreamLogger extends StreamLogger { + const CompositeStreamLogger(this.children); + + final List children; + + @override + void log( + Priority priority, + String tag, + MessageBuilder message, [ + Object? error, + StackTrace? stk, + ]) { + for (final child in children) { + child.log(priority, tag, message, error, stk); + } + } +} From 61d45d8ef2b2eaa7e4c12760bf64a0ab31c905f0 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 23:26:23 +0200 Subject: [PATCH 95/97] feat(llc): let a disconnection source report what closed the connection An SDK turning a `Disconnected` state into an exception wants the cause, and had to work it out from the outside: switch over the sources, know that only `ServerInitiated` and `AuthenticationFailed` carry one, and unwrap `ServerInitiated`'s `WebSocketEngineException` to reach the error the socket actually failed with. Anything less specific than that switch also had to end in a wildcard, so a source added later would have its cause silently dropped by every SDK that wrote one. `cause` puts that where `closeReason` already lives, on the sealed base, and its switch is exhaustive: a new source with an error to report fails to compile here, in front of whoever adds it. The unwrapping is what makes it worth having. `ClientException` sets `apiError` only when what it wraps is a `StreamApiError`, so handing it the `WebSocketEngineException` leaves a caller unable to see the refusal the server sent. The exception still stands in when it wraps nothing and carries only a close code. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 1 + .../client/web_socket_connection_state.dart | 12 ++++++ .../web_socket_connection_state_test.dart | 40 +++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 86da232f..2a1086b3 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -26,6 +26,7 @@ - Added `teams` field to `User` class - Added `DioException.apiError`, the Stream API error a response carried, or `null` for anything else - Added `DisconnectionSource.connectTimeout` and `authenticationFailed`, and `isReconnectable`, whether a connection closed for that reason is worth opening again +- Added `DisconnectionSource.cause`, what closed the connection, so an SDK reporting a failed `connect` no longer enumerates the sources itself; a `ServerInitiated` closure reports the error the socket failed with rather than the `WebSocketEngineException` wrapping it - Added `ConnectUserDetailsRequest.fromUser`, which builds the details a client may send from a `User` - Added `StreamWebSocketClient.dispose`, which closes the connection along with `events` and `connectionState`; the client is now `Disposable`, and `connect` throws a `StateError` afterwards - `StreamWebSocketClient` now honours `WebSocketOptions.connectTimeout`, no longer nullable and 30 seconds by default, so an attempt that never becomes usable is abandoned diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index aa1f003e..87994e8f 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -260,6 +260,18 @@ sealed class DisconnectionSource extends Equatable { }; } + /// What closed the connection, or `null` when this source carries no cause. + /// + /// A [ServerInitiated] closure reports the error the socket failed with, not the + /// [WebSocketEngineException] wrapping it — that stands in only when it wraps nothing. + Object? get cause { + return switch (this) { + ServerInitiated(:final error) => error?.error ?? error, + AuthenticationFailed(:final error) => error, + UserInitiated() || SystemInitiated() || UnHealthyConnection() || ConnectTimeout() => null, + }; + } + /// Whether a connection closed for this reason is worth opening again. /// /// {@template webSocketReconnectionRules} diff --git a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart index be9e1c9b..04554c3f 100644 --- a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart @@ -127,4 +127,44 @@ void main() { expect(state.isConnected, isFalse, reason: '$state'); } }); + + group('cause', () { + test('is what the socket failed with, not the exception carrying it', () { + final apiError = _apiError(40); + final source = ServerInitiated( + error: WebSocketEngineException(reason: apiError.message, code: 4001, error: apiError), + ); + + // Reported unwrapped so a caller can match on the error itself. Handed the exception, a + // caller checking for a `StreamApiError` would find none and report the closure as unexplained. + expect(source.cause, same(apiError)); + }); + + test('is the exception itself when it carries nothing but a close code', () { + const exception = WebSocketEngineException(reason: 'gone', code: 4001); + const source = ServerInitiated(error: exception); + + // The close code is the only account of the closure there is, so it stands in. + expect(source.cause, same(exception)); + }); + + test('is null for a server closure that named no reason at all', () { + expect(const ServerInitiated().cause, isNull); + }); + + test('is what authentication failed with', () { + final error = Exception('the token was refused'); + final source = AuthenticationFailed(error: error); + + expect(source.cause, same(error)); + }); + + test('is null for the sources that carry none', () { + // Nothing went wrong in these, or nothing that the source was told about. + expect(const UserInitiated().cause, isNull); + expect(const SystemInitiated().cause, isNull); + expect(const UnHealthyConnection().cause, isNull); + expect(const ConnectTimeout().cause, isNull); + }); + }); } From 385d4b58cb8b089defae3b4bae9906eabba8353c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 23:27:28 +0200 Subject: [PATCH 96/97] docs(changelog): say what `cause` is, not why it exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry explained that an SDK no longer has to enumerate the sources itself and that a server closure reports the error rather than the exception wrapping it — the reasoning behind the API and the shape of what it unwraps. A reader upgrading wants to know the getter is there and what it holds; the rest belongs on the member, where it already is. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 2a1086b3..a37960f4 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -26,7 +26,7 @@ - Added `teams` field to `User` class - Added `DioException.apiError`, the Stream API error a response carried, or `null` for anything else - Added `DisconnectionSource.connectTimeout` and `authenticationFailed`, and `isReconnectable`, whether a connection closed for that reason is worth opening again -- Added `DisconnectionSource.cause`, what closed the connection, so an SDK reporting a failed `connect` no longer enumerates the sources itself; a `ServerInitiated` closure reports the error the socket failed with rather than the `WebSocketEngineException` wrapping it +- Added `DisconnectionSource.cause`, the error that closed the connection, or `null` when the source carries none - Added `ConnectUserDetailsRequest.fromUser`, which builds the details a client may send from a `User` - Added `StreamWebSocketClient.dispose`, which closes the connection along with `events` and `connectionState`; the client is now `Disposable`, and `connect` throws a `StateError` afterwards - `StreamWebSocketClient` now honours `WebSocketOptions.connectTimeout`, no longer nullable and 30 seconds by default, so an attempt that never becomes usable is abandoned From c3050fc73755581af215ff7b9dbab76259c68e6a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 00:07:41 +0200 Subject: [PATCH 97/97] docs(llc): say what `cause` hands back, not how it is put together `not the WebSocketEngineException wrapping it` and `that stands in only when it wraps nothing` describe how the source stores its error, which is nothing a caller acts on. What they need is which of the two they will be handed, and when. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/ws/client/web_socket_connection_state.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index 87994e8f..11750df0 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -262,8 +262,8 @@ sealed class DisconnectionSource extends Equatable { /// What closed the connection, or `null` when this source carries no cause. /// - /// A [ServerInitiated] closure reports the error the socket failed with, not the - /// [WebSocketEngineException] wrapping it — that stands in only when it wraps nothing. + /// For a [ServerInitiated] closure this is the error that was reported, or a + /// [WebSocketEngineException] describing the close when nothing else was. Object? get cause { return switch (this) { ServerInitiated(:final error) => error?.error ?? error,