From ef0a29f8d2ddcf07123abd8bebb0b570b3b7f1f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Wed, 21 Oct 2026 14:48:32 +0200 Subject: [PATCH 01/29] Reconnect at once on a network change Watch the device's networks (connectivity_plus behind a NetworkMonitor seam): while joined, a change is a retryable loss; while reconnecting, attempts wait when offline and restart with a fresh budget on a new network. ICE disconnected for 3 s is a retryable loss. Client pings every 5 s, opening the socket times out after 10 s, and a new session no longer waits for the old socket to finish closing. Claude-Session: https://claude.ai/code/session_01Q5vZKmaVuaPpgoRaUvLQT8 --- AGENTS.md | 1 + README.md | 2 + SPEC.md | 36 +++++- example/pubspec.lock | 53 ++++++++ lib/src/connectivity_network_monitor.dart | 21 ++++ lib/src/engine/rtc_engine.dart | 18 +++ lib/src/fishjam_client.dart | 55 +++++++-- lib/src/signaling/network_monitor.dart | 68 +++++++++++ lib/src/signaling/reconnect.dart | 52 ++++++-- lib/src/signaling/transport.dart | 16 ++- pubspec.lock | 53 ++++++++ pubspec.yaml | 2 + test/client/client_harness.dart | 5 + .../connectivity_network_monitor_test.dart | 52 ++++++++ test/client/fishjam_client_test.dart | 85 +++++++++++++ test/engine/engine_harness.dart | 2 + test/engine/rtc_engine_test.dart | 49 ++++++++ test/fakes/fake_network.dart | 54 ++++++++ test/fakes/fake_transport.dart | 6 + test/signaling/network_watcher_test.dart | 107 ++++++++++++++++ test/signaling/reconnect_test.dart | 115 ++++++++++++++++++ test/signaling/websocket_transport_test.dart | 34 ++++++ 22 files changed, 861 insertions(+), 25 deletions(-) create mode 100644 lib/src/connectivity_network_monitor.dart create mode 100644 lib/src/signaling/network_monitor.dart create mode 100644 test/client/connectivity_network_monitor_test.dart create mode 100644 test/fakes/fake_network.dart create mode 100644 test/signaling/network_watcher_test.dart diff --git a/AGENTS.md b/AGENTS.md index 5ccba8d..95347bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,7 @@ Run analyze, format and tests before every commit. | `lib/src/livestream/` | WHIP/WHEP over plain HTTP, independent of the room connection | | `lib/src/models/`, `wire.dart`, `logger.dart`, `version.dart` | public immutable types and shared helpers | | `lib/src/fishjam_client.dart`, `lib/src/livestream_api.dart`, `lib/fishjam_client.dart` | the public facade; wiring and type conversion only | +| `lib/src/connectivity_network_monitor.dart` | the `NetworkMonitor` seam (`signaling/network_monitor.dart`) on `connectivity_plus`; the only file that imports it | | `test/fakes/`, `test/support/`, `test/fixtures/` | fakes, fixture replayer, golden sequences | ## Testing tools diff --git a/README.md b/README.md index db60299..8f3369b 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ await client.dispose(); A lost connection is re-established automatically (`ReconnectConfig`): +- A change of network (Wi-Fi to cellular, a different Wi-Fi, going offline) starts reconnecting at once instead + of waiting for the old connection to time out; while the device is offline, attempts wait for a network. - From `ReconnectionStarted` until `Reconnected` the status is `reconnecting` and calls that need the session (track operations, metadata updates, `createDataChannels()`) fail at once with `StateError`. - Before the new `Joined`, the remote tracks and peers you knew are reported with `TrackRemoved` and diff --git a/SPEC.md b/SPEC.md index d36cf2e..f2beced 100644 --- a/SPEC.md +++ b/SPEC.md @@ -36,8 +36,10 @@ Conventions: else becomes `https://fishjam.io/api/v1/connect/`. - Every frame in both directions is **binary** and contains exactly one `fishjam.PeerMessage`. - Text frames from the server are ignored (logged at debug level). -- The server pings every 30 s. The client additionally sends its own pings (interval 15 s) and treats a +- The server pings every 30 s. The client additionally sends its own pings (interval 5 s) and treats a missing pong within one interval as a connection loss (retryable, [§6](#6-reconnection)). +- Opening the socket (connection, TLS and upgrade) times out after 10 s; a timeout fails like a socket that + cannot be opened. ### 2.2 Messages @@ -175,8 +177,10 @@ States: `idle`, `reconnecting`, `failed`. | From | Trigger | Action | To | |---|---|---|---| -| idle (joined) | retryable loss: retryable close, socket error, pong timeout, peer connection `failed`, ICE `failed` | snapshot local peer metadata (`peer` part) and local tracks; emit `ReconnectionStarted`; schedule attempt 1 | reconnecting | -| reconnecting | attempt timer fires | tear down old session silently (no `Disconnected`); start a new session with the snapshot metadata | reconnecting | +| idle (joined) | retryable loss: retryable close, socket error, pong timeout, peer connection `failed`, ICE `failed`, ICE `disconnected` for 3 s ([§8.4](#84-peer-connection)), network change ([§6.1](#61-network-changes)) | snapshot local peer metadata (`peer` part) and local tracks; emit `ReconnectionStarted`; schedule attempt 1 | reconnecting | +| reconnecting | attempt timer fires while online | tear down old session silently (no `Disconnected`) without waiting for its socket to finish closing; start a new session with the snapshot metadata | reconnecting | +| reconnecting | attempt timer fires while offline ([§6.1](#61-network-changes)) | nothing; the attempt waits for the network and is not counted | reconnecting | +| reconnecting | the network comes back or changes ([§6.1](#61-network-changes)) | abandon the waiting, scheduled or running attempt; reset the attempt counter; start an attempt at once | reconnecting | | reconnecting | retryable loss in the new session before `connected` (including a new session that cannot be started at all) | schedule next attempt if attempts remain | reconnecting | | reconnecting | retryable loss, no attempts left | clear queue; emit `ReconnectionFailed`, then `Disconnected(DisconnectReason.reconnectionFailed)` | failed | | reconnecting | terminal close (auth/join/session ended/protocol) | cancel timer; clear queue; emit the matching error event and `Disconnected` | failed | @@ -198,6 +202,25 @@ States: `idle`, `reconnecting`, `failed`. - A **muted** track (no media track) is not re-added: there is nothing to publish. The app adds the track again when it unmutes. +### 6.1 Network changes + +The client watches the kinds of network the device uses (Wi-Fi, cellular, ethernet, VPN, other), as the OS +reports them. A socket and a peer connection are bound to the network they were opened on, so a change means +the session will not survive; reacting at once avoids waiting for a pong timeout or an ICE failure. + +- Watching starts at `connect()` and stops when the client is disconnected. The first value read is the + baseline; a report equal to the last one is ignored. **Online** means at least one network is reported; + **offline** means none. +- While joined (status `joined`) with reconnection enabled, every change, to offline included, is a retryable + loss. With reconnection disabled, changes are ignored: the session ends only when its socket or peer + connection does. +- Before the first `Joined` of a `connect()` call, changes are ignored (the attempt fails or succeeds on its + own). +- While reconnecting: going offline is recorded, and attempt timers that fire meanwhile wait (§6 table). A + change to online (back online, or a different network) abandons the current attempt and starts a new one at + once with a fresh attempt budget: the old attempt was opened on a network that is gone. +- When the OS cannot be asked, the client assumes it is always online and never reports a change (logged). + ## 7. Media event processing - Server media events are processed **strictly one at a time, in arrival order**. Handling of event N+1 starts @@ -284,7 +307,9 @@ Rules: candidate (end of gathering) is not sent. Remote candidates are added with `addIceCandidate`; failures are logged. - Connection state `failed` or ICE connection state `failed` emits `ConnectionError(message)` and is a - retryable loss ([§6](#6-reconnection)). + retryable loss ([§6](#6-reconnection)). So does ICE connection state `disconnected` that has not turned + `connected` or `completed` within 3 s: the OS takes much longer to report `failed`, while a short + `disconnected` recovers on its own. - A new session always creates a new peer connection; the old one is closed. ## 9. Receive transceiver sync @@ -611,6 +636,9 @@ through `dart:developer`'s `log` only when `debug` is true. The SDK never calls | D20 | a failed `replaceTrack` is swallowed; the call succeeds | rolled back, then the call fails with the error (§11.3) | the app learns the media was not replaced | | D21 | local track ids of the lost session silently disappear on reconnect | `LocalTrackRemoved` for every old id; restored tracks get `LocalTrackAdded` with new ids (§6) | apps can drop stale ids | | D22 | remote peers and tracks of the lost session are kept and re-announced with `peerJoined` / `trackAdded` after reconnecting | `TrackRemoved` and `PeerLeft` / `ComponentRemoved` for all of them before the new `Joined`, whose snapshot is the source of truth (§6) | no stale or duplicate remote state | +| D23 | a network switch is noticed only when the socket or ICE fails; attempts are used up while the device is offline | a network change is a retryable loss; attempts wait while offline and restart with a fresh budget on a new network (§6.1) | fast recovery on mobile | +| D24 | ICE `disconnected` only renegotiates, however long it lasts | `disconnected` for 3 s is a retryable loss (§8.4) | do not wait for a late `failed` | +| D25 | opening the socket has no timeout | 10 s (§2.1) | an attempt on a dead network does not hang | ## 23. Golden fixtures diff --git a/example/pubspec.lock b/example/pubspec.lock index bdc7460..7fd5081 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -57,6 +57,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: transitive + description: + name: connectivity_plus + sha256: d7749fd36d8b404a4935612b94eca7c3ce3705377ab678e24ff2a4301436f741 + url: "https://pub.dev" + source: hosted + version: "7.3.2" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" + url: "https://pub.dev" + source: hosted + version: "2.1.0" crypto: dependency: transitive description: @@ -73,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.8.2" + dbus: + dependency: transitive + description: + name: dbus + sha256: f7db8504e715b835f5ebe5fe74604ccd961d504c73389a5d8e81bafc2e2f3612 + url: "https://pub.dev" + source: hosted + version: "0.8.0" fake_async: dependency: transitive description: @@ -135,6 +159,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_webrtc: dependency: "direct main" description: @@ -273,6 +302,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.3" + nm: + dependency: transitive + description: + name: nm + sha256: "2ae480aa3379716b8bb58dfebb168e2919394c92b629efaaa0baaa5d140786e6" + url: "https://pub.dev" + source: hosted + version: "0.6.0" objective_c: dependency: transitive description: @@ -345,6 +382,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -526,6 +571,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" yaml: dependency: transitive description: diff --git a/lib/src/connectivity_network_monitor.dart b/lib/src/connectivity_network_monitor.dart new file mode 100644 index 0000000..2adffd6 --- /dev/null +++ b/lib/src/connectivity_network_monitor.dart @@ -0,0 +1,21 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:fishjam_client/src/signaling/network_monitor.dart'; + +/// [NetworkMonitor] backed by `connectivity_plus`. +final class ConnectivityNetworkMonitor implements NetworkMonitor { + @override + Future> current() async => + _kinds(await Connectivity().checkConnectivity()); + + @override + Stream> get changes => + Connectivity().onConnectivityChanged.map(_kinds); + + static Set _kinds(List results) => { + for (final result in results) + if (result == ConnectivityResult.mobile) + 'cellular' + else if (result != ConnectivityResult.none) + result.name, + }; +} diff --git a/lib/src/engine/rtc_engine.dart b/lib/src/engine/rtc_engine.dart index 6a67b00..c9f4f4f 100644 --- a/lib/src/engine/rtc_engine.dart +++ b/lib/src/engine/rtc_engine.dart @@ -45,6 +45,7 @@ class RtcEngine { required this.localTracks, required this.remote, this.nativeCallTimeout = const Duration(seconds: 10), + this.iceDisconnectedTimeout = const Duration(seconds: 3), this.onServerError, this.onLocalPeerRemoved, this.onConnectionFailed, @@ -66,6 +67,9 @@ class RtcEngine { /// `setLocalDescription`, `setRemoteDescription`). final Duration nativeCallTimeout; + /// How long ICE may stay `disconnected` before it counts as a failure. + final Duration iceDisconnectedTimeout; + final void Function(String message)? onServerError; final void Function()? onLocalPeerRemoved; @@ -96,6 +100,7 @@ class RtcEngine { final _ownedStreams = {}; bool _failed = false; bool _closed = false; + Timer? _iceDisconnectedTimer; void connect(Object? peerMetadata) { localTracks.peerMetadata = peerMetadata; @@ -316,6 +321,7 @@ class RtcEngine { Future close() async { if (_closed) return; _closed = true; + _iceDisconnectedTimer?.cancel(); _commands.failAll(); _negotiation.reset(); if (!_joined.isCompleted) _joined.completeError(StateError('disconnected')); @@ -639,6 +645,18 @@ class RtcEngine { _perform(_negotiation.handle(NegotiationInput.iceDisconnected)); }), ); + // The OS reports `failed` only after tens of seconds; a network + // switch should not keep the call frozen that long. + _iceDisconnectedTimer ??= Timer( + iceDisconnectedTimeout, + () => _connectionFailed( + 'ICE connection disconnected for ' + '${iceDisconnectedTimeout.inMilliseconds} ms', + ), + ); + case IceConnectionState.connected || IceConnectionState.completed: + _iceDisconnectedTimer?.cancel(); + _iceDisconnectedTimer = null; case IceConnectionState.failed: _connectionFailed('ICE connection failed'); case _: diff --git a/lib/src/fishjam_client.dart b/lib/src/fishjam_client.dart index 0c2c82f..88adadd 100644 --- a/lib/src/fishjam_client.dart +++ b/lib/src/fishjam_client.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:typed_data'; +import 'package:fishjam_client/src/connectivity_network_monitor.dart'; import 'package:fishjam_client/src/data/data_channels.dart'; import 'package:fishjam_client/src/engine/rtc_engine.dart'; import 'package:fishjam_client/src/logger.dart'; @@ -15,6 +16,7 @@ import 'package:fishjam_client/src/rtc/flutter_webrtc_adapter.dart'; import 'package:fishjam_client/src/rtc/rtc_adapter.dart'; import 'package:fishjam_client/src/signaling/auth.dart'; import 'package:fishjam_client/src/signaling/message_queue.dart'; +import 'package:fishjam_client/src/signaling/network_monitor.dart'; import 'package:fishjam_client/src/signaling/reconnect.dart'; import 'package:fishjam_client/src/signaling/signaling_session.dart'; import 'package:fishjam_client/src/signaling/transport.dart'; @@ -51,19 +53,32 @@ class FishjamClient { FishjamClient({ bool debug = false, ReconnectConfig reconnect = const ReconnectConfig(), - }) : this.internal(debug: debug, reconnect: reconnect); - - /// A client on a custom WebRTC adapter and socket transport (tests only; - /// they default to `flutter_webrtc` and `dart:io` WebSockets). + }) : this.internal( + debug: debug, + reconnect: reconnect, + networkMonitor: ConnectivityNetworkMonitor(), + ); + + /// A client on a custom WebRTC adapter, socket transport and network + /// monitor (tests only; they default to `flutter_webrtc`, `dart:io` + /// WebSockets and no network watching). @internal FishjamClient.internal({ this.debug = false, this.reconnect = const ReconnectConfig(), RtcAdapter? rtcAdapter, this._transportFactory, + NetworkMonitor? networkMonitor, }) : _adapter = rtcAdapter ?? const FlutterWebrtcAdapter(), _logger = FishjamLogger(enabled: debug) { _reconnect = ReconnectManager(config: reconnect, host: _Host(this)); + if (networkMonitor != null) { + _network = NetworkWatcher( + monitor: networkMonitor, + logger: _logger, + onChanged: _onNetworkChanged, + ); + } _dataChannels = DataChannelManager( logger: _logger, negotiate: (apply) => _requireAvailable().engine.negotiate(apply), @@ -93,6 +108,7 @@ class FishjamClient { late final ReconnectManager _reconnect; late final DataChannelManager _dataChannels; late final StatsReporter _stats; + NetworkWatcher? _network; ConnectionStatus _status = ConnectionStatus.idle; @@ -141,6 +157,7 @@ class FishjamClient { _reconnect.reset(); _staleRemote = null; _status = ConnectionStatus.connecting; + unawaited(_network?.start()); final connecting = _connecting = Completer(); final session = _newSession(); try { @@ -415,6 +432,19 @@ class FishjamClient { }); } + /// A network change while joined is a loss: the socket and the peer + /// connection are bound to the network that went away. + void _onNetworkChanged({required bool online}) { + _logger.debug('Network changed (online: $online)'); + if (!reconnect.isEnabled) return; + if (_status == ConnectionStatus.joined) { + unawaited( + _onRetryableLoss(const RetryableClose(reason: 'network changed')), + ); + } + _reconnect.onNetworkChanged(online: online); + } + Future _onRetryableLoss(RetryableClose reason) async { final session = _session!; final snapshot = ReconnectSnapshot( @@ -458,6 +488,7 @@ class FishjamClient { _session = null; _staleRemote = null; _status = ConnectionStatus.disconnected; + unawaited(_network?.stop()); await _teardown(session, sendDisconnect: sendDisconnect); if (error != null) _emit(error); _emit(disconnected); @@ -473,16 +504,23 @@ class FishjamClient { } /// In this order: data channels, `disconnect` (when leaving), peer - /// connection and local streams, socket. + /// connection and local streams, socket. Without [waitForSocket] the + /// socket close is started but not awaited. Future _teardown( _Session session, { required bool sendDisconnect, + bool waitForSocket = true, }) async { _stats.stop(); await _dataChannels.close(); if (sendDisconnect) session.engine.sendDisconnect(); await session.engine.close(); - await session.signaling.close(); + final closing = session.signaling.close(); + if (waitForSocket) { + await closing; + } else { + unawaited(closing); + } if (identical(_socket, session.signaling)) _socket = null; } @@ -597,7 +635,10 @@ class _Host implements ReconnectHost { } } } - await _client._teardown(old, sendDisconnect: false); + // A socket on a network that went away can take seconds to close + // (the close handshake never completes); the new session does not + // need it gone. + await _client._teardown(old, sendDisconnect: false, waitForSocket: false); } if (!identical(_client._session, session)) return; try { diff --git a/lib/src/signaling/network_monitor.dart b/lib/src/signaling/network_monitor.dart new file mode 100644 index 0000000..d459dcd --- /dev/null +++ b/lib/src/signaling/network_monitor.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:fishjam_client/src/logger.dart'; + +/// The device's networks as the OS reports them. Signaling depends only on +/// this seam; tests use `test/fakes/fake_network.dart`. +abstract interface class NetworkMonitor { + /// The kinds of network in use (e.g. `wifi`, `cellular`); empty when + /// offline. Throws when the OS cannot be asked. + Future> current(); + + /// Every report the OS makes; a report may repeat the previous one. + Stream> get changes; +} + +/// Turns [NetworkMonitor] reports into changes (SPEC §6.1): the first value +/// is the baseline and a report equal to the previous one is ignored. When +/// the OS cannot be asked, it logs a warning and reports nothing. +class NetworkWatcher { + NetworkWatcher({ + required this._monitor, + required this._logger, + required this._onChanged, + }); + + final NetworkMonitor _monitor; + final FishjamLogger _logger; + final void Function({required bool online}) _onChanged; + + StreamSubscription>? _subscription; + Set? _last; + + /// Starts watching with a new baseline (stops a previous watch first). + Future start() async { + await stop(); + try { + _subscription = _monitor.changes.listen( + _onReport, + onError: (Object e) { + _logger.warn('Network reports failed; stopped watching them', e); + unawaited(stop()); + }, + ); + final networks = await _monitor.current(); + if (_subscription != null) _last ??= networks; + } on Object catch (e) { + _logger.warn('Cannot read the network state; not watching it', e); + await stop(); + } + } + + Future stop() async { + final cancelled = _subscription?.cancel(); + _subscription = null; + _last = null; + await cancelled; + } + + void _onReport(Set networks) { + final last = _last; + _last = networks; + if (last == null || const SetEquality().equals(last, networks)) { + return; + } + _onChanged(online: networks.isNotEmpty); + } +} diff --git a/lib/src/signaling/reconnect.dart b/lib/src/signaling/reconnect.dart index 8ff18f0..108c992 100644 --- a/lib/src/signaling/reconnect.dart +++ b/lib/src/signaling/reconnect.dart @@ -59,6 +59,8 @@ abstract interface class ReconnectHost { /// - The caller reports every loss of the current session — including a new /// session that fails to open — through [onRetryableLoss] or /// [onTerminalClose], and every `connected` through [onSessionJoined]. +/// - The caller reports every network change through [onNetworkChanged]; +/// while joined it also reports the change as a retryable loss. /// - The manager emits only `ReconnectionStarted`, `ReconnectionFailed`, /// `Reconnected` and, on the first `connected` of a reconnection, /// `LocalTrackRemoved` for every snapshotted track (restored tracks get @@ -85,6 +87,11 @@ class ReconnectManager { /// Number of attempts started in the current reconnection. int _attempt = 0; + bool _online = true; + + /// An attempt came due while offline; it starts when the network is back. + bool _waitingForNetwork = false; + /// Whether `LocalTrackRemoved` was emitted for the snapshot's tracks in /// the current reconnection (once, on the first `connected`). bool _removalsReported = false; @@ -117,7 +124,7 @@ class ReconnectManager { _scheduleNextAttempt(); return true; case ReconnectState.reconnecting: - if (_timer != null) return true; + if (_timer != null || _waitingForNetwork) return true; _generation++; if (_attempt < _config.maxAttempts) { _scheduleNextAttempt(); @@ -172,6 +179,18 @@ class ReconnectManager { _host.emit(const Reconnected()); } + /// The device went offline, came back or moved to another network. While + /// reconnecting, a change to online abandons the current attempt (it was + /// opened on a network that is gone) and starts one at once with a fresh + /// budget; attempts that come due while offline wait, uncounted. + void onNetworkChanged({required bool online}) { + _online = online; + if (!online || _state != ReconnectState.reconnecting) return; + _cancelTimer(); + _waitingForNetwork = false; + _startAttempt(1); + } + /// Cancels everything and returns to [ReconnectState.idle] (on `leave()` /// and on a new `connect()`); clears the queue. void reset() => _finish(ReconnectState.idle); @@ -180,20 +199,28 @@ class ReconnectManager { final attempt = _attempt + 1; _timer = Timer(_config.delayBeforeAttempt(attempt), () { _timer = null; - _attempt = attempt; - final generation = ++_generation; - unawaited( - _host - .startSession(_snapshot!.peerMetadata) - .then( - (_) {}, - onError: (Object error) => - _onStartFailed(generation, RetryableClose(error: error)), - ), - ); + if (_online) { + _startAttempt(attempt); + } else { + _waitingForNetwork = true; + } }); } + void _startAttempt(int attempt) { + _attempt = attempt; + final generation = ++_generation; + unawaited( + _host + .startSession(_snapshot!.peerMetadata) + .then( + (_) {}, + onError: (Object error) => + _onStartFailed(generation, RetryableClose(error: error)), + ), + ); + } + /// [ReconnectHost.startSession] threw. Unless the attempt was already /// superseded (its loss reported, a terminal close, `reset`), this is the /// attempt's retryable loss. @@ -212,6 +239,7 @@ class ReconnectManager { _state = state; _snapshot = null; _attempt = 0; + _waitingForNetwork = false; if (release) { _host.releaseQueue(); } else { diff --git a/lib/src/signaling/transport.dart b/lib/src/signaling/transport.dart index 7ef59ae..83c786e 100644 --- a/lib/src/signaling/transport.dart +++ b/lib/src/signaling/transport.dart @@ -77,13 +77,17 @@ typedef SignalingTransportFactory = SignalingTransport Function(); final class WebSocketTransport implements SignalingTransport { /// Ignored text frames are logged at debug level with [logger]. WebSocketTransport({ - this.pingInterval = const Duration(seconds: 15), + this.pingInterval = const Duration(seconds: 5), + this.connectTimeout = const Duration(seconds: 10), FishjamLogger? logger, }) : _logger = logger ?? FishjamLogger.disabled(); /// Interval between client pings, and the pong timeout. final Duration pingInterval; + /// Limit for opening the socket: connection, TLS and upgrade together. + final Duration connectTimeout; + final FishjamLogger _logger; WebSocket? _socket; @@ -102,10 +106,18 @@ final class WebSocketTransport implements SignalingTransport { @override Future connect(Uri url) async { if (_socket != null) throw StateError('A transport can be used once'); + // Without a limit, a connect started on a network that went away hangs + // until the OS gives up, which can take minutes. + final client = HttpClient()..connectionTimeout = connectTimeout; + final connecting = WebSocket.connect(url.toString(), customClient: client); try { - _socket = await WebSocket.connect(url.toString()) + _socket = await connecting.timeout(connectTimeout) ..pingInterval = pingInterval; } on Object catch (e) { + if (e is TimeoutException) { + client.close(force: true); + connecting.ignore(); + } _finish(TransportClosed(error: e)); throw TransportException('Cannot connect to $url', e); } diff --git a/pubspec.lock b/pubspec.lock index 8c1b1ef..3d2f08e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,6 +73,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: d7749fd36d8b404a4935612b94eca7c3ce3705377ab678e24ff2a4301436f741 + url: "https://pub.dev" + source: hosted + version: "7.3.2" + connectivity_plus_platform_interface: + dependency: "direct dev" + description: + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" + url: "https://pub.dev" + source: hosted + version: "2.1.0" convert: dependency: transitive description: @@ -105,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.8.2" + dbus: + dependency: transitive + description: + name: dbus + sha256: f7db8504e715b835f5ebe5fe74604ccd961d504c73389a5d8e81bafc2e2f3612 + url: "https://pub.dev" + source: hosted + version: "0.8.0" fake_async: dependency: "direct dev" description: @@ -147,6 +171,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_webrtc: dependency: "direct main" description: @@ -275,6 +304,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2ae480aa3379716b8bb58dfebb168e2919394c92b629efaaa0baaa5d140786e6" + url: "https://pub.dev" + source: hosted + version: "0.6.0" objective_c: dependency: transitive description: @@ -347,6 +384,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "2f1564aadb14215e478563b5406cb80729d022158c01dc50c96013c2f7eb4790" + url: "https://pub.dev" + source: hosted + version: "7.1.0" platform: dependency: transitive description: @@ -528,6 +573,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "0fe8cc98946def31cbcbc8734aa4067031e6f65ea3a78a508060bfed2200bfeb" + url: "https://pub.dev" + source: hosted + version: "7.1.0" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 542c2b0..361bcb9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,6 +13,7 @@ environment: dependencies: collection: ^1.19.0 + connectivity_plus: ^7.3.2 flutter: sdk: flutter flutter_webrtc: ^1.6.2 @@ -22,6 +23,7 @@ dependencies: uuid: ^4.6.0 dev_dependencies: + connectivity_plus_platform_interface: ^2.1.0 fake_async: ^1.3.3 flutter_test: sdk: flutter diff --git a/test/client/client_harness.dart b/test/client/client_harness.dart index e0de3c8..6dc65a1 100644 --- a/test/client/client_harness.dart +++ b/test/client/client_harness.dart @@ -11,6 +11,7 @@ import 'package:fishjam_client/src/proto/fishjam/media_events/server/server.pb.d as server; import 'package:fishjam_client/src/proto/fishjam/peer_notifications.pb.dart'; +import '../fakes/fake_network.dart'; import '../fakes/fake_rtc.dart'; import '../fakes/fake_transport.dart'; import '../public/native_fakes.dart'; @@ -28,6 +29,7 @@ class ClientHarness { reconnect: reconnect, rtcAdapter: rtc, transportFactory: transports.call, + networkMonitor: network, ); _subscription = client.events.listen(events.add); } @@ -35,6 +37,9 @@ class ClientHarness { /// Transports created by the client. final transports = FakeTransportFactory(); + /// The device's networks, as the OS reports them. + final network = FakeNetworkMonitor(); + /// The WebRTC fake. final FakeRtcAdapter rtc; diff --git a/test/client/connectivity_network_monitor_test.dart b/test/client/connectivity_network_monitor_test.dart new file mode 100644 index 0000000..eba17c4 --- /dev/null +++ b/test/client/connectivity_network_monitor_test.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +import 'package:connectivity_plus_platform_interface/connectivity_plus_platform_interface.dart'; +import 'package:fishjam_client/src/connectivity_network_monitor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakePlatform extends ConnectivityPlatform { + List current = [ConnectivityResult.wifi]; + final reports = StreamController>.broadcast(); + + @override + Future> checkConnectivity() async => current; + + @override + Stream> get onConnectivityChanged => reports.stream; +} + +void main() { + late _FakePlatform platform; + + setUp(() => ConnectivityPlatform.instance = platform = _FakePlatform()); + + test('reports the kinds of network in use', () async { + platform.current = [ConnectivityResult.mobile, ConnectivityResult.vpn]; + + expect(await ConnectivityNetworkMonitor().current(), {'cellular', 'vpn'}); + }); + + test('none is reported as no network', () async { + final reports = >[]; + final subscription = ConnectivityNetworkMonitor().changes.listen( + reports.add, + ); + addTearDown(subscription.cancel); + + platform.reports + ..add([ConnectivityResult.none]) + ..add([ + ConnectivityResult.wifi, + ConnectivityResult.ethernet, + ConnectivityResult.bluetooth, + ConnectivityResult.satellite, + ConnectivityResult.other, + ]); + await Future.delayed(Duration.zero); + + expect(reports, [ + {}, + {'wifi', 'ethernet', 'bluetooth', 'satellite', 'other'}, + ]); + }); +} diff --git a/test/client/fishjam_client_test.dart b/test/client/fishjam_client_test.dart index 942e898..013510b 100644 --- a/test/client/fishjam_client_test.dart +++ b/test/client/fishjam_client_test.dart @@ -710,6 +710,91 @@ void main() { }); }); + group('network changes (SPEC §6.1)', () { + const slow = ReconnectConfig( + initialDelay: Duration(seconds: 5), + delay: Duration(seconds: 5), + ); + + test('a change while joined replaces the session at once', () async { + h = ClientHarness(reconnect: slow); + await h.join(); + final first = h.transport; + h.network.report({'cellular'}); + await settle(); + expect(h.eventsOf(), hasLength(1)); + expect(h.transports.created, hasLength(2)); + expect(first.closedByClient, isTrue); + await h.authenticate(); + await h.connectedEvent(); + expect(h.eventsOf(), hasLength(1)); + expect(h.eventsOf(), isEmpty); + }); + + test('the new session does not wait for the old socket to close', () async { + h = ClientHarness(reconnect: slow); + await h.join(); + h.transport.closeGate = Completer(); + h.network.report({'cellular'}); + await settle(); + expect(h.transports.created, hasLength(2)); + expect(h.transport.sentMessages.single.hasAuthRequest(), isTrue); + }); + + test('offline: the attempt waits for the network, then starts at ' + 'once', () async { + h = ClientHarness(reconnect: _fast); + await h.join(); + h.network.report({}); + await _wait(100); + expect(h.eventsOf(), hasLength(1)); + expect(h.transports.created, hasLength(1)); + h.network.report({'cellular'}); + await settle(); + expect(h.transports.created, hasLength(2)); + }); + + test('a report equal to the start value changes nothing', () async { + h = ClientHarness(reconnect: _fast); + await h.join(); + h.network.report({'wifi'}); + await _wait(40); + expect(h.eventsOf(), isEmpty); + }); + + test('a change before Joined is ignored', () async { + h = ClientHarness(reconnect: _fast); + final connected = h.client.connect(url: 'ws://fishjam.test', token: 't'); + await settle(); + h.network.report({'cellular'}); + await settle(); + await h.authenticate(); + await h.connectedEvent(); + await connected; + await _wait(40); + expect(h.eventsOf(), isEmpty); + expect(h.transports.created, hasLength(1)); + }); + + test('with reconnection disabled, a change is ignored', () async { + h = ClientHarness(reconnect: ReconnectConfig.disabled); + await h.join(); + h.network.report({'cellular'}); + await _wait(40); + expect(h.client.status, ConnectionStatus.joined); + expect(h.eventsOf(), isEmpty); + }); + + test('watching starts with connect() and stops on leave', () async { + h = ClientHarness(); + expect(h.network.isWatched, isFalse); + await h.join(); + expect(h.network.isWatched, isTrue); + await h.client.leave(); + expect(h.network.isWatched, isFalse); + }); + }); + group('statistics (SPEC §17)', () { test('reports every 10 s while joined and stops on teardown', () { fakeAsync((async) { diff --git a/test/engine/engine_harness.dart b/test/engine/engine_harness.dart index acc89bb..03b7ad7 100644 --- a/test/engine/engine_harness.dart +++ b/test/engine/engine_harness.dart @@ -31,6 +31,7 @@ class EngineHarness { FakeRtcAdapter? rtc, RemoteStoreFactory remoteStore = RemoteStore.new, Duration nativeCallTimeout = const Duration(seconds: 10), + Duration iceDisconnectedTimeout = const Duration(seconds: 3), }) : logger = logger ?? FishjamLogger.disabled(), rtc = rtc ?? FakeRtcAdapter() { localTracks = LocalTrackRegistry( @@ -46,6 +47,7 @@ class EngineHarness { engine = RtcEngine( adapter: this.rtc, nativeCallTimeout: nativeCallTimeout, + iceDisconnectedTimeout: iceDisconnectedTimeout, logger: this.logger, sendMediaEvent: sent.add, emit: events.add, diff --git a/test/engine/rtc_engine_test.dart b/test/engine/rtc_engine_test.dart index 950f3e8..221d262 100644 --- a/test/engine/rtc_engine_test.dart +++ b/test/engine/rtc_engine_test.dart @@ -420,6 +420,55 @@ void main() { expect(logs, contains(startsWith('warn'))); }); + group('ICE disconnected that lasts (SPEC §8.4)', () { + const timeout = Duration(milliseconds: 20); + + Future disconnected() async { + final h = EngineHarness(iceDisconnectedTimeout: timeout); + await h.join(); + await h.offerData(); + await h.answer(); + h.pc.setIceConnectionState(IceConnectionState.disconnected); + await settle(); + return h; + } + + Future outlast() async { + await Future.delayed(timeout * 2); + await settle(); + } + + test( + 'is one ConnectionError, reported as a connection failure', + () async { + final h = await disconnected(); + await outlast(); + expect(h.events.whereType(), hasLength(1)); + expect(h.connectionFailures, hasLength(1)); + }, + ); + + for (final recovered in [ + IceConnectionState.connected, + IceConnectionState.completed, + ]) { + test('is nothing when ICE turns ${recovered.name} in time', () async { + final h = await disconnected(); + h.pc.setIceConnectionState(recovered); + await outlast(); + expect(h.events.whereType(), isEmpty); + expect(h.connectionFailures, isEmpty); + }); + } + + test('is nothing once the engine was closed', () async { + final h = await disconnected(); + await h.engine.close(); + await outlast(); + expect(h.events.whereType(), isEmpty); + }); + }); + test('ICE or peer connection failure emits one ConnectionError and ' 'reports it', () async { final (h, _) = await _joined(); diff --git a/test/fakes/fake_network.dart b/test/fakes/fake_network.dart new file mode 100644 index 0000000..57bf2ac --- /dev/null +++ b/test/fakes/fake_network.dart @@ -0,0 +1,54 @@ +/// Scriptable [NetworkMonitor] for unit tests. +library; + +import 'dart:async'; + +import 'package:fishjam_client/src/signaling/network_monitor.dart'; + +/// A fake OS network report: the test sets the current networks and pushes +/// reports. +class FakeNetworkMonitor implements NetworkMonitor { + /// Starts with [networks] in use. + FakeNetworkMonitor({Set networks = const {'wifi'}}) + : _current = networks; + + Set _current; + final _reports = StreamController>.broadcast(); + + /// When set, [current] waits for it. + Completer? currentGate; + + /// When set, [current] throws it. + Error? currentError; + + /// When set, reading [changes] throws it. + Error? changesError; + + /// Whether anyone listens to [changes]. + bool get isWatched => _reports.hasListener; + + @override + Future> current() async { + final gate = currentGate; + if (gate != null) await gate.future; + final error = currentError; + if (error != null) throw error; + return _current; + } + + @override + Stream> get changes { + final error = changesError; + if (error != null) throw error; + return _reports.stream; + } + + /// The OS reports [networks] (also when they did not change). + void report(Set networks) { + _current = networks; + _reports.add(networks); + } + + /// The OS report stream fails. + void fail(Object error) => _reports.addError(error); +} diff --git a/test/fakes/fake_transport.dart b/test/fakes/fake_transport.dart index bc93a72..69d46b2 100644 --- a/test/fakes/fake_transport.dart +++ b/test/fakes/fake_transport.dart @@ -61,6 +61,10 @@ class FakeTransport implements SignalingTransport { /// Whether the client called [close]. bool closedByClient = false; + /// When set, [close] waits for it before closing, like a socket whose + /// close handshake cannot reach the server. + Completer? closeGate; + /// Code passed to [close]. int? clientCloseCode; @@ -108,6 +112,8 @@ class FakeTransport implements SignalingTransport { closedByClient = true; clientCloseCode = code; clientCloseReason = reason; + final gate = closeGate; + if (gate != null) await gate.future; _finish(TransportClosed(code: code, reason: reason)); } diff --git a/test/signaling/network_watcher_test.dart b/test/signaling/network_watcher_test.dart new file mode 100644 index 0000000..c52dda9 --- /dev/null +++ b/test/signaling/network_watcher_test.dart @@ -0,0 +1,107 @@ +import 'dart:async'; + +import 'package:fishjam_client/src/logger.dart'; +import 'package:fishjam_client/src/signaling/network_monitor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../fakes/fake_network.dart'; + +Future _settle() => Future.delayed(Duration.zero); + +void main() { + late FakeNetworkMonitor monitor; + late List changes; + late List logs; + late NetworkWatcher watcher; + + setUp(() { + monitor = FakeNetworkMonitor(); + changes = []; + logs = []; + watcher = NetworkWatcher( + monitor: monitor, + logger: FishjamLogger( + enabled: true, + sink: (level, message, {error, stackTrace}) => + logs.add('${level.name}: $message'), + ), + onChanged: ({required online}) => changes.add(online), + ); + }); + + test('the networks in use at start are the baseline, not a change', () async { + await watcher.start(); + monitor.report({'wifi'}); + await _settle(); + + expect(changes, isEmpty); + }); + + test('a different set of networks is a change; none means offline', () async { + await watcher.start(); + monitor + ..report({'cellular'}) + ..report({'cellular'}) + ..report({}) + ..report({'wifi', 'vpn'}); + await _settle(); + + expect(changes, [true, false, true]); + }); + + test( + 'a report that arrives before the start value is the baseline', + () async { + final gate = monitor.currentGate = Completer(); + final started = watcher.start(); + monitor.report({'cellular'}); + await _settle(); + gate.complete(); + await started; + monitor.report({'cellular'}); + await _settle(); + + expect(changes, isEmpty); + }, + ); + + test('when the OS cannot be asked, it warns and reports nothing', () async { + monitor.currentError = StateError('no plugin'); + await watcher.start(); + monitor.report({'cellular'}); + await _settle(); + + expect(changes, isEmpty); + expect(logs, [startsWith('warn')]); + }); + + test('when the report stream cannot be opened, it warns and reports ' + 'nothing', () async { + monitor.changesError = StateError('no binding'); + await watcher.start(); + + expect(logs, [startsWith('warn')]); + expect(changes, isEmpty); + }); + + test('a failing report stream warns and stops watching', () async { + await watcher.start(); + monitor.fail(StateError('channel gone')); + await _settle(); + + expect(logs, [startsWith('warn')]); + expect(monitor.isWatched, isFalse); + }); + + test('stop ends watching; start after stop takes a new baseline', () async { + await watcher.start(); + await watcher.stop(); + expect(monitor.isWatched, isFalse); + monitor.report({'cellular'}); + + await watcher.start(); + monitor.report({'cellular'}); + await _settle(); + expect(changes, isEmpty); + }); +} diff --git a/test/signaling/reconnect_test.dart b/test/signaling/reconnect_test.dart index fe9234d..ba259fe 100644 --- a/test/signaling/reconnect_test.dart +++ b/test/signaling/reconnect_test.dart @@ -535,4 +535,119 @@ void main() { expect(m.onRetryableLoss(_loss, snapshot()), isTrue); }); }); + + group('network changes (SPEC §6.1)', () { + test('an attempt that comes due while offline waits, uncounted, and ' + 'starts as soon as the network is back', () { + fakeAsync((async) { + final m = manager() + ..onRetryableLoss(_loss, snapshot()) + ..onNetworkChanged(online: false); + async.elapse(const Duration(seconds: 30)); + expect(host.count('startSession'), 0); + expect(m.isReconnecting, isTrue); + + m.onNetworkChanged(online: true); + async.flushMicrotasks(); + expect(host.count('startSession'), 1); + + // The wait used none of the 3 attempts. + expect(m.onRetryableLoss(_loss, snapshot()), isTrue); + async.elapse(const Duration(milliseconds: 800)); + expect(m.onRetryableLoss(_loss, snapshot()), isTrue); + async.elapse(const Duration(milliseconds: 1100)); + expect(host.count('startSession'), 3); + expect(m.onRetryableLoss(_loss, snapshot()), isFalse); + }); + }); + + test('a new network abandons the scheduled attempt and starts one at ' + 'once, with a fresh budget', () { + fakeAsync((async) { + final m = manager()..onRetryableLoss(_loss, snapshot()); + async.elapse(const Duration(milliseconds: 500)); + m.onRetryableLoss(_loss, snapshot()); + async.elapse(const Duration(milliseconds: 800)); + m.onRetryableLoss(_loss, snapshot()); + expect(host.count('startSession'), 2); + + m.onNetworkChanged(online: true); + async.flushMicrotasks(); + expect(host.count('startSession'), 3); + + // The scheduled attempt 3 does not run on top. + async.elapse(const Duration(seconds: 5)); + expect(host.count('startSession'), 3); + + // 2 more attempts follow the immediate one. + expect(m.onRetryableLoss(_loss, snapshot()), isTrue); + async.elapse(const Duration(milliseconds: 800)); + expect(m.onRetryableLoss(_loss, snapshot()), isTrue); + async.elapse(const Duration(milliseconds: 1100)); + expect(host.count('startSession'), 5); + expect(m.onRetryableLoss(_loss, snapshot()), isFalse); + }); + }); + + test('a new network abandons a running attempt; its late start error ' + 'is ignored', () { + fakeAsync((async) { + final gate = host.startGate = Completer(); + host.failingStarts = 1; + final m = manager()..onRetryableLoss(_loss, snapshot()); + async.elapse(const Duration(milliseconds: 500)); + expect(host.count('startSession'), 1); + + m.onNetworkChanged(online: true); + async.flushMicrotasks(); + expect(host.count('startSession'), 2); + + // The abandoned attempt throws; nothing new is scheduled. + gate.complete(); + async.elapse(const Duration(seconds: 5)); + expect(host.count('startSession'), 2); + expect(m.isReconnecting, isTrue); + }); + }); + + test('a network change during track restoration starts a new attempt ' + 'and cancels Reconnected', () { + fakeAsync((async) { + final pending = host.pendingRestores['t1'] = Completer(); + final m = manager(const ReconnectConfig(addTracksOnReconnect: true)) + ..onRetryableLoss( + _loss, + snapshot(tracks: [_track('t1', media: FakeMediaTrack.video())]), + ); + async.elapse(const Duration(milliseconds: 500)); + unawaited(m.onSessionJoined()); + async.flushMicrotasks(); + + m.onNetworkChanged(online: true); + async.flushMicrotasks(); + expect(host.count('startSession'), 2); + + pending.complete(); + async.flushMicrotasks(); + expect(host.events.whereType(), isEmpty); + }); + }); + + test('changes outside a reconnection start nothing', () { + fakeAsync((async) { + final m = manager() + ..onNetworkChanged(online: false) + ..onNetworkChanged(online: true); + async.elapse(const Duration(seconds: 5)); + expect(host.calls, isEmpty); + + m + ..onRetryableLoss(_loss, snapshot()) + ..reset() + ..onNetworkChanged(online: true); + async.elapse(const Duration(seconds: 5)); + expect(host.count('startSession'), 0); + }); + }); + }); } diff --git a/test/signaling/websocket_transport_test.dart b/test/signaling/websocket_transport_test.dart index eca201f..be36c63 100644 --- a/test/signaling/websocket_transport_test.dart +++ b/test/signaling/websocket_transport_test.dart @@ -105,6 +105,40 @@ void main() { expect((await transport.onClose).error, isNotNull); }); + test('pings every 5 s and gives up opening a socket after 10 s', () { + final transport = WebSocketTransport(); + + expect(transport.pingInterval, const Duration(seconds: 5)); + expect(transport.connectTimeout, const Duration(seconds: 10)); + }); + + test( + 'a server that never answers the upgrade times the connect out', + () async { + // Accepts the TCP connection and stays silent, like a peer on a network + // that went away mid-handshake. + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final accepted = []; + server.listen(accepted.add); + addTearDown(() async { + for (final socket in accepted) { + socket.destroy(); + } + await server.close(); + }); + final transport = WebSocketTransport( + connectTimeout: const Duration(milliseconds: 200), + ); + + await expectLater( + transport.connect(Uri.parse('ws://127.0.0.1:${server.port}/socket')), + throwsA(isA()), + ); + expect((await transport.onClose).error, isA()); + expect(transport.isOpen, isFalse); + }, + ); + test('a transport is single use', () async { final (server, url) = await _serve((socket) => socket.listen(null)); addTearDown(server.close); From c90ac43142578c09b4c76db9729fe841877c973e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Wed, 21 Oct 2026 02:03:07 +0200 Subject: [PATCH 02/29] =?UTF-8?q?Track=20receive=20transceivers=20instead?= =?UTF-8?q?=20of=20asking=20them=20for=20their=20direction=20(SPEC=20?= =?UTF-8?q?=C2=A79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Android flutter_webrtc disposes the native handles of transceivers that have no mid whenever it looks up a negotiated one, so asking each transceiver for its direction failed as soon as a negotiation mixed both kinds: adding a track after an earlier negotiation, and so every reconnection that restores two tracks, failed to create the offer. On iOS transceivers without a mid share one id. The engine now records the receive transceivers it adds by their receiver track id and stops the excess from one snapshot, those without a mid first. The fake models the Android handle disposal, which turned seven existing tests red before the fix. Claude-Session: https://claude.ai/code/session_01WZooUEkqeaFSacezszAELH --- SPEC.md | 10 ++- lib/src/engine/receive_transceivers.dart | 93 ++++++++++++++++-------- lib/src/engine/rtc_engine.dart | 5 +- test/fakes/fake_rtc.dart | 43 +++++++++-- test/fakes/fake_rtc_test.dart | 24 ++++++ 5 files changed, 134 insertions(+), 41 deletions(-) diff --git a/SPEC.md b/SPEC.md index f2beced..0ef283c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -315,11 +315,15 @@ Rules: ## 9. Receive transceiver sync On every `offerData{tracksTypes{audio, video}}`, the client makes the number of **live receive transceivers** -per kind equal the server's numbers. A transceiver is a live receive transceiver when its direction is -`recvonly` and its current direction is not `stopped`; its kind is its receiver track kind. +per kind equal the server's numbers. The live receive transceivers are the `recvonly` transceivers this +session added and has not stopped (the client always makes the offer, so it created every transceiver), +identified by their receiver track id. The SDK does not ask transceivers for their direction: on Android +`flutter_webrtc` disposes the handles of transceivers without a mid whenever it looks up a negotiated one, and +on iOS transceivers without a mid share one id. 1. Count live receive transceivers per kind. -2. If there are more than requested, stop the excess, choosing transceivers with no `mid` first. +2. If there are more than requested, stop the excess, choosing transceivers with no `mid` first. All + transceivers without a mid are stopped before any with a mid, from one `getTransceivers` snapshot. 3. If there are fewer, add `recvonly` transceivers: first all missing **video**, then all missing **audio**. 4. Negative deltas never throw. diff --git a/lib/src/engine/receive_transceivers.dart b/lib/src/engine/receive_transceivers.dart index 806340c..f3d1ee6 100644 --- a/lib/src/engine/receive_transceivers.dart +++ b/lib/src/engine/receive_transceivers.dart @@ -2,42 +2,75 @@ import 'package:fishjam_client/src/proto/fishjam/media_events/server/server.pb.d as server; import 'package:fishjam_client/src/rtc/rtc_adapter.dart'; -/// Makes the live receive transceivers per kind match the numbers the -/// server asked for in `offerData`, before the offer is created. -Future syncReceiveTransceivers( - RtcPeerConnection pc, - server.MediaEvent_OfferData_TrackTypes types, -) async { - final live = >{ +/// The receive-only transceivers of one peer connection (SPEC §9). +/// +/// This client always makes the offer, so every transceiver in the peer +/// connection was created here: the receive ones are recorded by their +/// receiver's track id, which stays the same in every snapshot, instead of +/// asking each transceiver for its direction. Per-transceiver native calls +/// are unreliable in the WebRTC plugin: on Android a call on a negotiated +/// transceiver disposes the handles of the ones without a mid, and on iOS all +/// transceivers without a mid share one id. +class ReceiveTransceivers { + final _live = >{ MediaKind.video: [], MediaKind.audio: [], }; - for (final t in await pc.getTransceivers()) { - if (await t.getDirection() == TransceiverDirection.recvonly && - await t.getCurrentDirection() != TransceiverDirection.stopped) { - live[t.receiver.kind]!.add(t); + + /// Makes the live receive transceivers per kind match the numbers the + /// server asked for in `offerData`, before the offer is created. + Future sync( + RtcPeerConnection pc, + server.MediaEvent_OfferData_TrackTypes types, + ) async { + final wanted = {MediaKind.video: types.video, MediaKind.audio: types.audio}; + if (_live.entries.any((e) => e.value.length > wanted[e.key]!)) { + await _stopExcess(pc, wanted); } - } - final wanted = {MediaKind.video: types.video, MediaKind.audio: types.audio}; - for (final kind in [MediaKind.video, MediaKind.audio]) { - final have = live[kind]!; - final excess = have.length - wanted[kind]!; - if (excess <= 0) continue; - // Transceivers without a mid were never negotiated: stop them first. - final candidates = [ - ...have.where((t) => t.mid == null), - ...have.where((t) => t.mid != null), - ]; - for (final t in candidates.take(excess)) { - await t.stop(); + for (final kind in [MediaKind.video, MediaKind.audio]) { + for (var i = _live[kind]!.length; i < wanted[kind]!; i++) { + final transceiver = await pc.addTransceiverOfKind( + kind, + const TransceiverInit(direction: TransceiverDirection.recvonly), + ); + final id = transceiver.receiver.track?.id; + if (id != null) _live[kind]!.add(id); + } } } - for (final kind in [MediaKind.video, MediaKind.audio]) { - for (var i = live[kind]!.length; i < wanted[kind]!; i++) { - await pc.addTransceiverOfKind( - kind, - const TransceiverInit(direction: TransceiverDirection.recvonly), - ); + + /// Transceivers without a mid were never negotiated: they are stopped + /// first. They are also stopped before any negotiated one, from one + /// snapshot, because stopping a negotiated one invalidates their handles. + Future _stopExcess( + RtcPeerConnection pc, + Map wanted, + ) async { + final snapshot = { + for (final t in await pc.getTransceivers()) ?t.receiver.track?.id: t, + }; + final victims = <(MediaKind, String, RtcRtpTransceiver)>[]; + for (final kind in [MediaKind.video, MediaKind.audio]) { + // A recorded transceiver missing from the snapshot is gone already. + final live = _live[kind]!..removeWhere((id) => !snapshot.containsKey(id)); + final excess = live.length - wanted[kind]!; + if (excess <= 0) continue; + final ordered = [ + ...live.where((id) => snapshot[id]!.mid == null), + ...live.where((id) => snapshot[id]!.mid != null), + ]; + victims.addAll([ + for (final id in ordered.take(excess)) (kind, id, snapshot[id]!), + ]); + } + victims.sort((a, b) { + final aNew = a.$3.mid == null ? 0 : 1; + final bNew = b.$3.mid == null ? 0 : 1; + return aNew - bNew; + }); + for (final (kind, id, transceiver) in victims) { + await transceiver.stop(); + _live[kind]!.remove(id); } } } diff --git a/lib/src/engine/rtc_engine.dart b/lib/src/engine/rtc_engine.dart index c9f4f4f..8fd66f9 100644 --- a/lib/src/engine/rtc_engine.dart +++ b/lib/src/engine/rtc_engine.dart @@ -89,6 +89,9 @@ class RtcEngine { String? _localPeerId; List _iceServers = const []; RtcPeerConnection? _pc; + + /// The receive transceivers of [_pc]; one engine has one peer connection. + final _receive = ReceiveTransceivers(); final _subscriptions = >[]; bool _hasRemoteDescription = false; final _pendingCandidates = []; @@ -482,7 +485,7 @@ class RtcEngine { } try { final pc = await _ensurePeerConnection(); - await syncReceiveTransceivers(pc, offerData.tracksTypes); + await _receive.sync(pc, offerData.tracksTypes); await _commands.drainBatch(); final offer = await pc.createOffer().timeout(nativeCallTimeout); // Native stacks emit candidates during setLocalDescription, before diff --git a/test/fakes/fake_rtc.dart b/test/fakes/fake_rtc.dart index fa087ae..b1bf96f 100644 --- a/test/fakes/fake_rtc.dart +++ b/test/fakes/fake_rtc.dart @@ -151,22 +151,45 @@ class FakeTransceiverState { /// A snapshot view of a transceiver: [mid] is the value at the time the view /// was obtained, like `flutter_webrtc`'s objects. +/// +/// Models how `flutter_webrtc` reaches native transceivers on Android: a view +/// without a mid holds a native handle that the next native +/// `getTransceivers()` disposes, and a call on a view with a mid makes that +/// call itself (it looks the transceiver up by mid). A call on a disposed +/// handle throws. class FakeRtpTransceiver implements RtcRtpTransceiver { - FakeRtpTransceiver._(this.state, this._pc) : mid = state.mid; + FakeRtpTransceiver._(this.state, this._pc) + : mid = state.mid, + _handleGeneration = _pc._handleGeneration; /// The live state behind this view. final FakeTransceiverState state; final FakePeerConnection _pc; + final int _handleGeneration; @override final String? mid; + /// Resolves the native transceiver, as `flutter_webrtc` does. + void _resolve() { + if (mid != null) { + _pc._handleGeneration++; + } else if (_handleGeneration != _pc._handleGeneration) { + throw StateError('RtpTransceiver has been disposed.'); + } + } + @override - Future getDirection() async => state.direction; + Future getDirection() async { + _resolve(); + return state.direction; + } @override - Future getCurrentDirection() async => - state.currentDirection; + Future getCurrentDirection() async { + _resolve(); + return state.currentDirection; + } @override RtcRtpSender get sender => state.sender; @@ -176,6 +199,7 @@ class FakeRtpTransceiver implements RtcRtpTransceiver { @override Future stop() async { + _resolve(); _pc._record('stopTransceiver', {'mid': state.mid, 'kind': state.kind.name}); state ..direction = TransceiverDirection.stopped @@ -339,6 +363,10 @@ class FakePeerConnection implements RtcPeerConnection { bool isClosed = false; final List _transceivers = []; + + /// Bumped by every native `getTransceivers()`; views without a mid from an + /// earlier generation are disposed (see [FakeRtpTransceiver]). + int _handleGeneration = 0; final Map _failures = {}; int _offers = 0; int _mids = 0; @@ -484,9 +512,10 @@ class FakePeerConnection implements RtcPeerConnection { } @override - Future> getTransceivers() async => [ - for (final t in _transceivers) FakeRtpTransceiver._(t, this), - ]; + Future> getTransceivers() async { + _handleGeneration++; + return [for (final t in _transceivers) FakeRtpTransceiver._(t, this)]; + } @override Future> getSenders() async => [ diff --git a/test/fakes/fake_rtc_test.dart b/test/fakes/fake_rtc_test.dart index 1722daf..b045045 100644 --- a/test/fakes/fake_rtc_test.dart +++ b/test/fakes/fake_rtc_test.dart @@ -13,6 +13,30 @@ void main() { }); group('transceivers', () { + test('model flutter_webrtc on Android: a lookup by mid disposes the ' + 'handles of transceivers without a mid', () async { + final pc = FakePeerConnection(const RtcConfiguration()); + await pc.addTransceiverOfKind( + MediaKind.audio, + const TransceiverInit(direction: TransceiverDirection.recvonly), + ); + await pc.setLocalDescription( + const SessionDescription(type: SdpType.offer, sdp: 'v=0'), + ); + await pc.addTransceiverOfKind( + MediaKind.video, + const TransceiverInit(direction: TransceiverDirection.recvonly), + ); + final [negotiated, fresh] = await pc.getTransceivers(); + expect(negotiated.mid, isNotNull); + expect(fresh.mid, isNull); + + await fresh.getDirection(); + await negotiated.getDirection(); + await expectLater(fresh.getDirection(), throwsStateError); + await expectLater(fresh.stop(), throwsStateError); + }); + test( 'mids are assigned by setLocalDescription and views are snapshots', () async { From e918afeb240dbd0ae4c2f066db0280dc7820c250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Wed, 21 Oct 2026 19:37:36 +0200 Subject: [PATCH 03/29] Settle network changes and pace reconnection by them A network report is acted on once the reports have been stable for 500 ms, so the brief "offline" platforms report while switching networks is not a loss of its own. Only losing a network (or reaching the same set through offline) is a change; gaining one keeps the connection's path. While reconnecting, an attempt that started after the change began is kept, a session that joins while offline does not complete the reconnection, the reconnection gives up after `offlineTimeout` (60 s) offline, and one reconnection makes at most 3 x maxAttempts attempts. Claude-Session: https://claude.ai/code/session_01Q5vZKmaVuaPpgoRaUvLQT8 --- README.md | 4 +- SPEC.md | 47 +++--- lib/src/fishjam_client.dart | 27 +++- lib/src/models/config.dart | 17 ++- lib/src/signaling/network_monitor.dart | 75 ++++++++-- lib/src/signaling/reconnect.dart | 132 +++++++++++++---- pubspec.lock | 2 +- pubspec.yaml | 1 + test/client/client_harness.dart | 7 +- test/client/fishjam_client_test.dart | 40 ++++++ test/models/models_test.dart | 20 +++ test/signaling/network_watcher_test.dart | 174 ++++++++++++++++------- test/signaling/reconnect_test.dart | 147 ++++++++++++++++--- 13 files changed, 556 insertions(+), 137 deletions(-) diff --git a/README.md b/README.md index 8f3369b..68b8228 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,9 @@ await client.dispose(); A lost connection is re-established automatically (`ReconnectConfig`): - A change of network (Wi-Fi to cellular, a different Wi-Fi, going offline) starts reconnecting at once instead - of waiting for the old connection to time out; while the device is offline, attempts wait for a network. + of waiting for the old connection to time out; while the device is offline, attempts wait for a network + (at most `ReconnectConfig.offlineTimeout`, 60 s by default). Gaining a network (cellular next to Wi-Fi) is + not a change. - From `ReconnectionStarted` until `Reconnected` the status is `reconnecting` and calls that need the session (track operations, metadata updates, `createDataChannels()`) fail at once with `StateError`. - Before the new `Joined`, the remote tracks and peers you knew are reported with `TrackRemoved` and diff --git a/SPEC.md b/SPEC.md index 0ef283c..5377e88 100644 --- a/SPEC.md +++ b/SPEC.md @@ -170,8 +170,8 @@ All client → server messages go through a single ordered queue in front of the ## 6. Reconnection Configuration (`ReconnectConfig`): `maxAttempts` (default 3), `initialDelay` (default 500 ms), `delay` -(default 500 ms), `addTracksOnReconnect` (default false). `ReconnectConfig.disabled` turns it off. Reconnect is -enabled with defaults unless configured otherwise. +(default 500 ms), `addTracksOnReconnect` (default false), `offlineTimeout` (default 60 s, [§6.1](#61-network-changes)). +`ReconnectConfig.disabled` turns it off. Reconnect is enabled with defaults unless configured otherwise. States: `idle`, `reconnecting`, `failed`. @@ -179,8 +179,7 @@ States: `idle`, `reconnecting`, `failed`. |---|---|---|---| | idle (joined) | retryable loss: retryable close, socket error, pong timeout, peer connection `failed`, ICE `failed`, ICE `disconnected` for 3 s ([§8.4](#84-peer-connection)), network change ([§6.1](#61-network-changes)) | snapshot local peer metadata (`peer` part) and local tracks; emit `ReconnectionStarted`; schedule attempt 1 | reconnecting | | reconnecting | attempt timer fires while online | tear down old session silently (no `Disconnected`) without waiting for its socket to finish closing; start a new session with the snapshot metadata | reconnecting | -| reconnecting | attempt timer fires while offline ([§6.1](#61-network-changes)) | nothing; the attempt waits for the network and is not counted | reconnecting | -| reconnecting | the network comes back or changes ([§6.1](#61-network-changes)) | abandon the waiting, scheduled or running attempt; reset the attempt counter; start an attempt at once | reconnecting | +| reconnecting | the network is lost or comes back or changes | see [§6.1](#61-network-changes) | reconnecting | | reconnecting | retryable loss in the new session before `connected` (including a new session that cannot be started at all) | schedule next attempt if attempts remain | reconnecting | | reconnecting | retryable loss, no attempts left | clear queue; emit `ReconnectionFailed`, then `Disconnected(DisconnectReason.reconnectionFailed)` | failed | | reconnecting | terminal close (auth/join/session ended/protocol) | cancel timer; clear queue; emit the matching error event and `Disconnected` | failed | @@ -205,20 +204,36 @@ States: `idle`, `reconnecting`, `failed`. ### 6.1 Network changes The client watches the kinds of network the device uses (Wi-Fi, cellular, ethernet, VPN, other), as the OS -reports them. A socket and a peer connection are bound to the network they were opened on, so a change means +reports them. A socket and a peer connection are bound to the network they were opened on, so losing it means the session will not survive; reacting at once avoids waiting for a pong timeout or an ICE failure. - Watching starts at `connect()` and stops when the client is disconnected. The first value read is the - baseline; a report equal to the last one is ignored. **Online** means at least one network is reported; - **offline** means none. -- While joined (status `joined`) with reconnection enabled, every change, to offline included, is a retryable - loss. With reconnection disabled, changes are ignored: the session ends only when its socket or peer - connection does. -- Before the first `Joined` of a `connect()` call, changes are ignored (the attempt fails or succeeds on its - own). -- While reconnecting: going offline is recorded, and attempt timers that fire meanwhile wait (§6 table). A - change to online (back online, or a different network) abandons the current attempt and starts a new one at - once with a fresh attempt budget: the old attempt was opened on a network that is gone. + baseline. **Online** means at least one network is reported; **offline** means none. +- A report is acted on once the reports have been stable for 500 ms (the **settle** time), so the brief + "offline" some platforms report while switching networks is not a loss of its own. Each change carries the + time its first report arrived (when the change **began**). +- Kinds of change, comparing the settled set with the previous one: + - **lost**: now offline; + - **available**: online after being offline; + - **changed**: a network went away (Wi-Fi → cellular, a VPN going down), or the same set was reached through + offline (another Wi-Fi network looks the same). + - Only gaining a network (cellular added next to Wi-Fi, a VPN coming up) and a repeat of the same set are no + change: the connection keeps its path. + +| Status | Change | Action | +|---|---|---| +| joined, reconnection enabled | changed | retryable loss `network changed` (§6), without waiting for the socket or ICE to fail | +| joined, reconnection enabled | lost | retryable loss `network lost` (§6) | +| joined, reconnection disabled | any | nothing: the session ends only when its socket or peer connection does | +| before the first `Joined` of a `connect()` | any | no loss (the attempt fails or succeeds on its own); whether the device is online is remembered | +| reconnecting | lost | a scheduled attempt is cancelled; no attempt starts while offline, and a loss while offline waits for the network instead of using up an attempt | +| reconnecting | available or changed | the attempt budget starts over (`maxAttempts` on the new network). An attempt in progress that started after the change began is kept: it already runs on the new network. Otherwise a scheduled attempt is cancelled, an attempt in progress is abandoned (its session torn down silently) and a new attempt starts at once | +| reconnecting | offline for `offlineTimeout` | as "no attempts left" (§6): `ReconnectionFailed`, then `Disconnected(reconnectionFailed)` | + +- A session of a reconnection that reaches `connected` while the device is offline does not complete it: no + `Reconnected`; the reconnection waits for the network and starts a new attempt then. +- One reconnection makes at most `3 × maxAttempts` attempts in total, so a network that keeps changing cannot + keep it going forever; when that cap is reached the reconnection fails as when no attempts are left. - When the OS cannot be asked, the client assumes it is always online and never reports a change (logged). ## 7. Media event processing @@ -640,7 +655,7 @@ through `dart:developer`'s `log` only when `debug` is true. The SDK never calls | D20 | a failed `replaceTrack` is swallowed; the call succeeds | rolled back, then the call fails with the error (§11.3) | the app learns the media was not replaced | | D21 | local track ids of the lost session silently disappear on reconnect | `LocalTrackRemoved` for every old id; restored tracks get `LocalTrackAdded` with new ids (§6) | apps can drop stale ids | | D22 | remote peers and tracks of the lost session are kept and re-announced with `peerJoined` / `trackAdded` after reconnecting | `TrackRemoved` and `PeerLeft` / `ComponentRemoved` for all of them before the new `Joined`, whose snapshot is the source of truth (§6) | no stale or duplicate remote state | -| D23 | a network switch is noticed only when the socket or ICE fails; attempts are used up while the device is offline | a network change is a retryable loss; attempts wait while offline and restart with a fresh budget on a new network (§6.1) | fast recovery on mobile | +| D23 | a network switch is noticed only when the socket or ICE fails; attempts are used up while the device is offline | losing a network is a retryable loss; attempts wait while offline (at most `offlineTimeout`) and restart with a fresh budget on a new network (§6.1) | fast recovery on mobile; a tunnel does not exhaust the attempts | | D24 | ICE `disconnected` only renegotiates, however long it lasts | `disconnected` for 3 s is a retryable loss (§8.4) | do not wait for a late `failed` | | D25 | opening the socket has no timeout | 10 s (§2.1) | an attempt on a dead network does not hang | diff --git a/lib/src/fishjam_client.dart b/lib/src/fishjam_client.dart index 88adadd..5fba2f9 100644 --- a/lib/src/fishjam_client.dart +++ b/lib/src/fishjam_client.dart @@ -69,6 +69,7 @@ class FishjamClient { RtcAdapter? rtcAdapter, this._transportFactory, NetworkMonitor? networkMonitor, + Duration networkSettle = const Duration(milliseconds: 500), }) : _adapter = rtcAdapter ?? const FlutterWebrtcAdapter(), _logger = FishjamLogger(enabled: debug) { _reconnect = ReconnectManager(config: reconnect, host: _Host(this)); @@ -77,6 +78,7 @@ class FishjamClient { monitor: networkMonitor, logger: _logger, onChanged: _onNetworkChanged, + settle: networkSettle, ); } _dataChannels = DataChannelManager( @@ -432,17 +434,28 @@ class FishjamClient { }); } - /// A network change while joined is a loss: the socket and the peer - /// connection are bound to the network that went away. - void _onNetworkChanged({required bool online}) { - _logger.debug('Network changed (online: $online)'); + /// Losing the network, or the one in use, while joined is a loss: the + /// socket and the peer connection are bound to the network that went away. + void _onNetworkChanged(NetworkChange change, DateTime began) { + _logger.debug('Network ${change.name}'); if (!reconnect.isEnabled) return; - if (_status == ConnectionStatus.joined) { + if (_status == ConnectionStatus.joined && + change != NetworkChange.available) { unawaited( - _onRetryableLoss(const RetryableClose(reason: 'network changed')), + _onRetryableLoss( + RetryableClose( + reason: change == NetworkChange.lost + ? 'network lost' + : 'network changed', + ), + ), ); } - _reconnect.onNetworkChanged(online: online); + if (change == NetworkChange.lost) { + _reconnect.onNetworkLost(); + } else { + _reconnect.onNetworkAvailable(since: began); + } } Future _onRetryableLoss(RetryableClose reason) async { diff --git a/lib/src/models/config.dart b/lib/src/models/config.dart index 6d10a5a..67ce192 100644 --- a/lib/src/models/config.dart +++ b/lib/src/models/config.dart @@ -9,6 +9,7 @@ final class ReconnectConfig { this.initialDelay = const Duration(milliseconds: 500), this.delay = const Duration(milliseconds: 500), this.addTracksOnReconnect = false, + this.offlineTimeout = const Duration(seconds: 60), }); /// Reconnection turned off. @@ -30,6 +31,10 @@ final class ReconnectConfig { /// Whether local tracks are published again after reconnecting. final bool addTracksOnReconnect; + /// How long a reconnection waits for the device to get a network before + /// it gives up. + final Duration offlineTimeout; + bool get isEnabled => maxAttempts > 0; /// Delay before attempt [attempt] (1-based): @@ -43,11 +48,17 @@ final class ReconnectConfig { other.maxAttempts == maxAttempts && other.initialDelay == initialDelay && other.delay == delay && - other.addTracksOnReconnect == addTracksOnReconnect; + other.addTracksOnReconnect == addTracksOnReconnect && + other.offlineTimeout == offlineTimeout; @override - int get hashCode => - Object.hash(maxAttempts, initialDelay, delay, addTracksOnReconnect); + int get hashCode => Object.hash( + maxAttempts, + initialDelay, + delay, + addTracksOnReconnect, + offlineTimeout, + ); } /// Connection status of the client. diff --git a/lib/src/signaling/network_monitor.dart b/lib/src/signaling/network_monitor.dart index d459dcd..c06fec8 100644 --- a/lib/src/signaling/network_monitor.dart +++ b/lib/src/signaling/network_monitor.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:collection/collection.dart'; +import 'package:clock/clock.dart'; import 'package:fishjam_client/src/logger.dart'; /// The device's networks as the OS reports them. Signaling depends only on @@ -14,23 +14,48 @@ abstract interface class NetworkMonitor { Stream> get changes; } -/// Turns [NetworkMonitor] reports into changes (SPEC §6.1): the first value -/// is the baseline and a report equal to the previous one is ignored. When -/// the OS cannot be asked, it logs a warning and reports nothing. +enum NetworkChange { + /// The device went offline. + lost, + + /// The device is online again after being offline. + available, + + /// A network went away, or the same networks were reached through offline. + changed, +} + +/// Turns [NetworkMonitor] reports into [NetworkChange]s (SPEC §6.1): the +/// first value is the baseline, and reports are acted on once they have been +/// stable for [settle]; each change carries the time its first report +/// arrived. When the OS cannot be asked, it logs a warning and reports +/// nothing. class NetworkWatcher { NetworkWatcher({ required this._monitor, required this._logger, required this._onChanged, + this.settle = const Duration(milliseconds: 500), }); final NetworkMonitor _monitor; final FishjamLogger _logger; - final void Function({required bool online}) _onChanged; + final void Function(NetworkChange change, DateTime began) _onChanged; + final Duration settle; StreamSubscription>? _subscription; + + /// The last settled networks; `null` before the baseline. Set? _last; + Timer? _settling; + + /// When the first report of the change still settling arrived. + DateTime? _began; + + /// Whether the change still settling went through offline. + bool _wentOffline = false; + /// Starts watching with a new baseline (stops a previous watch first). Future start() async { await stop(); @@ -54,15 +79,47 @@ class NetworkWatcher { final cancelled = _subscription?.cancel(); _subscription = null; _last = null; + _settling?.cancel(); + _settling = null; + _began = null; + _wentOffline = false; await cancelled; } void _onReport(Set networks) { - final last = _last; - _last = networks; - if (last == null || const SetEquality().equals(last, networks)) { + if (_last == null) { + _last = networks; return; } - _onChanged(online: networks.isNotEmpty); + // Platforms report a short "offline" while they switch the default + // network; acting on it would cost a reconnection of its own. + _began ??= clock.now(); + if (networks.isEmpty) _wentOffline = true; + _settling?.cancel(); + _settling = Timer(settle, () => _settled(networks)); + } + + void _settled(Set networks) { + final began = _began!; + final change = _classify(_last!, networks, throughOffline: _wentOffline); + _last = networks; + _settling = null; + _began = null; + _wentOffline = false; + if (change != null) _onChanged(change, began); + } + + static NetworkChange? _classify( + Set last, + Set now, { + required bool throughOffline, + }) { + if (now.isEmpty) return last.isEmpty ? null : NetworkChange.lost; + if (last.isEmpty) return NetworkChange.available; + // Another Wi-Fi network reports the same set as the one that was lost. + if (throughOffline) return NetworkChange.changed; + // Only losing a network can break the connection's path; gaining one + // (cellular next to Wi-Fi, a VPN coming up) leaves it in place. + return now.containsAll(last) ? null : NetworkChange.changed; } } diff --git a/lib/src/signaling/reconnect.dart b/lib/src/signaling/reconnect.dart index 108c992..50342cd 100644 --- a/lib/src/signaling/reconnect.dart +++ b/lib/src/signaling/reconnect.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:clock/clock.dart'; + import 'package:fishjam_client/src/models/config.dart'; import 'package:fishjam_client/src/models/errors.dart'; import 'package:fishjam_client/src/models/events.dart'; @@ -35,9 +37,11 @@ abstract interface class ReconnectHost { Future startSession(Object? peerMetadata); /// Tears the session down and emits `Disconnected(reason)`. Called only - /// when the manager gives up on its own: [startSession] threw on the last - /// attempt (`ReconnectionFailed` was emitted just before). Later reports - /// about the abandoned session must be ignored. + /// when the manager gives up on its own (`ReconnectionFailed` was emitted + /// just before): [startSession] threw on the last attempt, the device was + /// offline for `offlineTimeout`, or the network changed after the total + /// attempt cap was reached. Later reports about the abandoned session must + /// be ignored. void endSession(DisconnectReason reason); /// Publishes [track]'s media again; the new track gets a new id. @@ -59,8 +63,9 @@ abstract interface class ReconnectHost { /// - The caller reports every loss of the current session — including a new /// session that fails to open — through [onRetryableLoss] or /// [onTerminalClose], and every `connected` through [onSessionJoined]. -/// - The caller reports every network change through [onNetworkChanged]; -/// while joined it also reports the change as a retryable loss. +/// - The caller reports every network change through [onNetworkLost] or +/// [onNetworkAvailable]; while joined it also reports a lost or changed +/// network as a retryable loss. /// - The manager emits only `ReconnectionStarted`, `ReconnectionFailed`, /// `Reconnected` and, on the first `connected` of a reconnection, /// `LocalTrackRemoved` for every snapshotted track (restored tracks get @@ -69,8 +74,8 @@ abstract interface class ReconnectHost { /// `Disconnected(reconnectionFailed)` if [state] is /// [ReconnectState.failed], else `Disconnected(connectionLost)`; after a /// terminal close the caller emits the error event and `Disconnected`. -/// The one exception is [ReconnectHost.startSession] throwing on the last -/// attempt, where the manager calls [ReconnectHost.endSession]. +/// The exception is giving up on its own (see [ReconnectHost.endSession]), +/// where the manager calls [ReconnectHost.endSession]. /// - With `addTracksOnReconnect`, only tracks with a live media track are /// re-added. Muted tracks (no media track) and ended tracks are /// not; a failed re-add does not stop the others. @@ -84,13 +89,23 @@ class ReconnectManager { ReconnectSnapshot? _snapshot; Timer? _timer; - /// Number of attempts started in the current reconnection. + /// Number of attempts started on the current network in the current + /// reconnection (the budget of `maxAttempts`). int _attempt = 0; + /// Number of attempts started in the current reconnection on any network: + /// a network that keeps changing restarts the budget, so this caps it. + int _totalAttempts = 0; + + /// When the attempt in progress started; `null` when none is in progress. + DateTime? _attemptStartedAt; + + /// While offline no attempt starts, and a loss waits for the network + /// instead of using up the budget. bool _online = true; - /// An attempt came due while offline; it starts when the network is back. - bool _waitingForNetwork = false; + /// Runs while reconnecting offline; gives up at `offlineTimeout`. + Timer? _offlineTimer; /// Whether `LocalTrackRemoved` was emitted for the snapshot's tracks in /// the current reconnection (once, on the first `connected`). @@ -117,16 +132,23 @@ class ReconnectManager { _state = ReconnectState.reconnecting; _snapshot = snapshot; _attempt = 0; + _totalAttempts = 0; _removalsReported = false; _host ..holdQueue() ..emit(const ReconnectionStarted()); - _scheduleNextAttempt(); + _online ? _scheduleNextAttempt() : _waitForNetwork(); return true; case ReconnectState.reconnecting: - if (_timer != null || _waitingForNetwork) return true; + if (_timer != null) return true; _generation++; - if (_attempt < _config.maxAttempts) { + _attemptStartedAt = null; + if (!_online) { + _waitForNetwork(); + return true; + } + if (_attempt < _config.maxAttempts && + _totalAttempts < _maxTotalAttempts) { _scheduleNextAttempt(); return true; } @@ -156,6 +178,13 @@ class ReconnectManager { /// while restoring, `Reconnected` is not emitted for it. Future onSessionJoined() async { if (_state != ReconnectState.reconnecting) return; + if (!_online) { + // The session got through just before the network went away; it will + // not survive. + _attemptStartedAt = null; + _waitForNetwork(); + return; + } final generation = _generation; final tracks = _snapshot!.tracks; if (!_removalsReported) { @@ -179,36 +208,74 @@ class ReconnectManager { _host.emit(const Reconnected()); } - /// The device went offline, came back or moved to another network. While - /// reconnecting, a change to online abandons the current attempt (it was - /// opened on a network that is gone) and starts one at once with a fresh - /// budget; attempts that come due while offline wait, uncounted. - void onNetworkChanged({required bool online}) { - _online = online; - if (!online || _state != ReconnectState.reconnecting) return; + /// The device has no network. While reconnecting, a scheduled attempt is + /// cancelled and the reconnection waits, at most `offlineTimeout`, for + /// [onNetworkAvailable]. Outside a reconnection it is only remembered. + void onNetworkLost() { + _online = false; + if (_state != ReconnectState.reconnecting) return; + _cancelTimer(); + _waitForNetwork(); + } + + /// The device has a network again, or a different one; the change began + /// at [since]. While reconnecting, the attempt budget starts over (earlier + /// attempts failed on a network that is gone) and an attempt starts at + /// once, unless one that started after [since] is in progress: that one + /// already runs on the new network. + void onNetworkAvailable({required DateTime since}) { + _online = true; + _cancelOfflineTimer(); + if (_state != ReconnectState.reconnecting) return; + _attempt = 0; + final startedAt = _attemptStartedAt; + if (startedAt != null && startedAt.isAfter(since)) return; + if (_totalAttempts >= _maxTotalAttempts) { + // An attempt still in progress decides; with none, nothing would. + if (startedAt == null && _timer == null) _giveUp(); + return; + } _cancelTimer(); - _waitingForNetwork = false; _startAttempt(1); } /// Cancels everything and returns to [ReconnectState.idle] (on `leave()` - /// and on a new `connect()`); clears the queue. - void reset() => _finish(ReconnectState.idle); + /// and on a new `connect()`); clears the queue and forgets that the device + /// was offline. + void reset() { + _finish(ReconnectState.idle); + _online = true; + } + + int get _maxTotalAttempts => 3 * _config.maxAttempts; + + void _waitForNetwork() { + _offlineTimer ??= Timer(_config.offlineTimeout, () { + _offlineTimer = null; + _giveUp(); + }); + } + + void _giveUp() { + _finish(ReconnectState.failed); + _host + ..emit(const ReconnectionFailed()) + ..endSession(DisconnectReason.reconnectionFailed); + } void _scheduleNextAttempt() { final attempt = _attempt + 1; _timer = Timer(_config.delayBeforeAttempt(attempt), () { _timer = null; - if (_online) { - _startAttempt(attempt); - } else { - _waitingForNetwork = true; - } + _startAttempt(attempt); }); } + /// Starts [attempt], superseding any attempt still in progress. void _startAttempt(int attempt) { _attempt = attempt; + _totalAttempts++; + _attemptStartedAt = clock.now(); final generation = ++_generation; unawaited( _host @@ -235,11 +302,13 @@ class ReconnectManager { /// restores, and releases (success) or clears (otherwise) the queue. void _finish(ReconnectState state, {bool release = false}) { _cancelTimer(); + _cancelOfflineTimer(); _generation++; _state = state; _snapshot = null; _attempt = 0; - _waitingForNetwork = false; + _totalAttempts = 0; + _attemptStartedAt = null; if (release) { _host.releaseQueue(); } else { @@ -251,4 +320,9 @@ class ReconnectManager { _timer?.cancel(); _timer = null; } + + void _cancelOfflineTimer() { + _offlineTimer?.cancel(); + _offlineTimer = null; + } } diff --git a/pubspec.lock b/pubspec.lock index 3d2f08e..834cf2b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -50,7 +50,7 @@ packages: source: hosted version: "1.4.1" clock: - dependency: transitive + dependency: "direct main" description: name: clock sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e diff --git a/pubspec.yaml b/pubspec.yaml index 361bcb9..75b3086 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,7 @@ environment: flutter: ">=3.47.0" dependencies: + clock: ^1.1.3 collection: ^1.19.0 connectivity_plus: ^7.3.2 flutter: diff --git a/test/client/client_harness.dart b/test/client/client_harness.dart index 6dc65a1..5006d17 100644 --- a/test/client/client_harness.dart +++ b/test/client/client_harness.dart @@ -23,13 +23,16 @@ const localPeerId = 'local-peer'; /// One client plus its fakes. class ClientHarness { /// Creates the client; nothing is connected yet. - ClientHarness({ReconnectConfig reconnect = const ReconnectConfig()}) - : rtc = FakeRtcAdapter() { + ClientHarness({ + ReconnectConfig reconnect = const ReconnectConfig(), + Duration networkSettle = Duration.zero, + }) : rtc = FakeRtcAdapter() { client = FishjamClient.internal( reconnect: reconnect, rtcAdapter: rtc, transportFactory: transports.call, networkMonitor: network, + networkSettle: networkSettle, ); _subscription = client.events.listen(events.add); } diff --git a/test/client/fishjam_client_test.dart b/test/client/fishjam_client_test.dart index 013510b..63207b6 100644 --- a/test/client/fishjam_client_test.dart +++ b/test/client/fishjam_client_test.dart @@ -754,6 +754,46 @@ void main() { expect(h.transports.created, hasLength(2)); }); + test('gaining a network next to the current one changes nothing', () async { + h = ClientHarness(reconnect: _fast); + await h.join(); + h.network.report({'wifi', 'cellular'}); + await _wait(40); + expect(h.eventsOf(), isEmpty); + expect(h.transports.created, hasLength(1)); + }); + + test('a brief offline while switching networks is one change', () async { + h = ClientHarness( + reconnect: slow, + networkSettle: const Duration(milliseconds: 50), + ); + await h.join(); + h.network.report({}); + await _wait(10); + h.network.report({'cellular'}); + await _wait(100); + expect(h.eventsOf(), hasLength(1)); + expect(h.transports.created, hasLength(2)); + }); + + test('offline for offlineTimeout ends the reconnection', () async { + h = ClientHarness( + reconnect: const ReconnectConfig( + offlineTimeout: Duration(milliseconds: 50), + ), + ); + await h.join(); + h.network.report({}); + await _wait(100); + expect(h.eventsOf(), hasLength(1)); + expect( + h.eventsOf().single.reason, + DisconnectReason.reconnectionFailed, + ); + expect(h.client.status, ConnectionStatus.disconnected); + }); + test('a report equal to the start value changes nothing', () async { h = ClientHarness(reconnect: _fast); await h.join(); diff --git a/test/models/models_test.dart b/test/models/models_test.dart index 95c0940..3c5b74b 100644 --- a/test/models/models_test.dart +++ b/test/models/models_test.dart @@ -179,6 +179,26 @@ void main() { expect(ReconnectConfig.disabled.isEnabled, isFalse); }); + test( + 'waits at most 60 s for the network; the limit is part of equality', + () { + expect( + const ReconnectConfig().offlineTimeout, + const Duration(seconds: 60), + ); + const shorter = ReconnectConfig(offlineTimeout: Duration(seconds: 5)); + expect(shorter, isNot(const ReconnectConfig())); + expect( + shorter, + const ReconnectConfig(offlineTimeout: Duration(seconds: 5)), + ); + expect( + shorter.hashCode, + const ReconnectConfig(offlineTimeout: Duration(seconds: 5)).hashCode, + ); + }, + ); + test('delay of attempt n is initialDelay + (n - 1) * delay', () { const config = ReconnectConfig( initialDelay: Duration(milliseconds: 100), diff --git a/test/signaling/network_watcher_test.dart b/test/signaling/network_watcher_test.dart index c52dda9..0685517 100644 --- a/test/signaling/network_watcher_test.dart +++ b/test/signaling/network_watcher_test.dart @@ -1,16 +1,18 @@ import 'dart:async'; +import 'package:clock/clock.dart'; +import 'package:fake_async/fake_async.dart'; import 'package:fishjam_client/src/logger.dart'; import 'package:fishjam_client/src/signaling/network_monitor.dart'; import 'package:flutter_test/flutter_test.dart'; import '../fakes/fake_network.dart'; -Future _settle() => Future.delayed(Duration.zero); +const _settle = Duration(milliseconds: 500); void main() { late FakeNetworkMonitor monitor; - late List changes; + late List<(NetworkChange, DateTime)> changes; late List logs; late NetworkWatcher watcher; @@ -25,83 +27,151 @@ void main() { sink: (level, message, {error, stackTrace}) => logs.add('${level.name}: $message'), ), - onChanged: ({required online}) => changes.add(online), + onChanged: (change, began) => changes.add((change, began)), ); }); - test('the networks in use at start are the baseline, not a change', () async { - await watcher.start(); - monitor.report({'wifi'}); - await _settle(); + /// Starts the watcher inside [body]'s fake time. + void run(void Function(FakeAsync async) body) => fakeAsync((async) { + unawaited(watcher.start()); + async.flushMicrotasks(); + body(async); + }); + + List kinds() => [for (final (c, _) in changes) c]; + + test('the networks in use at start are the baseline, not a change', () { + run((async) { + monitor.report({'wifi'}); + async.elapse(_settle * 2); + + expect(changes, isEmpty); + }); + }); + + test('a change is reported once the reports have been stable for 500 ms, ' + 'with the time its first report arrived', () { + run((async) { + final began = clock.now(); + monitor.report({'cellular'}); + async.elapse(_settle - const Duration(milliseconds: 1)); + expect(changes, isEmpty); + + async.elapse(const Duration(milliseconds: 1)); + expect(changes, [(NetworkChange.changed, began)]); + }); + }); + + test('offline is lost; online after offline is available', () { + run((async) { + monitor.report({}); + async.elapse(_settle); + monitor.report({'cellular'}); + async.elapse(_settle); - expect(changes, isEmpty); + expect(kinds(), [NetworkChange.lost, NetworkChange.available]); + }); }); - test('a different set of networks is a change; none means offline', () async { - await watcher.start(); - monitor - ..report({'cellular'}) - ..report({'cellular'}) - ..report({}) - ..report({'wifi', 'vpn'}); - await _settle(); + test('reports within the settle time are one change that began with the ' + 'first of them', () { + run((async) { + final began = clock.now(); + monitor.report({}); + async.elapse(const Duration(milliseconds: 200)); + monitor.report({'cellular'}); + async.elapse(_settle); - expect(changes, [true, false, true]); + expect(changes, [(NetworkChange.changed, began)]); + }); }); - test( - 'a report that arrives before the start value is the baseline', - () async { + test('back to the same networks through offline is a change: another ' + 'Wi-Fi network looks the same', () { + run((async) { + monitor + ..report({}) + ..report({'wifi'}); + async.elapse(_settle); + + expect(kinds(), [NetworkChange.changed]); + }); + }); + + test('only gaining a network is no change; losing one later is', () { + run((async) { + monitor.report({'wifi', 'cellular'}); + async.elapse(_settle); + monitor.report({'wifi', 'cellular'}); + async.elapse(_settle); + expect(changes, isEmpty); + + monitor.report({'cellular'}); + async.elapse(_settle); + expect(kinds(), [NetworkChange.changed]); + }); + }); + + test('a report that arrives before the start value is the baseline', () { + fakeAsync((async) { final gate = monitor.currentGate = Completer(); - final started = watcher.start(); + unawaited(watcher.start()); + async.flushMicrotasks(); monitor.report({'cellular'}); - await _settle(); + async.flushMicrotasks(); gate.complete(); - await started; + async.flushMicrotasks(); monitor.report({'cellular'}); - await _settle(); + async.elapse(_settle); expect(changes, isEmpty); - }, - ); + }); + }); - test('when the OS cannot be asked, it warns and reports nothing', () async { + test('when the OS cannot be asked, it warns and reports nothing', () { monitor.currentError = StateError('no plugin'); - await watcher.start(); - monitor.report({'cellular'}); - await _settle(); + run((async) { + monitor.report({'cellular'}); + async.elapse(_settle); - expect(changes, isEmpty); - expect(logs, [startsWith('warn')]); + expect(changes, isEmpty); + expect(logs, [startsWith('warn')]); + }); }); test('when the report stream cannot be opened, it warns and reports ' - 'nothing', () async { + 'nothing', () { monitor.changesError = StateError('no binding'); - await watcher.start(); - - expect(logs, [startsWith('warn')]); - expect(changes, isEmpty); + run((async) { + expect(logs, [startsWith('warn')]); + expect(changes, isEmpty); + }); }); - test('a failing report stream warns and stops watching', () async { - await watcher.start(); - monitor.fail(StateError('channel gone')); - await _settle(); + test('a failing report stream warns and stops watching', () { + run((async) { + monitor.fail(StateError('channel gone')); + async.flushMicrotasks(); - expect(logs, [startsWith('warn')]); - expect(monitor.isWatched, isFalse); + expect(logs, [startsWith('warn')]); + expect(monitor.isWatched, isFalse); + }); }); - test('stop ends watching; start after stop takes a new baseline', () async { - await watcher.start(); - await watcher.stop(); - expect(monitor.isWatched, isFalse); - monitor.report({'cellular'}); + test('stop ends watching and drops a change still settling; start after ' + 'stop takes a new baseline', () { + run((async) { + monitor.report({'cellular'}); + unawaited(watcher.stop()); + async.elapse(_settle); + expect(monitor.isWatched, isFalse); + expect(changes, isEmpty); - await watcher.start(); - monitor.report({'cellular'}); - await _settle(); - expect(changes, isEmpty); + unawaited(watcher.start()); + async.flushMicrotasks(); + monitor.report({'cellular'}); + async.elapse(_settle); + expect(changes, isEmpty); + }); }); } diff --git a/test/signaling/reconnect_test.dart b/test/signaling/reconnect_test.dart index ba259fe..839a333 100644 --- a/test/signaling/reconnect_test.dart +++ b/test/signaling/reconnect_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:clock/clock.dart'; import 'package:fake_async/fake_async.dart'; import 'package:fishjam_client/src/models/config.dart'; import 'package:fishjam_client/src/models/errors.dart'; @@ -537,21 +538,40 @@ void main() { }); group('network changes (SPEC §6.1)', () { - test('an attempt that comes due while offline waits, uncounted, and ' - 'starts as soon as the network is back', () { + test('losing the network cancels the scheduled attempt; while offline ' + 'nothing starts and a loss waits instead of using an attempt', () { fakeAsync((async) { final m = manager() ..onRetryableLoss(_loss, snapshot()) - ..onNetworkChanged(online: false); + ..onNetworkLost(); async.elapse(const Duration(seconds: 30)); expect(host.count('startSession'), 0); + expect(m.onRetryableLoss(_loss, snapshot()), isTrue); + async.elapse(const Duration(seconds: 20)); + expect(host.count('startSession'), 0); expect(m.isReconnecting, isTrue); + }); + }); + + test('a loss while already offline waits for the network', () { + fakeAsync((async) { + final m = manager()..onNetworkLost(); + expect(m.onRetryableLoss(_loss, snapshot()), isTrue); + async.elapse(const Duration(seconds: 30)); + expect(host.count('startSession'), 0); + expect(host.count('emit:ReconnectionStarted'), 1); + }); + }); - m.onNetworkChanged(online: true); + test('the network back starts an attempt at once with a fresh budget', () { + fakeAsync((async) { + final m = manager() + ..onRetryableLoss(_loss, snapshot()) + ..onNetworkLost() + ..onNetworkAvailable(since: clock.now()); async.flushMicrotasks(); expect(host.count('startSession'), 1); - // The wait used none of the 3 attempts. expect(m.onRetryableLoss(_loss, snapshot()), isTrue); async.elapse(const Duration(milliseconds: 800)); expect(m.onRetryableLoss(_loss, snapshot()), isTrue); @@ -571,15 +591,12 @@ void main() { m.onRetryableLoss(_loss, snapshot()); expect(host.count('startSession'), 2); - m.onNetworkChanged(online: true); + m.onNetworkAvailable(since: clock.now()); async.flushMicrotasks(); expect(host.count('startSession'), 3); - - // The scheduled attempt 3 does not run on top. async.elapse(const Duration(seconds: 5)); expect(host.count('startSession'), 3); - // 2 more attempts follow the immediate one. expect(m.onRetryableLoss(_loss, snapshot()), isTrue); async.elapse(const Duration(milliseconds: 800)); expect(m.onRetryableLoss(_loss, snapshot()), isTrue); @@ -589,8 +606,23 @@ void main() { }); }); - test('a new network abandons a running attempt; its late start error ' - 'is ignored', () { + test('an attempt that started after the change began is kept: it already ' + 'runs on the new network', () { + fakeAsync((async) { + host.startGate = Completer(); + final m = manager()..onRetryableLoss(_loss, snapshot()); + final began = clock.now(); + async.elapse(const Duration(milliseconds: 500)); + expect(host.count('startSession'), 1); + + m.onNetworkAvailable(since: began); + async.flushMicrotasks(); + expect(host.count('startSession'), 1); + }); + }); + + test('an attempt that started before the change began is abandoned; its ' + 'late start error is ignored', () { fakeAsync((async) { final gate = host.startGate = Completer(); host.failingStarts = 1; @@ -598,11 +630,11 @@ void main() { async.elapse(const Duration(milliseconds: 500)); expect(host.count('startSession'), 1); - m.onNetworkChanged(online: true); + async.elapse(const Duration(milliseconds: 100)); + m.onNetworkAvailable(since: clock.now()); async.flushMicrotasks(); expect(host.count('startSession'), 2); - // The abandoned attempt throws; nothing new is scheduled. gate.complete(); async.elapse(const Duration(seconds: 5)); expect(host.count('startSession'), 2); @@ -623,7 +655,7 @@ void main() { unawaited(m.onSessionJoined()); async.flushMicrotasks(); - m.onNetworkChanged(online: true); + m.onNetworkAvailable(since: clock.now()); async.flushMicrotasks(); expect(host.count('startSession'), 2); @@ -633,21 +665,102 @@ void main() { }); }); + test('a session that joins while offline does not complete the ' + 'reconnection; the network back starts a new attempt', () { + fakeAsync((async) { + final m = manager()..onRetryableLoss(_loss, snapshot()); + async.elapse(const Duration(milliseconds: 500)); + m.onNetworkLost(); + unawaited(m.onSessionJoined()); + async.flushMicrotasks(); + expect(host.events.whereType(), isEmpty); + expect(m.isReconnecting, isTrue); + + m.onNetworkAvailable(since: clock.now()); + async.flushMicrotasks(); + expect(host.count('startSession'), 2); + }); + }); + + test('offline for offlineTimeout fails the reconnection', () { + fakeAsync((async) { + final m = + manager( + const ReconnectConfig(offlineTimeout: Duration(seconds: 10)), + ) + ..onRetryableLoss(_loss, snapshot()) + ..onNetworkLost(); + async.elapse(const Duration(milliseconds: 9999)); + expect(m.isReconnecting, isTrue); + + async.elapse(const Duration(milliseconds: 1)); + expect(m.state, ReconnectState.failed); + expect(host.calls.sublist(host.calls.length - 3), [ + 'clearQueue', + 'emit:ReconnectionFailed', + 'endSession:${DisconnectReason.reconnectionFailed}', + ]); + }); + }); + + test('the network back stops the offline timeout', () { + fakeAsync((async) { + final m = + manager( + const ReconnectConfig(offlineTimeout: Duration(seconds: 10)), + ) + ..onRetryableLoss(_loss, snapshot()) + ..onNetworkLost(); + async.elapse(const Duration(seconds: 5)); + m.onNetworkAvailable(since: clock.now()); + async.elapse(const Duration(seconds: 30)); + expect(host.events.whereType(), isEmpty); + }); + }); + + test('one reconnection makes at most 3 × maxAttempts attempts', () { + fakeAsync((async) { + final m = manager(const ReconnectConfig(maxAttempts: 2)) + ..onRetryableLoss(_loss, snapshot()); + // A network that keeps changing restarts the budget every time. + for (var i = 0; i < 6; i++) { + async.elapse(const Duration(seconds: 1)); + m.onNetworkAvailable(since: clock.now()); + async.flushMicrotasks(); + m.onRetryableLoss(_loss, snapshot()); + } + expect(host.count('startSession'), 6); + expect(m.state, ReconnectState.failed); + expect(host.events.whereType(), hasLength(1)); + }); + }); + test('changes outside a reconnection start nothing', () { fakeAsync((async) { final m = manager() - ..onNetworkChanged(online: false) - ..onNetworkChanged(online: true); + ..onNetworkLost() + ..onNetworkAvailable(since: clock.now()); async.elapse(const Duration(seconds: 5)); expect(host.calls, isEmpty); m ..onRetryableLoss(_loss, snapshot()) ..reset() - ..onNetworkChanged(online: true); + ..onNetworkAvailable(since: clock.now()); async.elapse(const Duration(seconds: 5)); expect(host.count('startSession'), 0); }); }); + + test('reset forgets that the device was offline', () { + fakeAsync((async) { + manager() + ..onNetworkLost() + ..reset() + ..onRetryableLoss(_loss, snapshot()); + async.elapse(const Duration(milliseconds: 500)); + expect(host.count('startSession'), 1); + }); + }); }); } From d09eb6d413b9da3f00ce8505d76cb32f564de46f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Wed, 21 Oct 2026 19:39:21 +0200 Subject: [PATCH 04/29] Publish the local tracks again after reconnecting by default With addTracksOnReconnect off, a network switch left the user publishing nothing until the app added its tracks again. It now defaults to true (deviation D26 from the web SDK); tests that cover the behaviour without restoration say so explicitly. Claude-Session: https://claude.ai/code/session_01Q5vZKmaVuaPpgoRaUvLQT8 --- README.md | 2 +- SPEC.md | 3 ++- lib/src/models/config.dart | 5 +++-- test/client/fishjam_client_test.dart | 2 +- test/models/models_test.dart | 4 ++-- test/signaling/reconnect_test.dart | 16 ++++++++++------ 6 files changed, 19 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 68b8228..1245f29 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ A lost connection is re-established automatically (`ReconnectConfig`): session (track operations, metadata updates, `createDataChannels()`) fail at once with `StateError`. - Before the new `Joined`, the remote tracks and peers you knew are reported with `TrackRemoved` and `PeerLeft` / `ComponentRemoved`; the new `Joined` lists the room as it is now. -- Local tracks are re-published only with `ReconnectConfig(addTracksOnReconnect: true)`, under new track ids +- Local tracks are re-published (`ReconnectConfig.addTracksOnReconnect`, on by default), under new track ids (the old ids get `LocalTrackRemoved`). Create the data channels again after `Reconnected`. `replaceTrack(id, null)` mutes by sending nothing; the device keeps capturing until you stop the track. diff --git a/SPEC.md b/SPEC.md index 5377e88..1b15df9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -170,7 +170,7 @@ All client → server messages go through a single ordered queue in front of the ## 6. Reconnection Configuration (`ReconnectConfig`): `maxAttempts` (default 3), `initialDelay` (default 500 ms), `delay` -(default 500 ms), `addTracksOnReconnect` (default false), `offlineTimeout` (default 60 s, [§6.1](#61-network-changes)). +(default 500 ms), `addTracksOnReconnect` (default true, D26), `offlineTimeout` (default 60 s, [§6.1](#61-network-changes)). `ReconnectConfig.disabled` turns it off. Reconnect is enabled with defaults unless configured otherwise. States: `idle`, `reconnecting`, `failed`. @@ -658,6 +658,7 @@ through `dart:developer`'s `log` only when `debug` is true. The SDK never calls | D23 | a network switch is noticed only when the socket or ICE fails; attempts are used up while the device is offline | losing a network is a retryable loss; attempts wait while offline (at most `offlineTimeout`) and restart with a fresh budget on a new network (§6.1) | fast recovery on mobile; a tunnel does not exhaust the attempts | | D24 | ICE `disconnected` only renegotiates, however long it lasts | `disconnected` for 3 s is a retryable loss (§8.4) | do not wait for a late `failed` | | D25 | opening the socket has no timeout | 10 s (§2.1) | an attempt on a dead network does not hang | +| D26 | `addTracksOnReconnect` defaults to false: after a reconnection the user publishes nothing until the app adds its tracks again | defaults to true (§6) | a network switch on mobile should not silently turn off the camera and microphone | ## 23. Golden fixtures diff --git a/lib/src/models/config.dart b/lib/src/models/config.dart index 67ce192..0aadd14 100644 --- a/lib/src/models/config.dart +++ b/lib/src/models/config.dart @@ -8,7 +8,7 @@ final class ReconnectConfig { this.maxAttempts = 3, this.initialDelay = const Duration(milliseconds: 500), this.delay = const Duration(milliseconds: 500), - this.addTracksOnReconnect = false, + this.addTracksOnReconnect = true, this.offlineTimeout = const Duration(seconds: 60), }); @@ -28,7 +28,8 @@ final class ReconnectConfig { /// Additional delay added for every following attempt. final Duration delay; - /// Whether local tracks are published again after reconnecting. + /// Whether local tracks are published again (with new ids) after + /// reconnecting. final bool addTracksOnReconnect; /// How long a reconnection waits for the device to get a network before diff --git a/test/client/fishjam_client_test.dart b/test/client/fishjam_client_test.dart index 63207b6..118e102 100644 --- a/test/client/fishjam_client_test.dart +++ b/test/client/fishjam_client_test.dart @@ -21,13 +21,13 @@ const _fast = ReconnectConfig( maxAttempts: 2, initialDelay: Duration(milliseconds: 20), delay: Duration(milliseconds: 20), + addTracksOnReconnect: false, ); const _fastRestore = ReconnectConfig( maxAttempts: 2, initialDelay: Duration(milliseconds: 20), delay: Duration(milliseconds: 20), - addTracksOnReconnect: true, ); Future _wait(int ms) async { diff --git a/test/models/models_test.dart b/test/models/models_test.dart index 3c5b74b..bfeab93 100644 --- a/test/models/models_test.dart +++ b/test/models/models_test.dart @@ -171,11 +171,11 @@ void main() { }); group('ReconnectConfig (SPEC §6)', () { - test('defaults enable three attempts', () { + test('defaults enable three attempts that publish the tracks again', () { const config = ReconnectConfig(); expect(config.isEnabled, isTrue); expect(config.maxAttempts, 3); - expect(config.addTracksOnReconnect, isFalse); + expect(config.addTracksOnReconnect, isTrue); expect(ReconnectConfig.disabled.isEnabled, isFalse); }); diff --git a/test/signaling/reconnect_test.dart b/test/signaling/reconnect_test.dart index 839a333..35281a3 100644 --- a/test/signaling/reconnect_test.dart +++ b/test/signaling/reconnect_test.dart @@ -90,8 +90,12 @@ LocalTrackContext _track(String id, {RtcMediaTrack? media}) => const _loss = RetryableClose(code: 1011, reason: 'Internal server error'); -// maxAttempts 3, initialDelay 500 ms (the defaults), delay 300 ms. -const _config = ReconnectConfig(delay: Duration(milliseconds: 300)); +// maxAttempts 3, initialDelay 500 ms (the defaults), delay 300 ms, tracks +// not restored. +const _config = ReconnectConfig( + delay: Duration(milliseconds: 300), + addTracksOnReconnect: false, +); void main() { late _Host host; @@ -267,7 +271,7 @@ void main() { 'Reconnected', () { fakeAsync((async) { final ended = FakeMediaTrack.video()..end(); - final m = manager(const ReconnectConfig(addTracksOnReconnect: true)) + final m = manager(const ReconnectConfig()) ..onRetryableLoss( _loss, snapshot( @@ -329,7 +333,7 @@ void main() { test('a loss during restoration cancels Reconnected and schedules the ' 'next attempt', () { fakeAsync((async) { - final m = manager(const ReconnectConfig(addTracksOnReconnect: true)) + final m = manager(const ReconnectConfig()) ..onRetryableLoss( _loss, snapshot( @@ -393,7 +397,7 @@ void main() { test('a terminal close during restoration prevents Reconnected', () { fakeAsync((async) { - final m = manager(const ReconnectConfig(addTracksOnReconnect: true)) + final m = manager(const ReconnectConfig()) ..onRetryableLoss( _loss, snapshot(tracks: [_track('cam', media: FakeMediaTrack.video())]), @@ -646,7 +650,7 @@ void main() { 'and cancels Reconnected', () { fakeAsync((async) { final pending = host.pendingRestores['t1'] = Completer(); - final m = manager(const ReconnectConfig(addTracksOnReconnect: true)) + final m = manager(const ReconnectConfig()) ..onRetryableLoss( _loss, snapshot(tracks: [_track('t1', media: FakeMediaTrack.video())]), From 8604dcdcea24d5873633a445601f69ec5b2cbd3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Thu, 22 Oct 2026 19:45:38 +0200 Subject: [PATCH 05/29] Prefill the example's join form from a dart-define file FISHJAM_ID, SANDBOX_API_URL and PEER_TOKEN can come from a file passed with --dart-define-from-file; example/.env.example lists them and where to get them. With a Sandbox API URL the form starts in sandbox mode. Claude-Session: https://claude.ai/code/session_01Q5vZKmaVuaPpgoRaUvLQT8 --- example/.env.example | 10 ++++++++++ example/lib/join_screen.dart | 8 +++++--- 2 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 example/.env.example diff --git a/example/.env.example b/example/.env.example new file mode 100644 index 0000000..f76e3a2 --- /dev/null +++ b/example/.env.example @@ -0,0 +1,10 @@ +# Copy to .env.local, then: flutter run --dart-define-from-file=.env.local + +# https://fishjam.io/app +FISHJAM_ID= + +# https://fishjam.io/app/sandbox (prototyping only) +SANDBOX_API_URL= + +# Or a peer token from your backend (server SDK) +# PEER_TOKEN= diff --git a/example/lib/join_screen.dart b/example/lib/join_screen.dart index 66f0eb2..b6e1d4b 100644 --- a/example/lib/join_screen.dart +++ b/example/lib/join_screen.dart @@ -18,16 +18,18 @@ class JoinScreen extends StatefulWidget { class _JoinScreenState extends State { final _form = GlobalKey(); final _name = TextEditingController(text: 'flutter'); - // Optional prefill: flutter run --dart-define=FISHJAM_ID=... --dart-define=PEER_TOKEN=... + // Optional prefill from dart-defines; see example/.env.example. final _fishjamUrl = TextEditingController( text: const String.fromEnvironment('FISHJAM_ID'), ); final _peerToken = TextEditingController( text: const String.fromEnvironment('PEER_TOKEN'), ); - final _sandboxUrl = TextEditingController(); + final _sandboxUrl = TextEditingController(text: _sandboxUrlDefine); final _roomName = TextEditingController(text: 'flutter-room'); - _Mode _mode = _Mode.peerToken; + _Mode _mode = _sandboxUrlDefine.isEmpty ? _Mode.peerToken : _Mode.sandbox; + + static const _sandboxUrlDefine = String.fromEnvironment('SANDBOX_API_URL'); bool _busy = false; @override From 342051ab6e0f09b2163e7f0526d0624bdeffaf30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Thu, 22 Oct 2026 19:45:50 +0200 Subject: [PATCH 06/29] Report a restored track removed once on a later reconnection A session that completed a reconnection was still treated as a reconnection attempt, so when the next reconnection began, the tracks it had restored were reported removed twice. Claude-Session: https://claude.ai/code/session_01Q5vZKmaVuaPpgoRaUvLQT8 --- lib/src/fishjam_client.dart | 7 ++++++- test/client/fishjam_client_test.dart | 29 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/src/fishjam_client.dart b/lib/src/fishjam_client.dart index 5fba2f9..4640524 100644 --- a/lib/src/fishjam_client.dart +++ b/lib/src/fishjam_client.dart @@ -688,7 +688,12 @@ class _Host implements ReconnectHost { @override void emit(FishjamEvent event) { - if (event is Reconnected) _client._status = ConnectionStatus.joined; + if (event is Reconnected) { + _client._status = ConnectionStatus.joined; + // Its tracks are now the snapshot of the next reconnection, which + // reports them removed itself. + _client._session?.isReconnectAttempt = false; + } _client._emit(event); } } diff --git a/test/client/fishjam_client_test.dart b/test/client/fishjam_client_test.dart index 118e102..e560fd9 100644 --- a/test/client/fishjam_client_test.dart +++ b/test/client/fishjam_client_test.dart @@ -476,6 +476,35 @@ void main() { expect(h.client.localPeer!.tracks.keys, [restored.trackId]); }); + test( + 'a later reconnection reports each restored track removed once', + () async { + h = ClientHarness(reconnect: _fastRestore); + await h.join(); + final cam = await h.publish('video'); + h.events.clear(); + + Future reconnect() async { + h.transport.serverClose(code: 1011, reason: 'boom'); + await _wait(40); + await h.authenticate(); + await h.connectedEvent(); + await h.negotiate(); + await settle(); + } + + await reconnect(); + final cam2 = h.eventsOf().single.track.trackId; + await reconnect(); + + expect(h.eventsOf().map((e) => e.trackId), [ + cam, + cam2, + ]); + expect(h.eventsOf(), hasLength(2)); + }, + ); + test('tracks restored by an attempt that is then lost are reported ' 'removed too', () async { h = ClientHarness(reconnect: _fastRestore); From a6f0857a0540fa38640ed66963d6791f26f6b49d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Thu, 22 Oct 2026 19:46:02 +0200 Subject: [PATCH 07/29] Restore all local tracks at once after reconnecting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracks were re-added one after another, each waiting for its own negotiation, so audio came back 0.6-1.8 s after video on devices. They are now started together and ride one negotiation cycle (SPEC §8.3). An attempt lost while restoring adds none of them; the next attempt restores them all. Claude-Session: https://claude.ai/code/session_01Q5vZKmaVuaPpgoRaUvLQT8 --- SPEC.md | 2 +- lib/src/signaling/reconnect.dart | 23 +++++++-------- test/client/fishjam_client_test.dart | 44 ++++++++++++++++++++-------- test/signaling/reconnect_test.dart | 17 +++++++---- 4 files changed, 54 insertions(+), 32 deletions(-) diff --git a/SPEC.md b/SPEC.md index 1b15df9..874f78b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -183,7 +183,7 @@ States: `idle`, `reconnecting`, `failed`. | reconnecting | retryable loss in the new session before `connected` (including a new session that cannot be started at all) | schedule next attempt if attempts remain | reconnecting | | reconnecting | retryable loss, no attempts left | clear queue; emit `ReconnectionFailed`, then `Disconnected(DisconnectReason.reconnectionFailed)` | failed | | reconnecting | terminal close (auth/join/session ended/protocol) | cancel timer; clear queue; emit the matching error event and `Disconnected` | failed | -| reconnecting | `connected` | emit `TrackRemoved` for every remote track and then `PeerLeft` / `ComponentRemoved` for every remote endpoint the app was told about by the lost session; emit `Joined` (its snapshot is the source of truth); emit `LocalTrackRemoved(oldTrackId)` for every snapshotted track (once per reconnection, also without `addTracksOnReconnect`); if `addTracksOnReconnect`, re-add every snapshotted track whose media track is still live (skip, do not abort on, ended or muted tracks); when all re-adds settled (success or failure): flush queue, emit `Reconnected`, reset attempt counter | idle | +| reconnecting | `connected` | emit `TrackRemoved` for every remote track and then `PeerLeft` / `ComponentRemoved` for every remote endpoint the app was told about by the lost session; emit `Joined` (its snapshot is the source of truth); emit `LocalTrackRemoved(oldTrackId)` for every snapshotted track (once per reconnection, also without `addTracksOnReconnect`); if `addTracksOnReconnect`, re-add every snapshotted track whose media track is still live (skip, do not abort on, ended or muted tracks), all at once and in snapshot order, so they ride one negotiation cycle ([§8.3](#83-command-queue)); when all re-adds settled (success or failure): flush queue, emit `Reconnected`, reset attempt counter | idle | | any | `leave()` | cancel timer; clear queue | idle | - Delay of attempt `n` (1-based) is `initialDelay + (n - 1) * delay`. diff --git a/lib/src/signaling/reconnect.dart b/lib/src/signaling/reconnect.dart index 50342cd..273db70 100644 --- a/lib/src/signaling/reconnect.dart +++ b/lib/src/signaling/reconnect.dart @@ -173,9 +173,9 @@ class ReconnectManager { /// reports every snapshotted track removed (once per reconnection), /// restores tracks, releases the queue and emits `Reconnected`. /// - /// Only live tracks are restored; ended or muted ones are skipped and a - /// failed restore does not stop the others. If the session is lost - /// while restoring, `Reconnected` is not emitted for it. + /// Only live tracks are restored, all at once; ended or muted ones are + /// skipped and a failed restore does not stop the others. If the session + /// is lost while restoring, `Reconnected` is not emitted for it. Future onSessionJoined() async { if (_state != ReconnectState.reconnecting) return; if (!_online) { @@ -194,15 +194,14 @@ class ReconnectManager { } } if (_config.addTracksOnReconnect) { - for (final track in tracks) { - if (!(track.rtcTrack?.isLive ?? false)) continue; - try { - await _host.restoreTrack(track); - } on Object { - // The host reports the failure; the other tracks still go. - } - if (generation != _generation) return; - } + // Started together, so they ride one negotiation cycle and the media + // comes back at once. The host reports a failure; the others still go. + await Future.wait([ + for (final track in tracks) + if (track.rtcTrack?.isLive ?? false) + _host.restoreTrack(track).then((_) {}, onError: (_) {}), + ]); + if (generation != _generation) return; } _finish(ReconnectState.idle, release: true); _host.emit(const Reconnected()); diff --git a/test/client/fishjam_client_test.dart b/test/client/fishjam_client_test.dart index e560fd9..f36fdd0 100644 --- a/test/client/fishjam_client_test.dart +++ b/test/client/fishjam_client_test.dart @@ -505,8 +505,33 @@ void main() { }, ); - test('tracks restored by an attempt that is then lost are reported ' - 'removed too', () async { + test( + 'live tracks are restored together, in one negotiation cycle', + () async { + h = ClientHarness(reconnect: _fastRestore); + await h.join(); + await h.publish('video'); + await h.publish('audio'); + final renegotiations = h.sentOf(_C.renegotiateTracks).length; + h.events.clear(); + + h.transport.serverClose(code: 1011, reason: 'boom'); + await _wait(40); + await h.authenticate(); + await h.connectedEvent(); + await h.negotiate(); + await settle(); + + expect(h.sentOf(_C.renegotiateTracks), hasLength(renegotiations + 1)); + final offer = h.sentOf(_C.sdpOffer).last.sdpOffer; + expect(offer.trackIdToMetadataJson, hasLength(2)); + expect(h.eventsOf(), hasLength(2)); + expect(h.eventsOf(), hasLength(1)); + }, + ); + + test('an attempt lost while its tracks are being restored adds none of ' + 'them; the next attempt restores them all', () async { h = ClientHarness(reconnect: _fastRestore); await h.join(); final cam = await h.publish('video'); @@ -517,24 +542,17 @@ void main() { await _wait(40); await h.authenticate(); await h.connectedEvent(); - await h.negotiate(); // the camera is restored - final cam2 = h.eventsOf().single.track.trackId; - h.transport.serverClose(code: 1011, reason: 'boom'); // mic pending + h.transport.serverClose(code: 1011, reason: 'boom'); // restore pending await _wait(60); await h.authenticate(); await h.connectedEvent(); await h.negotiate(); - await h.negotiate(); await settle(); - expect(h.eventsOf().map((e) => e.trackId), [ - cam, - mic, - cam2, - ]); + expect(h.eventsOf().map((e) => e.trackId), [cam, mic]); final added = h.eventsOf().map((e) => e.track.trackId); - expect(added, hasLength(3)); - expect(h.client.localPeer!.tracks.keys, added.skip(1)); + expect(added, hasLength(2)); + expect(h.client.localPeer!.tracks.keys, added); expect(h.eventsOf(), hasLength(1)); }); diff --git a/test/signaling/reconnect_test.dart b/test/signaling/reconnect_test.dart index 35281a3..480308b 100644 --- a/test/signaling/reconnect_test.dart +++ b/test/signaling/reconnect_test.dart @@ -266,8 +266,8 @@ void main() { }); }); - test('restores live tracks in order, skips ended and muted ones, ' - 'survives failures, then releases the queue and emits ' + test('restores live tracks together and in order, skips ended and muted ' + 'ones, survives failures, then releases the queue and emits ' 'Reconnected', () { fakeAsync((async) { final ended = FakeMediaTrack.video()..end(); @@ -293,8 +293,13 @@ void main() { unawaited(m.onSessionJoined().then((_) => done = true)); async.flushMicrotasks(); - // The queue stays held until every restore settled. - expect(host.calls.last, 'restoreTrack:cam'); + // Every restore starts before the first one completes, so they ride + // one negotiation cycle; the queue stays held until all settled. + expect(host.calls.where((c) => c.startsWith('restoreTrack')), [ + 'restoreTrack:cam', + 'restoreTrack:broken', + 'restoreTrack:mic', + ]); expect(host.count('releaseQueue'), 0); expect(m.isReconnecting, isTrue); expect(done, isFalse); @@ -308,7 +313,7 @@ void main() { 'restoreTrack:broken', 'restoreTrack:mic', ]); - expect(host.restored, ['cam', 'mic']); + expect(host.restored, unorderedEquals(['cam', 'mic'])); // Every old track id is reported removed, in order, before the // first restore (restored tracks get new ids). expect( @@ -352,7 +357,7 @@ void main() { host.pendingRestores['cam']!.complete(); async.flushMicrotasks(); - expect(host.count('restoreTrack:mic'), 0); + expect(host.count('restoreTrack:mic'), 1); expect(host.count('emit:Reconnected'), 0); expect(host.count('releaseQueue'), 0); expect(m.isReconnecting, isTrue); From daa70c3326318a83220581ac60909938625aaa6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Thu, 22 Oct 2026 19:46:25 +0200 Subject: [PATCH 08/29] Act at once on a network that appears after offline Such a report can only mean a new session is needed, so waiting 500 ms for it to settle only delayed the reconnection. Later reports settle against it as usual. Claude-Session: https://claude.ai/code/session_01Q5vZKmaVuaPpgoRaUvLQT8 --- SPEC.md | 3 ++ lib/src/signaling/network_monitor.dart | 12 ++++++-- test/signaling/network_watcher_test.dart | 37 ++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/SPEC.md b/SPEC.md index 874f78b..a16f6b9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -212,6 +212,9 @@ the session will not survive; reacting at once avoids waiting for a pong timeout - A report is acted on once the reports have been stable for 500 ms (the **settle** time), so the brief "offline" some platforms report while switching networks is not a loss of its own. Each change carries the time its first report arrived (when the change **began**). +- Exception: a network reported after offline (settled, or still settling) is acted on at once. The change is + then certain to be `available` or `changed`, and waiting would only delay the new session. Later reports + settle against it as usual. - Kinds of change, comparing the settled set with the previous one: - **lost**: now offline; - **available**: online after being offline; diff --git a/lib/src/signaling/network_monitor.dart b/lib/src/signaling/network_monitor.dart index c06fec8..1e27987 100644 --- a/lib/src/signaling/network_monitor.dart +++ b/lib/src/signaling/network_monitor.dart @@ -27,9 +27,9 @@ enum NetworkChange { /// Turns [NetworkMonitor] reports into [NetworkChange]s (SPEC §6.1): the /// first value is the baseline, and reports are acted on once they have been -/// stable for [settle]; each change carries the time its first report -/// arrived. When the OS cannot be asked, it logs a warning and reports -/// nothing. +/// stable for [settle], except a network reported after offline, which is +/// acted on at once; each change carries the time its first report arrived. +/// When the OS cannot be asked, it logs a warning and reports nothing. class NetworkWatcher { NetworkWatcher({ required this._monitor, @@ -96,6 +96,12 @@ class NetworkWatcher { _began ??= clock.now(); if (networks.isEmpty) _wentOffline = true; _settling?.cancel(); + if (networks.isNotEmpty && (_wentOffline || _last!.isEmpty)) { + // A network after offline can only mean a new session is needed; + // settling would only delay it. + _settled(networks); + return; + } _settling = Timer(settle, () => _settled(networks)); } diff --git a/test/signaling/network_watcher_test.dart b/test/signaling/network_watcher_test.dart index 0685517..1626b3a 100644 --- a/test/signaling/network_watcher_test.dart +++ b/test/signaling/network_watcher_test.dart @@ -98,6 +98,43 @@ void main() { }); }); + test('a network appearing after a settled offline is available at once', () { + run((async) { + monitor.report({}); + async.elapse(_settle); + final began = clock.now(); + monitor.report({'cellular'}); + async.flushMicrotasks(); + + expect(changes.last, (NetworkChange.available, began)); + }); + }); + + test('a network appearing while offline is still settling is a change at ' + 'once, that began with the offline report', () { + run((async) { + final began = clock.now(); + monitor.report({}); + async.elapse(const Duration(milliseconds: 200)); + monitor.report({'wifi'}); + async.flushMicrotasks(); + + expect(changes, [(NetworkChange.changed, began)]); + }); + }); + + test('reports right after a network appeared settle against it', () { + run((async) { + monitor + ..report({}) + ..report({'wifi'}) + ..report({'wifi', 'cellular'}); + async.elapse(_settle); + + expect(kinds(), [NetworkChange.changed]); + }); + }); + test('only gaining a network is no change; losing one later is', () { run((async) { monitor.report({'wifi', 'cellular'}); From b09b045dc8427a386df6889d8ece09dee7434e22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Thu, 22 Oct 2026 19:46:36 +0200 Subject: [PATCH 09/29] Do not complete a reconnection that goes offline while restoring tracks Only joining while offline was checked. If the network went away while the tracks were being restored, Reconnected was emitted for a session that could not survive, and the network coming back started nothing because the client counted as joined. The reconnection now waits for the network and starts a new attempt then, as when the session joins offline. Claude-Session: https://claude.ai/code/session_01Q5vZKmaVuaPpgoRaUvLQT8 --- SPEC.md | 5 +++-- lib/src/signaling/reconnect.dart | 10 +++++++++- test/signaling/reconnect_test.dart | 26 ++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/SPEC.md b/SPEC.md index a16f6b9..e1d09a3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -233,8 +233,9 @@ the session will not survive; reacting at once avoids waiting for a pong timeout | reconnecting | available or changed | the attempt budget starts over (`maxAttempts` on the new network). An attempt in progress that started after the change began is kept: it already runs on the new network. Otherwise a scheduled attempt is cancelled, an attempt in progress is abandoned (its session torn down silently) and a new attempt starts at once | | reconnecting | offline for `offlineTimeout` | as "no attempts left" (§6): `ReconnectionFailed`, then `Disconnected(reconnectionFailed)` | -- A session of a reconnection that reaches `connected` while the device is offline does not complete it: no - `Reconnected`; the reconnection waits for the network and starts a new attempt then. +- A session of a reconnection that reaches `connected` while the device is offline, or goes offline before its + tracks are restored, does not complete it: no `Reconnected`; the reconnection waits for the network and starts + a new attempt then. - One reconnection makes at most `3 × maxAttempts` attempts in total, so a network that keeps changing cannot keep it going forever; when that cap is reached the reconnection fails as when no attempts are left. - When the OS cannot be asked, the client assumes it is always online and never reports a change (logged). diff --git a/lib/src/signaling/reconnect.dart b/lib/src/signaling/reconnect.dart index 273db70..c3b648f 100644 --- a/lib/src/signaling/reconnect.dart +++ b/lib/src/signaling/reconnect.dart @@ -175,7 +175,8 @@ class ReconnectManager { /// /// Only live tracks are restored, all at once; ended or muted ones are /// skipped and a failed restore does not stop the others. If the session - /// is lost while restoring, `Reconnected` is not emitted for it. + /// is lost or the device goes offline while restoring, `Reconnected` is not + /// emitted for it. Future onSessionJoined() async { if (_state != ReconnectState.reconnecting) return; if (!_online) { @@ -202,6 +203,13 @@ class ReconnectManager { _host.restoreTrack(track).then((_) {}, onError: (_) {}), ]); if (generation != _generation) return; + if (!_online) { + // Nothing reports the loss of this session while it is not yet + // joined, so it is abandoned like one that joined offline. + _attemptStartedAt = null; + _waitForNetwork(); + return; + } } _finish(ReconnectState.idle, release: true); _host.emit(const Reconnected()); diff --git a/test/signaling/reconnect_test.dart b/test/signaling/reconnect_test.dart index 480308b..1c99091 100644 --- a/test/signaling/reconnect_test.dart +++ b/test/signaling/reconnect_test.dart @@ -691,6 +691,32 @@ void main() { }); }); + test('a session that goes offline while its tracks are restored does not ' + 'complete the reconnection; the network back starts a new attempt', () { + fakeAsync((async) { + final pending = host.pendingRestores['t1'] = Completer(); + final m = manager(const ReconnectConfig()) + ..onRetryableLoss( + _loss, + snapshot(tracks: [_track('t1', media: FakeMediaTrack.video())]), + ); + async.elapse(const Duration(milliseconds: 500)); + unawaited(m.onSessionJoined()); + async.flushMicrotasks(); + + m.onNetworkLost(); + pending.complete(); + async.flushMicrotasks(); + expect(host.events.whereType(), isEmpty); + expect(host.count('releaseQueue'), 0); + expect(m.isReconnecting, isTrue); + + m.onNetworkAvailable(since: clock.now()); + async.flushMicrotasks(); + expect(host.count('startSession'), 2); + }); + }); + test('offline for offlineTimeout fails the reconnection', () { fakeAsync((async) { final m = From a6cb3d8ce8a830fa34a86a2bd0f42b3c235e2555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Thu, 22 Oct 2026 19:46:48 +0200 Subject: [PATCH 10/29] Read the network state before subscribing to its reports Without the connectivity plugin, as in an app's widget tests, the failed subscription went to FlutterError.onError instead of the stream, so it failed those tests although the client fell back to always online. The current networks are now read first; when that fails the watcher logs a warning and never subscribes. Claude-Session: https://claude.ai/code/session_01Q5vZKmaVuaPpgoRaUvLQT8 --- SPEC.md | 4 ++- lib/src/signaling/network_monitor.dart | 16 ++++++---- .../connectivity_missing_plugin_test.dart | 29 +++++++++++++++++++ test/signaling/network_watcher_test.dart | 28 ++++++++++++++---- 4 files changed, 64 insertions(+), 13 deletions(-) create mode 100644 test/client/connectivity_missing_plugin_test.dart diff --git a/SPEC.md b/SPEC.md index e1d09a3..7dc32a4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -238,7 +238,9 @@ the session will not survive; reacting at once avoids waiting for a pong timeout a new attempt then. - One reconnection makes at most `3 × maxAttempts` attempts in total, so a network that keeps changing cannot keep it going forever; when that cap is reached the reconnection fails as when no attempts are left. -- When the OS cannot be asked, the client assumes it is always online and never reports a change (logged). +- When the OS cannot be asked, the client assumes it is always online and never reports a change. This is only + logged: the client asks for the networks in use before it subscribes to reports, so nothing reaches the app's + error handler. ## 7. Media event processing diff --git a/lib/src/signaling/network_monitor.dart b/lib/src/signaling/network_monitor.dart index 1e27987..b18832e 100644 --- a/lib/src/signaling/network_monitor.dart +++ b/lib/src/signaling/network_monitor.dart @@ -45,6 +45,9 @@ class NetworkWatcher { StreamSubscription>? _subscription; + /// Bumped by [stop], so a [start] still reading the start value gives up. + int _runs = 0; + /// The last settled networks; `null` before the baseline. Set? _last; @@ -59,7 +62,13 @@ class NetworkWatcher { /// Starts watching with a new baseline (stops a previous watch first). Future start() async { await stop(); + final run = _runs; try { + // Read first: a platform that cannot be asked reports a failed + // subscription to the framework's error handler, not to the stream. + final networks = await _monitor.current(); + if (run != _runs) return; + _last = networks; _subscription = _monitor.changes.listen( _onReport, onError: (Object e) { @@ -67,8 +76,6 @@ class NetworkWatcher { unawaited(stop()); }, ); - final networks = await _monitor.current(); - if (_subscription != null) _last ??= networks; } on Object catch (e) { _logger.warn('Cannot read the network state; not watching it', e); await stop(); @@ -76,6 +83,7 @@ class NetworkWatcher { } Future stop() async { + _runs++; final cancelled = _subscription?.cancel(); _subscription = null; _last = null; @@ -87,10 +95,6 @@ class NetworkWatcher { } void _onReport(Set networks) { - if (_last == null) { - _last = networks; - return; - } // Platforms report a short "offline" while they switch the default // network; acting on it would cost a reconnection of its own. _began ??= clock.now(); diff --git a/test/client/connectivity_missing_plugin_test.dart b/test/client/connectivity_missing_plugin_test.dart new file mode 100644 index 0000000..1d4f0a7 --- /dev/null +++ b/test/client/connectivity_missing_plugin_test.dart @@ -0,0 +1,29 @@ +import 'package:fishjam_client/src/connectivity_network_monitor.dart'; +import 'package:fishjam_client/src/logger.dart'; +import 'package:fishjam_client/src/signaling/network_monitor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// No platform implementation is registered here, as in an app's widget test. +void main() { + testWidgets('without the plugin, watching only logs a warning; nothing is ' + 'reported to the framework', (tester) async { + final logs = []; + final watcher = NetworkWatcher( + monitor: ConnectivityNetworkMonitor(), + logger: FishjamLogger( + enabled: true, + sink: (level, message, {error, stackTrace}) => + logs.add('${level.name}: $message'), + ), + onChanged: (_, _) {}, + ); + + // Platform channels answer in real time. + await tester.runAsync(() async { + await watcher.start(); + await watcher.stop(); + }); + + expect(logs, [startsWith('warn')]); + }); +} diff --git a/test/signaling/network_watcher_test.dart b/test/signaling/network_watcher_test.dart index 1626b3a..994d4f9 100644 --- a/test/signaling/network_watcher_test.dart +++ b/test/signaling/network_watcher_test.dart @@ -149,25 +149,41 @@ void main() { }); }); - test('a report that arrives before the start value is the baseline', () { + test('reports are listened to only once the start value was read, so an ' + 'OS that cannot be asked is never subscribed to', () { fakeAsync((async) { final gate = monitor.currentGate = Completer(); unawaited(watcher.start()); async.flushMicrotasks(); - monitor.report({'cellular'}); - async.flushMicrotasks(); + expect(monitor.isWatched, isFalse); + gate.complete(); async.flushMicrotasks(); - monitor.report({'cellular'}); + expect(monitor.isWatched, isTrue); + monitor.report({'wifi'}); async.elapse(_settle); - expect(changes, isEmpty); }); }); - test('when the OS cannot be asked, it warns and reports nothing', () { + test('stop while the start value is read: nothing is listened to', () { + fakeAsync((async) { + final gate = monitor.currentGate = Completer(); + unawaited(watcher.start()); + async.flushMicrotasks(); + unawaited(watcher.stop()); + gate.complete(); + async.flushMicrotasks(); + + expect(monitor.isWatched, isFalse); + }); + }); + + test('when the OS cannot be asked, it warns, reports nothing and does not ' + 'listen', () { monitor.currentError = StateError('no plugin'); run((async) { + expect(monitor.isWatched, isFalse); monitor.report({'cellular'}); async.elapse(_settle); From 19007ce0866f169d02441574663872b815fbbbf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Thu, 22 Oct 2026 19:46:59 +0200 Subject: [PATCH 11/29] Ignore a failing close of the socket a reconnection abandons The old socket's close is not awaited, and after a network switch that socket has often failed already, so its close could fail as an unhandled error in the app's zone. The failure is now logged at debug level. Claude-Session: https://claude.ai/code/session_01Q5vZKmaVuaPpgoRaUvLQT8 --- lib/src/fishjam_client.dart | 7 ++++++- test/client/fishjam_client_test.dart | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/src/fishjam_client.dart b/lib/src/fishjam_client.dart index 4640524..6d241b5 100644 --- a/lib/src/fishjam_client.dart +++ b/lib/src/fishjam_client.dart @@ -532,7 +532,12 @@ class FishjamClient { if (waitForSocket) { await closing; } else { - unawaited(closing); + // The socket has often failed already, and then so does its close. + unawaited( + closing.catchError( + (Object e) => _logger.debug('Closing the old socket failed: $e'), + ), + ); } if (identical(_socket, session.signaling)) _socket = null; } diff --git a/test/client/fishjam_client_test.dart b/test/client/fishjam_client_test.dart index f36fdd0..2823ad0 100644 --- a/test/client/fishjam_client_test.dart +++ b/test/client/fishjam_client_test.dart @@ -788,6 +788,17 @@ void main() { expect(h.transport.sentMessages.single.hasAuthRequest(), isTrue); }); + test('an old socket whose close fails is not an unhandled error', () async { + h = ClientHarness(reconnect: slow); + await h.join(); + final gate = h.transport.closeGate = Completer(); + h.network.report({'cellular'}); + await settle(); + gate.completeError(StateError('socket failed')); + await _wait(10); + expect(h.transports.created, hasLength(2)); + }); + test('offline: the attempt waits for the network, then starts at ' 'once', () async { h = ClientHarness(reconnect: _fast); From 7910104a4188633d96edb312c5efc4b78d984415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Fri, 23 Oct 2026 10:54:20 +0200 Subject: [PATCH 12/29] Leave a newer network watch running when a superseded start fails A start whose first read of the network state failed stopped the watcher unconditionally, so it cancelled a newer start that had already subscribed. It now gives up silently once it is no longer the current run. Claude-Session: https://claude.ai/code/session_01DKNRwERcfKW2FtCEbRUjZA --- lib/src/signaling/network_monitor.dart | 2 ++ test/signaling/network_watcher_test.dart | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/lib/src/signaling/network_monitor.dart b/lib/src/signaling/network_monitor.dart index b18832e..1cd93c5 100644 --- a/lib/src/signaling/network_monitor.dart +++ b/lib/src/signaling/network_monitor.dart @@ -77,6 +77,8 @@ class NetworkWatcher { }, ); } on Object catch (e) { + // A newer start may be watching already; this one only had to give up. + if (run != _runs) return; _logger.warn('Cannot read the network state; not watching it', e); await stop(); } diff --git a/test/signaling/network_watcher_test.dart b/test/signaling/network_watcher_test.dart index 994d4f9..39d79e0 100644 --- a/test/signaling/network_watcher_test.dart +++ b/test/signaling/network_watcher_test.dart @@ -179,6 +179,28 @@ void main() { }); }); + test( + 'a superseded start whose read fails leaves the newer watch running', + () { + fakeAsync((async) { + final gate = monitor.currentGate = Completer(); + unawaited(watcher.start()); + async.flushMicrotasks(); + monitor.currentGate = null; + unawaited(watcher.start()); + async.flushMicrotasks(); + expect(monitor.isWatched, isTrue); + + monitor.currentError = StateError('no answer'); + gate.complete(); + async.flushMicrotasks(); + + expect(monitor.isWatched, isTrue); + expect(logs, isEmpty); + }); + }, + ); + test('when the OS cannot be asked, it warns, reports nothing and does not ' 'listen', () { monitor.currentError = StateError('no plugin'); From 39b3fcf67a46b7c9e0ac8c6ad34a2079848ad0c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Fri, 23 Oct 2026 11:10:50 +0200 Subject: [PATCH 13/29] Remove the data channel chat from the example The example now shows only media, mute toggles and leave, so a reconnection in it exercises tracks alone. Claude-Session: https://claude.ai/code/session_01Q5vZKmaVuaPpgoRaUvLQT8 --- example/README.md | 2 +- example/lib/room_screen.dart | 83 +----------------------------------- 2 files changed, 3 insertions(+), 82 deletions(-) diff --git a/example/README.md b/example/README.md index 8d3e922..9cbbeed 100644 --- a/example/README.md +++ b/example/README.md @@ -1,7 +1,7 @@ # fishjam_example A minimal Fishjam room built with `fishjam_client`: it publishes the camera and microphone, renders the -other peers' video, has mute toggles, a chat over the reliable data channel, and a leave button. +other peers' video, and has mute toggles and a leave button. ## Run diff --git a/example/lib/room_screen.dart b/example/lib/room_screen.dart index 19febc5..3bdc0bb 100644 --- a/example/lib/room_screen.dart +++ b/example/lib/room_screen.dart @@ -1,6 +1,4 @@ import 'dart:async'; -import 'dart:convert'; -import 'dart:typed_data'; import 'package:fishjam_client/fishjam_client.dart'; import 'package:flutter/material.dart'; @@ -9,7 +7,7 @@ import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'sandbox.dart'; /// A minimal room: publishes camera and microphone, renders remote video, -/// mute toggles, a data channel chat and leave. +/// mute toggles and leave. class RoomScreen extends StatefulWidget { const RoomScreen({ super.key, @@ -31,13 +29,6 @@ class _RemoteVideo { final String label; } -class _ChatMessage { - const _ChatMessage(this.from, this.text); - - final String from; - final String text; -} - class _RoomScreenState extends State { // Tracks are published again after a reconnection (with new ids). final _client = FishjamClient( @@ -45,8 +36,6 @@ class _RoomScreenState extends State { ); final _localRenderer = RTCVideoRenderer(); final _remote = {}; - final _messages = <_ChatMessage>[]; - final _chatInput = TextEditingController(); final _subscriptions = >[]; MediaStream? _localStream; @@ -66,9 +55,7 @@ class _RoomScreenState extends State { Future _start() async { await _localRenderer.initialize(); - _subscriptions - ..add(_client.events.listen(_onEvent)) - ..add(_client.dataStream(reliable: true).listen(_onChat)); + _subscriptions.add(_client.events.listen(_onEvent)); try { // Asks for camera and microphone permission. final stream = await navigator.mediaDevices.getUserMedia({ @@ -88,7 +75,6 @@ class _RoomScreenState extends State { ); _setStatus('Connected'); await Future.wait([_publishCamera(), _publishMicrophone()]); - await _client.createDataChannels(); } on Object catch (e) { _setStatus('Error: $e'); } @@ -142,8 +128,6 @@ class _RoomScreenState extends State { _setStatus('Reconnecting…'); case Reconnected(): _setStatus('Connected'); - // Data channels belong to the session: create them again. - unawaited(_client.createDataChannels().catchError((Object _) {})); case Disconnected(:final reason) when reason != DisconnectReason.left: _setStatus('Disconnected: ${reason.name}'); default: @@ -178,28 +162,6 @@ class _RoomScreenState extends State { await previous?.renderer.dispose(); } - void _onChat(Uint8List data) { - try { - final json = jsonDecode(utf8.decode(data)) as Map; - setState( - () => _messages.add(_ChatMessage('${json['from']}', '${json['text']}')), - ); - } on Object { - // Not a chat message. - } - } - - void _sendChat() { - final text = _chatInput.text.trim(); - if (text.isEmpty || !_client.dataChannelsReady) return; - _client.publishData( - utf8.encode(jsonEncode({'from': widget.displayName, 'text': text})), - reliable: true, - ); - _chatInput.clear(); - setState(() => _messages.add(_ChatMessage(widget.displayName, text))); - } - /// Muting replaces the sent media with nothing; unmuting puts it back. /// `replaceTrack(id, null)` only stops sending: the camera keeps capturing /// (and the local preview keeps running). Set `track.enabled = false` or @@ -278,7 +240,6 @@ class _RoomScreenState extends State { } unawaited(stream.dispose()); } - _chatInput.dispose(); super.dispose(); } @@ -308,7 +269,6 @@ class _RoomScreenState extends State { children: tiles, ), ), - SizedBox(height: 180, child: _chat()), _controls(), ], ), @@ -316,45 +276,6 @@ class _RoomScreenState extends State { ); } - Widget _chat() => Column( - children: [ - Expanded( - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 12), - children: [ - for (final m in _messages) - Text.rich( - TextSpan( - children: [ - TextSpan( - text: '${m.from}: ', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - TextSpan(text: m.text), - ], - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _chatInput, - decoration: const InputDecoration(hintText: 'Message'), - onSubmitted: (_) => _sendChat(), - ), - ), - IconButton(onPressed: _sendChat, icon: const Icon(Icons.send)), - ], - ), - ), - ], - ); - Widget _controls() => Padding( padding: const EdgeInsets.all(8), child: Row( From 4ff97efec905b83f6717a41a6cba0ec4fab41221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Fri, 23 Oct 2026 11:40:38 +0200 Subject: [PATCH 14/29] Test that orphans of every kind are stopped before negotiated transceivers Claude-Session: https://claude.ai/code/session_01PD5D7HqJhZwwEpL7KszKzZ --- test/engine/rtc_engine_test.dart | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/engine/rtc_engine_test.dart b/test/engine/rtc_engine_test.dart index 221d262..20eb684 100644 --- a/test/engine/rtc_engine_test.dart +++ b/test/engine/rtc_engine_test.dart @@ -536,6 +536,29 @@ void main() { expect(await liveRecv(h), isEmpty); }); + test( + 'orphans of every kind are stopped before any negotiated one', + () async { + final (h, _) = await _joined(); + await h.offerData(video: 1); + await h.answer(); + h.pc.failNext('setLocalDescription', StateError('failed')); + await h.offerData(video: 1, audio: 1); + final states = h.pc.transceiverStates; + expect(states.map((t) => (t.kind, t.mid)), [ + (MediaKind.video, '0'), + (MediaKind.audio, null), + ]); + + await h.offerData(); + expect(h.pc.callsOf('stopTransceiver').map((c) => c.args), [ + {'mid': null, 'kind': 'audio'}, + {'mid': '0', 'kind': 'video'}, + ]); + expect(states.every((t) => t.isStopped), isTrue); + }, + ); + test('stopped transceivers are not counted', () async { final (h, _) = await _joined(); await h.offerData(audio: 1); From 9e858db84f6facdd4cc49b9f3475e170d66d1ca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Fri, 23 Oct 2026 11:40:38 +0200 Subject: [PATCH 15/29] Simplify the reconnection code and its tests Order the excess receive transceivers with two filters instead of a sort, build the network loss reason from the change name, and share the repeated reconnect sequence and offline config in the tests. Claude-Session: https://claude.ai/code/session_01PD5D7HqJhZwwEpL7KszKzZ --- lib/src/engine/receive_transceivers.dart | 10 ++++---- lib/src/fishjam_client.dart | 8 +------ test/client/fishjam_client_test.dart | 30 ++++++++++-------------- test/signaling/reconnect_test.dart | 20 +++++++--------- 4 files changed, 26 insertions(+), 42 deletions(-) diff --git a/lib/src/engine/receive_transceivers.dart b/lib/src/engine/receive_transceivers.dart index f3d1ee6..5f723d2 100644 --- a/lib/src/engine/receive_transceivers.dart +++ b/lib/src/engine/receive_transceivers.dart @@ -63,12 +63,10 @@ class ReceiveTransceivers { for (final id in ordered.take(excess)) (kind, id, snapshot[id]!), ]); } - victims.sort((a, b) { - final aNew = a.$3.mid == null ? 0 : 1; - final bNew = b.$3.mid == null ? 0 : 1; - return aNew - bNew; - }); - for (final (kind, id, transceiver) in victims) { + for (final (kind, id, transceiver) in [ + ...victims.where((v) => v.$3.mid == null), + ...victims.where((v) => v.$3.mid != null), + ]) { await transceiver.stop(); _live[kind]!.remove(id); } diff --git a/lib/src/fishjam_client.dart b/lib/src/fishjam_client.dart index 6d241b5..fb2de9c 100644 --- a/lib/src/fishjam_client.dart +++ b/lib/src/fishjam_client.dart @@ -442,13 +442,7 @@ class FishjamClient { if (_status == ConnectionStatus.joined && change != NetworkChange.available) { unawaited( - _onRetryableLoss( - RetryableClose( - reason: change == NetworkChange.lost - ? 'network lost' - : 'network changed', - ), - ), + _onRetryableLoss(RetryableClose(reason: 'network ${change.name}')), ); } if (change == NetworkChange.lost) { diff --git a/test/client/fishjam_client_test.dart b/test/client/fishjam_client_test.dart index 2823ad0..f1b9592 100644 --- a/test/client/fishjam_client_test.dart +++ b/test/client/fishjam_client_test.dart @@ -385,6 +385,16 @@ void main() { }); group('reconnection (SPEC §6)', () { + /// Loses the session; the next attempt joins and restores the tracks. + Future reconnectAndRestore() async { + h.transport.serverClose(code: 1011, reason: 'boom'); + await _wait(40); + await h.authenticate(); + await h.connectedEvent(); + await h.negotiate(); + await settle(); + } + test('a peer connection failure replaces the session', () async { h = ClientHarness(reconnect: _fast); await h.join(); @@ -484,18 +494,9 @@ void main() { final cam = await h.publish('video'); h.events.clear(); - Future reconnect() async { - h.transport.serverClose(code: 1011, reason: 'boom'); - await _wait(40); - await h.authenticate(); - await h.connectedEvent(); - await h.negotiate(); - await settle(); - } - - await reconnect(); + await reconnectAndRestore(); final cam2 = h.eventsOf().single.track.trackId; - await reconnect(); + await reconnectAndRestore(); expect(h.eventsOf().map((e) => e.trackId), [ cam, @@ -515,12 +516,7 @@ void main() { final renegotiations = h.sentOf(_C.renegotiateTracks).length; h.events.clear(); - h.transport.serverClose(code: 1011, reason: 'boom'); - await _wait(40); - await h.authenticate(); - await h.connectedEvent(); - await h.negotiate(); - await settle(); + await reconnectAndRestore(); expect(h.sentOf(_C.renegotiateTracks), hasLength(renegotiations + 1)); final offer = h.sentOf(_C.sdpOffer).last.sdpOffer; diff --git a/test/signaling/reconnect_test.dart b/test/signaling/reconnect_test.dart index 1c99091..376498e 100644 --- a/test/signaling/reconnect_test.dart +++ b/test/signaling/reconnect_test.dart @@ -547,6 +547,8 @@ void main() { }); group('network changes (SPEC §6.1)', () { + const offline10s = ReconnectConfig(offlineTimeout: Duration(seconds: 10)); + test('losing the network cancels the scheduled attempt; while offline ' 'nothing starts and a loss waits instead of using an attempt', () { fakeAsync((async) { @@ -719,12 +721,9 @@ void main() { test('offline for offlineTimeout fails the reconnection', () { fakeAsync((async) { - final m = - manager( - const ReconnectConfig(offlineTimeout: Duration(seconds: 10)), - ) - ..onRetryableLoss(_loss, snapshot()) - ..onNetworkLost(); + final m = manager(offline10s) + ..onRetryableLoss(_loss, snapshot()) + ..onNetworkLost(); async.elapse(const Duration(milliseconds: 9999)); expect(m.isReconnecting, isTrue); @@ -740,12 +739,9 @@ void main() { test('the network back stops the offline timeout', () { fakeAsync((async) { - final m = - manager( - const ReconnectConfig(offlineTimeout: Duration(seconds: 10)), - ) - ..onRetryableLoss(_loss, snapshot()) - ..onNetworkLost(); + final m = manager(offline10s) + ..onRetryableLoss(_loss, snapshot()) + ..onNetworkLost(); async.elapse(const Duration(seconds: 5)); m.onNetworkAvailable(since: clock.now()); async.elapse(const Duration(seconds: 30)); From 5a0564ed984eaa69b7a88ff37789592ad5d3f72e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Fri, 23 Oct 2026 12:27:51 +0200 Subject: [PATCH 16/29] Report the remote endpoints of a replaced attempt gone before the next Joined An attempt that joined while offline, or that was still restoring its tracks when a new network appeared, was replaced without a loss of its own, so the peers its Joined announced were never reported gone: an app kept a peer that had left meanwhile. Every path that gives a session up now goes through one place that records them (D22). Claude-Session: https://claude.ai/code/session_01DKNRwERcfKW2FtCEbRUjZA --- SPEC.md | 3 ++ lib/src/fishjam_client.dart | 26 +++++++--- test/client/fishjam_client_test.dart | 74 ++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/SPEC.md b/SPEC.md index 7dc32a4..69fb9f1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -194,6 +194,9 @@ States: `idle`, `reconnecting`, `failed`. of its events are forwarded and its close or failure is ignored. App operations that need the session (track operations, metadata updates, `setTargetTrackEncoding`, `createDataChannels()`) fail at once with a `StateError` naming the reconnecting state; nothing is queued for later. +- An attempt replaced after its `Joined` without a loss of its own (it joined while offline, or a new network + appeared while its tracks were restored, [§6.1](#61-network-changes)) counts as lost too: the remote + endpoints it announced are reported gone before the next `Joined`. - Every local track id of the lost session ends with `LocalTrackRemoved`: the tracks went away with the session. A re-added track gets a **new** track id; `LocalTrackAdded` is emitted for it after the removals. If an attempt that already re-added tracks is lost before `Reconnected`, those new ids are reported diff --git a/lib/src/fishjam_client.dart b/lib/src/fishjam_client.dart index fb2de9c..ae5c346 100644 --- a/lib/src/fishjam_client.dart +++ b/lib/src/fishjam_client.dart @@ -459,14 +459,7 @@ class FishjamClient { tracks: [for (final t in session.localTracks.tracks) t.toContext()], ); if (_reconnect.onRetryableLoss(reason, snapshot)) { - // From here on the lost session reports nothing. - session.isLost = true; - if (session.isJoined) { - _staleRemote = ( - peers: session.remote.peers, - components: session.remote.components, - ); - } + _abandon(session); _status = ConnectionStatus.reconnecting; _stats.stop(); } else if (_reconnect.state == ReconnectState.failed) { @@ -476,6 +469,20 @@ class FishjamClient { } } + /// From here on [session] reports nothing. Every path that replaces a + /// session comes through here, so the remote endpoints it announced are + /// reported gone before the next `Joined` (D22). + void _abandon(_Session session) { + if (session.isLost) return; + session.isLost = true; + if (session.isJoined) { + _staleRemote = ( + peers: session.remote.peers, + components: session.remote.components, + ); + } + } + Future _leave(DisconnectReason reason) async { if (_session == null) return; _reconnect.reset(); @@ -634,6 +641,9 @@ class _Host implements ReconnectHost { @override Future startSession(Object? peerMetadata) async { final old = _client._session; + // Without a reported loss (an attempt replaced after its `Joined`) this is + // where it is given up. + if (old != null) _client._abandon(old); // The new session becomes current first, so the old one is torn down // silently (no `Disconnected`) and its late reports are ignored. final session = _client._newSession()..isReconnectAttempt = true; diff --git a/test/client/fishjam_client_test.dart b/test/client/fishjam_client_test.dart index f1b9592..0f3d784 100644 --- a/test/client/fishjam_client_test.dart +++ b/test/client/fishjam_client_test.dart @@ -848,6 +848,80 @@ void main() { expect(h.client.status, ConnectionStatus.disconnected); }); + group('an attempt replaced after its Joined has its remote endpoints ' + 'reported gone before the next Joined (D22)', () { + final bob = server.MediaEvent_Endpoint( + endpointType: 'exwebrtc', + metadataJson: '{"peer":{"name":"bob"},"server":{}}', + trackIdToTrack: { + 'bob:cam': server.MediaEvent_Track( + metadataJson: '{"type":"camera","paused":false}', + ), + }.entries, + ); + + List remoteEvents() => [ + for (final e in h.events) + switch (e) { + TrackRemoved(:final track) => 'TrackRemoved ${track.trackId}', + PeerLeft(:final peer) => 'PeerLeft ${peer.id}', + Joined(:final peers) => 'Joined ${[for (final p in peers) p.id]}', + _ => null, + }, + ].nonNulls.toList(); + + test('one that joined while offline', () async { + h = ClientHarness(reconnect: slow); + await h.join(); + h.events.clear(); + h.network.report({'cellular'}); + await _wait(10); + h.network.report({}); + await _wait(10); + await h.authenticate(); + await h.connectedEvent(endpoints: {'bob': bob}); + h.network.report({'wifi'}); + await _wait(10); + expect(h.transports.created, hasLength(3)); + await h.authenticate(); + await h.connectedEvent(); + await settle(); + + expect(remoteEvents(), [ + 'Joined [bob]', + 'TrackRemoved bob:cam', + 'PeerLeft bob', + 'Joined []', + ]); + expect(h.eventsOf(), hasLength(1)); + }); + + test('one still restoring its tracks', () async { + h = ClientHarness(reconnect: _fastRestore); + await h.join(); + await h.publish('audio'); + h.events.clear(); + h.network.report({'cellular'}); + await _wait(10); + await h.authenticate(); + // No negotiation follows, so the restore stays pending. + await h.connectedEvent(endpoints: {'bob': bob}); + h.network.report({'wifi'}); + await _wait(10); + expect(h.transports.created, hasLength(3)); + await h.authenticate(); + await h.connectedEvent(); + await settle(); + + expect(remoteEvents(), [ + 'Joined [bob]', + 'TrackRemoved bob:cam', + 'PeerLeft bob', + 'Joined []', + ]); + }); + }); + test('a report equal to the start value changes nothing', () async { h = ClientHarness(reconnect: _fast); await h.join(); From e8ae05db509e497a7be1a8adbb13404376315833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Fri, 23 Oct 2026 13:24:37 +0200 Subject: [PATCH 17/29] Restore only announced local tracks; let connect() retry from ReconnectionFailed A track is registered as soon as its addTrack starts, so a reconnection during that negotiation snapshotted it: the failed addTrack's id was reported removed although the app never got it, and the track was published again although the app was told it failed. The same happened to a track an abandoned attempt negotiated without its LocalTrackAdded reaching the app. The client now records the ids it announced and snapshots and removes only those. The status turns disconnected before the given-up session is torn down, so an app could call connect() from a ReconnectionFailed listener; the teardown then failed that new call with "disconnected (reconnectionFailed)". The pending connect() is now taken before the teardown, so only the call that started the session is settled by it. Claude-Session: https://claude.ai/code/session_01DKNRwERcfKW2FtCEbRUjZA --- SPEC.md | 9 ++- lib/src/fishjam_client.dart | 28 ++++++- test/client/fishjam_client_test.dart | 112 +++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 5 deletions(-) diff --git a/SPEC.md b/SPEC.md index 69fb9f1..1c92789 100644 --- a/SPEC.md +++ b/SPEC.md @@ -109,7 +109,10 @@ idle ──connect()──▶ connecting ──authenticated──▶ authentica - join close reason → `JoinException(reason)` (and `JoinFailed` is emitted), - any other close or socket failure before `Joined` → `ConnectionException(code, reason)`. Reconnection is **not** attempted for failures before the first `Joined`. -6. Calling `connect()` while not `idle`/`disconnected` throws `StateError`. +6. Calling `connect()` while not `idle`/`disconnected` throws `StateError`. The status is `disconnected` + from the moment a session is given up, so `connect()` may be called from a listener of an event emitted + then (e.g. `ReconnectionFailed`); the teardown of the given-up session settles only the `connect()` call + that started it. ### 3.3 `leave()` @@ -201,6 +204,10 @@ States: `idle`, `reconnecting`, `failed`. session. A re-added track gets a **new** track id; `LocalTrackAdded` is emitted for it after the removals. If an attempt that already re-added tracks is lost before `Reconnected`, those new ids are reported removed when the next attempt starts. +- Only tracks the app got `LocalTrackAdded` for (and no `LocalTrackRemoved` yet) are snapshotted, reported + removed and re-added. A track whose `addTrack` was still negotiating when the session was lost is not: its + `addTrack` fails, so the app never learned its id and does not expect it published. Neither is a track an + abandoned attempt negotiated without its `LocalTrackAdded` reaching the app. - A **muted** track (no media track) is not re-added: there is nothing to publish. The app adds the track again when it unmutes. diff --git a/lib/src/fishjam_client.dart b/lib/src/fishjam_client.dart index ae5c346..cf15d80 100644 --- a/lib/src/fishjam_client.dart +++ b/lib/src/fishjam_client.dart @@ -21,7 +21,6 @@ import 'package:fishjam_client/src/signaling/reconnect.dart'; import 'package:fishjam_client/src/signaling/signaling_session.dart'; import 'package:fishjam_client/src/signaling/transport.dart'; import 'package:fishjam_client/src/stats/stats_reporter.dart'; -import 'package:fishjam_client/src/tracks/local_track.dart'; import 'package:fishjam_client/src/tracks/local_track_registry.dart'; import 'package:fishjam_client/src/tracks/remote_track.dart'; import 'package:fishjam_client/src/version.dart'; @@ -132,6 +131,12 @@ class FishjamClient { /// by a session that was lost; reported gone before the next `Joined`. ({List peers, List components})? _staleRemote; + /// Local track ids the app got `LocalTrackAdded` for and no + /// `LocalTrackRemoved` yet. A track is registered before its negotiation + /// ends, and an abandoned session's events are not forwarded, so only these + /// are reported removed or restored on a reconnection. + final _announcedLocalTracks = {}; + /// All client events (broadcast, not replayed). Stream get events => _events.stream; @@ -158,6 +163,7 @@ class FishjamClient { _token = token; _reconnect.reset(); _staleRemote = null; + _announcedLocalTracks.clear(); _status = ConnectionStatus.connecting; unawaited(_network?.start()); final connecting = _connecting = Completer(); @@ -456,7 +462,10 @@ class FishjamClient { final session = _session!; final snapshot = ReconnectSnapshot( peerMetadata: session.localTracks.peerMetadata, - tracks: [for (final t in session.localTracks.tracks) t.toContext()], + tracks: [ + for (final t in session.localTracks.tracks) + if (_announcedLocalTracks.contains(t.trackId)) t.toContext(), + ], ); if (_reconnect.onRetryableLoss(reason, snapshot)) { _abandon(session); @@ -502,11 +511,15 @@ class FishjamClient { _session = null; _staleRemote = null; _status = ConnectionStatus.disconnected; + // Taken now: the app may call connect() again while the teardown runs + // (e.g. from a `ReconnectionFailed` listener), and that call is not ours + // to fail. + final connecting = _connecting; + _connecting = null; unawaited(_network?.stop()); await _teardown(session, sendDisconnect: sendDisconnect); if (error != null) _emit(error); _emit(disconnected); - final connecting = _connecting; if (connecting != null && !connecting.isCompleted) { connecting.completeError( connectError ?? @@ -598,6 +611,13 @@ class FishjamClient { } void _emit(FishjamEvent event) { + switch (event) { + case LocalTrackAdded(:final track): + _announcedLocalTracks.add(track.trackId); + case LocalTrackRemoved(:final trackId): + _announcedLocalTracks.remove(trackId); + case _: + } if (!_events.isClosed) _events.add(event); } @@ -652,7 +672,7 @@ class _Host implements ReconnectHost { // `LocalTrackAdded`; they go away with that session. if (old.isReconnectAttempt) { for (final track in old.localTracks.tracks) { - if (track.negotiationStatus == TrackNegotiationStatus.negotiated) { + if (_client._announcedLocalTracks.contains(track.trackId)) { _client._emit(LocalTrackRemoved(track.trackId)); } } diff --git a/test/client/fishjam_client_test.dart b/test/client/fishjam_client_test.dart index 0f3d784..215f705 100644 --- a/test/client/fishjam_client_test.dart +++ b/test/client/fishjam_client_test.dart @@ -922,6 +922,118 @@ void main() { }); }); + group('only local tracks the app was told about are reported removed and ' + 'restored', () { + List localEvents() => [ + for (final e in h.events) + switch (e) { + LocalTrackAdded(:final track) => 'added ${track.trackId}', + LocalTrackRemoved(:final trackId) => 'removed $trackId', + Reconnected() => 'Reconnected', + _ => null, + }, + ].nonNulls.toList(); + + test('a track still being added when the network changes', () async { + h = ClientHarness(reconnect: _fastRestore); + await h.join(); + final adding = h.client.addTrack(h.track('audio')); + await settle(); + h.network.report({'cellular'}); + await expectLater(adding, throwsStateError); + await _wait(10); + expect(h.transports.created, hasLength(2)); + await h.authenticate(); + await h.connectedEvent(); + await settle(); + + expect(localEvents(), ['Reconnected']); + }); + + test('a track restored by an attempt that failed before announcing ' + 'it', () async { + h = ClientHarness(reconnect: _fastRestore); + await h.join(); + final first = await h.publish('audio'); + h.events.clear(); + h.transport.serverClose(code: 1011, reason: 'boom'); + await _wait(40); + await h.authenticate(); + await h.connectedEvent(); + await h.receive( + server.MediaEvent( + offerData: server.MediaEvent_OfferData( + tracksTypes: server.MediaEvent_OfferData_TrackTypes(), + ), + ), + ); + final offer = h.sentOf(_C.sdpOffer).last.sdpOffer; + final attempt = h.transport; + h.pc.setConnectionState(PeerConnectionState.failed); + await settle(); + attempt.receive( + PeerMessage( + serverMediaEvent: server.MediaEvent( + sdpAnswer: server.MediaEvent_SdpAnswer( + sdp: 'v=0\r\ns=fake-answer\r\n', + midToTrackId: offer.midToTrackId.entries, + ), + ), + ), + ); + await _wait(40); + await h.authenticate(); + await h.connectedEvent(); + await h.negotiate(); + await settle(); + + final events = localEvents(); + expect(events.first, 'removed $first'); + final added = { + for (final e in events) + if (e.startsWith('added ')) e.substring(6), + }; + final removed = [ + for (final e in events.skip(1)) + if (e.startsWith('removed ')) e.substring(8), + ]; + expect(removed.where((id) => !added.contains(id)), isEmpty); + expect(events.last, 'Reconnected'); + }); + }); + + test('connect() from a ReconnectionFailed listener is not failed by the ' + 'teardown of the given-up session', () async { + h = ClientHarness( + reconnect: const ReconnectConfig( + offlineTimeout: Duration(milliseconds: 50), + ), + ); + await h.join(); + Object? retryError; + var retried = false; + final retry = h.client.events.listen((e) { + if (e is! ReconnectionFailed) return; + retried = true; + unawaited( + h.client + .connect(url: 'ws://fishjam.test', token: 'peer-token') + .then((_) {}, onError: (Object e) => retryError = e), + ); + }); + addTearDown(retry.cancel); + h.network.report({}); + await _wait(100); + expect(retried, isTrue); + expect(retryError, isNull); + await h.authenticate(); + await h.connectedEvent(); + await settle(); + + expect(retryError, isNull); + expect(h.client.status, ConnectionStatus.joined); + }); + test('a report equal to the start value changes nothing', () async { h = ClientHarness(reconnect: _fast); await h.join(); From a1ee8f55136790617ab19c53efccaa0b53a58ee8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Fri, 23 Oct 2026 13:32:50 +0200 Subject: [PATCH 18/29] Act on every network report at once Waiting 500 ms for reports to settle never sped up a recovery on real devices: a network reported after offline was already acted on at once, no attempt starts while offline, and a Wi-Fi to Wi-Fi switch is either reported as offline then online or not reported at all. The wait only delayed noticing an offline and a network that went away next to another. Each report is now compared with the previous one as it arrives. Claude-Session: https://claude.ai/code/session_01Q5vZKmaVuaPpgoRaUvLQT8 --- SPEC.md | 17 ++--- lib/src/fishjam_client.dart | 2 - lib/src/signaling/network_monitor.dart | 54 ++------------ test/client/client_harness.dart | 7 +- test/client/fishjam_client_test.dart | 5 +- test/signaling/network_watcher_test.dart | 95 +++++------------------- 6 files changed, 37 insertions(+), 143 deletions(-) diff --git a/SPEC.md b/SPEC.md index 1c92789..2369798 100644 --- a/SPEC.md +++ b/SPEC.md @@ -219,19 +219,18 @@ the session will not survive; reacting at once avoids waiting for a pong timeout - Watching starts at `connect()` and stops when the client is disconnected. The first value read is the baseline. **Online** means at least one network is reported; **offline** means none. -- A report is acted on once the reports have been stable for 500 ms (the **settle** time), so the brief - "offline" some platforms report while switching networks is not a loss of its own. Each change carries the - time its first report arrived (when the change **began**). -- Exception: a network reported after offline (settled, or still settling) is acted on at once. The change is - then certain to be `available` or `changed`, and waiting would only delay the new session. Later reports - settle against it as usual. -- Kinds of change, comparing the settled set with the previous one: +- Every report is acted on when it arrives, compared with the previous report; each change carries the time + its report arrived (when the change **began**). The brief "offline" some platforms report while switching + networks is not waited out: no attempt starts while offline, and the network reported after it starts one at + once. +- Kinds of change: - **lost**: now offline; - **available**: online after being offline; - - **changed**: a network went away (Wi-Fi → cellular, a VPN going down), or the same set was reached through - offline (another Wi-Fi network looks the same). + - **changed**: a network went away while another remains (Wi-Fi → cellular, a VPN going down). - Only gaining a network (cellular added next to Wi-Fi, a VPN coming up) and a repeat of the same set are no change: the connection keeps its path. +- A switch between two Wi-Fi networks looks like the same set: some platforms report it as offline and then + online (`lost`, then `available`), others not at all, and then the socket or ICE reports the loss. | Status | Change | Action | |---|---|---| diff --git a/lib/src/fishjam_client.dart b/lib/src/fishjam_client.dart index cf15d80..627f53f 100644 --- a/lib/src/fishjam_client.dart +++ b/lib/src/fishjam_client.dart @@ -68,7 +68,6 @@ class FishjamClient { RtcAdapter? rtcAdapter, this._transportFactory, NetworkMonitor? networkMonitor, - Duration networkSettle = const Duration(milliseconds: 500), }) : _adapter = rtcAdapter ?? const FlutterWebrtcAdapter(), _logger = FishjamLogger(enabled: debug) { _reconnect = ReconnectManager(config: reconnect, host: _Host(this)); @@ -77,7 +76,6 @@ class FishjamClient { monitor: networkMonitor, logger: _logger, onChanged: _onNetworkChanged, - settle: networkSettle, ); } _dataChannels = DataChannelManager( diff --git a/lib/src/signaling/network_monitor.dart b/lib/src/signaling/network_monitor.dart index 1cd93c5..48265df 100644 --- a/lib/src/signaling/network_monitor.dart +++ b/lib/src/signaling/network_monitor.dart @@ -21,44 +21,33 @@ enum NetworkChange { /// The device is online again after being offline. available, - /// A network went away, or the same networks were reached through offline. + /// A network went away while another remains. changed, } /// Turns [NetworkMonitor] reports into [NetworkChange]s (SPEC §6.1): the -/// first value is the baseline, and reports are acted on once they have been -/// stable for [settle], except a network reported after offline, which is -/// acted on at once; each change carries the time its first report arrived. +/// first value is the baseline, and every report is compared with the one +/// before it as it arrives; each change carries the time of its report. /// When the OS cannot be asked, it logs a warning and reports nothing. class NetworkWatcher { NetworkWatcher({ required this._monitor, required this._logger, required this._onChanged, - this.settle = const Duration(milliseconds: 500), }); final NetworkMonitor _monitor; final FishjamLogger _logger; final void Function(NetworkChange change, DateTime began) _onChanged; - final Duration settle; StreamSubscription>? _subscription; /// Bumped by [stop], so a [start] still reading the start value gives up. int _runs = 0; - /// The last settled networks; `null` before the baseline. + /// The networks of the last report; `null` before the baseline. Set? _last; - Timer? _settling; - - /// When the first report of the change still settling arrived. - DateTime? _began; - - /// Whether the change still settling went through offline. - bool _wentOffline = false; - /// Starts watching with a new baseline (stops a previous watch first). Future start() async { await stop(); @@ -89,47 +78,18 @@ class NetworkWatcher { final cancelled = _subscription?.cancel(); _subscription = null; _last = null; - _settling?.cancel(); - _settling = null; - _began = null; - _wentOffline = false; await cancelled; } void _onReport(Set networks) { - // Platforms report a short "offline" while they switch the default - // network; acting on it would cost a reconnection of its own. - _began ??= clock.now(); - if (networks.isEmpty) _wentOffline = true; - _settling?.cancel(); - if (networks.isNotEmpty && (_wentOffline || _last!.isEmpty)) { - // A network after offline can only mean a new session is needed; - // settling would only delay it. - _settled(networks); - return; - } - _settling = Timer(settle, () => _settled(networks)); - } - - void _settled(Set networks) { - final began = _began!; - final change = _classify(_last!, networks, throughOffline: _wentOffline); + final change = _classify(_last!, networks); _last = networks; - _settling = null; - _began = null; - _wentOffline = false; - if (change != null) _onChanged(change, began); + if (change != null) _onChanged(change, clock.now()); } - static NetworkChange? _classify( - Set last, - Set now, { - required bool throughOffline, - }) { + static NetworkChange? _classify(Set last, Set now) { if (now.isEmpty) return last.isEmpty ? null : NetworkChange.lost; if (last.isEmpty) return NetworkChange.available; - // Another Wi-Fi network reports the same set as the one that was lost. - if (throughOffline) return NetworkChange.changed; // Only losing a network can break the connection's path; gaining one // (cellular next to Wi-Fi, a VPN coming up) leaves it in place. return now.containsAll(last) ? null : NetworkChange.changed; diff --git a/test/client/client_harness.dart b/test/client/client_harness.dart index 5006d17..6dc65a1 100644 --- a/test/client/client_harness.dart +++ b/test/client/client_harness.dart @@ -23,16 +23,13 @@ const localPeerId = 'local-peer'; /// One client plus its fakes. class ClientHarness { /// Creates the client; nothing is connected yet. - ClientHarness({ - ReconnectConfig reconnect = const ReconnectConfig(), - Duration networkSettle = Duration.zero, - }) : rtc = FakeRtcAdapter() { + ClientHarness({ReconnectConfig reconnect = const ReconnectConfig()}) + : rtc = FakeRtcAdapter() { client = FishjamClient.internal( reconnect: reconnect, rtcAdapter: rtc, transportFactory: transports.call, networkMonitor: network, - networkSettle: networkSettle, ); _subscription = client.events.listen(events.add); } diff --git a/test/client/fishjam_client_test.dart b/test/client/fishjam_client_test.dart index 215f705..f12b243 100644 --- a/test/client/fishjam_client_test.dart +++ b/test/client/fishjam_client_test.dart @@ -818,10 +818,7 @@ void main() { }); test('a brief offline while switching networks is one change', () async { - h = ClientHarness( - reconnect: slow, - networkSettle: const Duration(milliseconds: 50), - ); + h = ClientHarness(reconnect: slow); await h.join(); h.network.report({}); await _wait(10); diff --git a/test/signaling/network_watcher_test.dart b/test/signaling/network_watcher_test.dart index 39d79e0..08bd793 100644 --- a/test/signaling/network_watcher_test.dart +++ b/test/signaling/network_watcher_test.dart @@ -8,8 +8,6 @@ import 'package:flutter_test/flutter_test.dart'; import '../fakes/fake_network.dart'; -const _settle = Duration(milliseconds: 500); - void main() { late FakeNetworkMonitor monitor; late List<(NetworkChange, DateTime)> changes; @@ -43,108 +41,55 @@ void main() { test('the networks in use at start are the baseline, not a change', () { run((async) { monitor.report({'wifi'}); - async.elapse(_settle * 2); + async.flushMicrotasks(); expect(changes, isEmpty); }); }); - test('a change is reported once the reports have been stable for 500 ms, ' - 'with the time its first report arrived', () { - run((async) { - final began = clock.now(); - monitor.report({'cellular'}); - async.elapse(_settle - const Duration(milliseconds: 1)); - expect(changes, isEmpty); - - async.elapse(const Duration(milliseconds: 1)); - expect(changes, [(NetworkChange.changed, began)]); - }); - }); - - test('offline is lost; online after offline is available', () { - run((async) { - monitor.report({}); - async.elapse(_settle); - monitor.report({'cellular'}); - async.elapse(_settle); - - expect(kinds(), [NetworkChange.lost, NetworkChange.available]); - }); - }); - - test('reports within the settle time are one change that began with the ' - 'first of them', () { + test('a report is acted on at once, at the time it arrived', () { run((async) { final began = clock.now(); - monitor.report({}); - async.elapse(const Duration(milliseconds: 200)); monitor.report({'cellular'}); - async.elapse(_settle); + async.flushMicrotasks(); expect(changes, [(NetworkChange.changed, began)]); }); }); - test('back to the same networks through offline is a change: another ' - 'Wi-Fi network looks the same', () { + test('offline is lost; online after offline is available, even when it ' + 'comes right after', () { run((async) { monitor ..report({}) ..report({'wifi'}); - async.elapse(_settle); - - expect(kinds(), [NetworkChange.changed]); - }); - }); - - test('a network appearing after a settled offline is available at once', () { - run((async) { - monitor.report({}); - async.elapse(_settle); - final began = clock.now(); - monitor.report({'cellular'}); async.flushMicrotasks(); - expect(changes.last, (NetworkChange.available, began)); - }); - }); - - test('a network appearing while offline is still settling is a change at ' - 'once, that began with the offline report', () { - run((async) { - final began = clock.now(); - monitor.report({}); - async.elapse(const Duration(milliseconds: 200)); - monitor.report({'wifi'}); - async.flushMicrotasks(); - - expect(changes, [(NetworkChange.changed, began)]); + expect(kinds(), [NetworkChange.lost, NetworkChange.available]); }); }); - test('reports right after a network appeared settle against it', () { + test('a repeated report is no change', () { run((async) { monitor + ..report({}) ..report({}) ..report({'wifi'}) - ..report({'wifi', 'cellular'}); - async.elapse(_settle); + ..report({'wifi'}); + async.flushMicrotasks(); - expect(kinds(), [NetworkChange.changed]); + expect(kinds(), [NetworkChange.lost, NetworkChange.available]); }); }); test('only gaining a network is no change; losing one later is', () { run((async) { monitor.report({'wifi', 'cellular'}); - async.elapse(_settle); - monitor.report({'wifi', 'cellular'}); - async.elapse(_settle); + async.flushMicrotasks(); expect(changes, isEmpty); monitor.report({'cellular'}); - async.elapse(_settle); + async.flushMicrotasks(); expect(kinds(), [NetworkChange.changed]); }); }); @@ -161,7 +106,7 @@ void main() { async.flushMicrotasks(); expect(monitor.isWatched, isTrue); monitor.report({'wifi'}); - async.elapse(_settle); + async.flushMicrotasks(); expect(changes, isEmpty); }); }); @@ -207,7 +152,7 @@ void main() { run((async) { expect(monitor.isWatched, isFalse); monitor.report({'cellular'}); - async.elapse(_settle); + async.flushMicrotasks(); expect(changes, isEmpty); expect(logs, [startsWith('warn')]); @@ -233,19 +178,17 @@ void main() { }); }); - test('stop ends watching and drops a change still settling; start after ' - 'stop takes a new baseline', () { + test('stop ends watching; start after stop takes a new baseline', () { run((async) { - monitor.report({'cellular'}); unawaited(watcher.stop()); - async.elapse(_settle); + async.flushMicrotasks(); expect(monitor.isWatched, isFalse); - expect(changes, isEmpty); + monitor.report({'cellular'}); unawaited(watcher.start()); async.flushMicrotasks(); monitor.report({'cellular'}); - async.elapse(_settle); + async.flushMicrotasks(); expect(changes, isEmpty); }); }); From 857b0d6780b622b96edf1cb22f89a8451e3fc737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Fri, 23 Oct 2026 22:19:17 +0200 Subject: [PATCH 19/29] Ping every 15 s again, as on main A network change and ICE disconnected for 3 s already end the session quickly; the client ping only has to catch a socket that dies on its own. A 5 s pong timeout turns a briefly congested link into a full reconnection. Claude-Session: https://claude.ai/code/session_01Xn8b3WbMz12ct7ugWKC7hC --- SPEC.md | 2 +- lib/src/signaling/transport.dart | 2 +- test/signaling/websocket_transport_test.dart | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/SPEC.md b/SPEC.md index eea9319..e20af53 100644 --- a/SPEC.md +++ b/SPEC.md @@ -36,7 +36,7 @@ Conventions: else becomes `https://fishjam.io/api/v1/connect/`. - Every frame in both directions is **binary** and contains exactly one `fishjam.PeerMessage`. - Text frames from the server are ignored (logged at debug level). -- The server pings every 30 s. The client additionally sends its own pings (interval 5 s) and treats a +- The server pings every 30 s. The client additionally sends its own pings (interval 15 s) and treats a missing pong within one interval as a connection loss (retryable, [§6](#6-reconnection)). - Opening the socket (connection, TLS and upgrade) times out after 10 s; a timeout fails like a socket that cannot be opened. diff --git a/lib/src/signaling/transport.dart b/lib/src/signaling/transport.dart index 83c786e..493641f 100644 --- a/lib/src/signaling/transport.dart +++ b/lib/src/signaling/transport.dart @@ -77,7 +77,7 @@ typedef SignalingTransportFactory = SignalingTransport Function(); final class WebSocketTransport implements SignalingTransport { /// Ignored text frames are logged at debug level with [logger]. WebSocketTransport({ - this.pingInterval = const Duration(seconds: 5), + this.pingInterval = const Duration(seconds: 15), this.connectTimeout = const Duration(seconds: 10), FishjamLogger? logger, }) : _logger = logger ?? FishjamLogger.disabled(); diff --git a/test/signaling/websocket_transport_test.dart b/test/signaling/websocket_transport_test.dart index be36c63..6fb5671 100644 --- a/test/signaling/websocket_transport_test.dart +++ b/test/signaling/websocket_transport_test.dart @@ -105,10 +105,10 @@ void main() { expect((await transport.onClose).error, isNotNull); }); - test('pings every 5 s and gives up opening a socket after 10 s', () { + test('pings every 15 s and gives up opening a socket after 10 s', () { final transport = WebSocketTransport(); - expect(transport.pingInterval, const Duration(seconds: 5)); + expect(transport.pingInterval, const Duration(seconds: 15)); expect(transport.connectTimeout, const Duration(seconds: 10)); }); From 4185790450b511be69d136253fd22c8730d5041f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Sun, 25 Oct 2026 01:05:40 +0200 Subject: [PATCH 20/29] Drop the time a network change began Since every network report is acted on at once, a change begins when it is handled, so no attempt in progress can have started after it: the check that kept such an attempt never held. A new network now always supersedes an attempt in progress, as it already did in practice. Claude-Session: https://claude.ai/code/session_01Xn8b3WbMz12ct7ugWKC7hC --- SPEC.md | 9 ++--- lib/src/fishjam_client.dart | 4 +- lib/src/signaling/network_monitor.dart | 7 ++-- lib/src/signaling/reconnect.dart | 31 +++++++-------- pubspec.lock | 2 +- pubspec.yaml | 1 - .../connectivity_missing_plugin_test.dart | 2 +- test/signaling/network_watcher_test.dart | 18 ++++----- test/signaling/reconnect_test.dart | 38 ++++++------------- 9 files changed, 42 insertions(+), 70 deletions(-) diff --git a/SPEC.md b/SPEC.md index e20af53..f1f5411 100644 --- a/SPEC.md +++ b/SPEC.md @@ -219,10 +219,9 @@ the session will not survive; reacting at once avoids waiting for a pong timeout - Watching starts at `connect()` and stops when the client is disconnected. The first value read is the baseline. **Online** means at least one network is reported; **offline** means none. -- Every report is acted on when it arrives, compared with the previous report; each change carries the time - its report arrived (when the change **began**). The brief "offline" some platforms report while switching - networks is not waited out: no attempt starts while offline, and the network reported after it starts one at - once. +- Every report is acted on when it arrives, compared with the previous report. The brief "offline" some + platforms report while switching networks is not waited out: no attempt starts while offline, and the network + reported after it starts one at once. - Kinds of change: - **lost**: now offline; - **available**: online after being offline; @@ -239,7 +238,7 @@ the session will not survive; reacting at once avoids waiting for a pong timeout | joined, reconnection disabled | any | nothing: the session ends only when its socket or peer connection does | | before the first `Joined` of a `connect()` | any | no loss (the attempt fails or succeeds on its own); whether the device is online is remembered | | reconnecting | lost | a scheduled attempt is cancelled; no attempt starts while offline, and a loss while offline waits for the network instead of using up an attempt | -| reconnecting | available or changed | the attempt budget starts over (`maxAttempts` on the new network). An attempt in progress that started after the change began is kept: it already runs on the new network. Otherwise a scheduled attempt is cancelled, an attempt in progress is abandoned (its session torn down silently) and a new attempt starts at once | +| reconnecting | available or changed | the attempt budget starts over (`maxAttempts` on the new network). A scheduled attempt is cancelled, an attempt in progress is abandoned (its session torn down silently) and a new attempt starts at once | | reconnecting | offline for `offlineTimeout` | as "no attempts left" (§6): `ReconnectionFailed`, then `Disconnected(reconnectionFailed)` | - A session of a reconnection that reaches `connected` while the device is offline, or goes offline before its diff --git a/lib/src/fishjam_client.dart b/lib/src/fishjam_client.dart index 4cadfc3..9434cdc 100644 --- a/lib/src/fishjam_client.dart +++ b/lib/src/fishjam_client.dart @@ -450,7 +450,7 @@ class FishjamClient { /// Losing the network, or the one in use, while joined is a loss: the /// socket and the peer connection are bound to the network that went away. - void _onNetworkChanged(NetworkChange change, DateTime began) { + void _onNetworkChanged(NetworkChange change) { _logger.debug('Network ${change.name}'); if (!reconnect.isEnabled) return; if (_status == ConnectionStatus.joined && @@ -462,7 +462,7 @@ class FishjamClient { if (change == NetworkChange.lost) { _reconnect.onNetworkLost(); } else { - _reconnect.onNetworkAvailable(since: began); + _reconnect.onNetworkAvailable(); } } diff --git a/lib/src/signaling/network_monitor.dart b/lib/src/signaling/network_monitor.dart index 48265df..11f75f4 100644 --- a/lib/src/signaling/network_monitor.dart +++ b/lib/src/signaling/network_monitor.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:clock/clock.dart'; import 'package:fishjam_client/src/logger.dart'; /// The device's networks as the OS reports them. Signaling depends only on @@ -27,7 +26,7 @@ enum NetworkChange { /// Turns [NetworkMonitor] reports into [NetworkChange]s (SPEC §6.1): the /// first value is the baseline, and every report is compared with the one -/// before it as it arrives; each change carries the time of its report. +/// before it as it arrives. /// When the OS cannot be asked, it logs a warning and reports nothing. class NetworkWatcher { NetworkWatcher({ @@ -38,7 +37,7 @@ class NetworkWatcher { final NetworkMonitor _monitor; final FishjamLogger _logger; - final void Function(NetworkChange change, DateTime began) _onChanged; + final void Function(NetworkChange change) _onChanged; StreamSubscription>? _subscription; @@ -84,7 +83,7 @@ class NetworkWatcher { void _onReport(Set networks) { final change = _classify(_last!, networks); _last = networks; - if (change != null) _onChanged(change, clock.now()); + if (change != null) _onChanged(change); } static NetworkChange? _classify(Set last, Set now) { diff --git a/lib/src/signaling/reconnect.dart b/lib/src/signaling/reconnect.dart index c3b648f..ea120bf 100644 --- a/lib/src/signaling/reconnect.dart +++ b/lib/src/signaling/reconnect.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'package:clock/clock.dart'; - import 'package:fishjam_client/src/models/config.dart'; import 'package:fishjam_client/src/models/errors.dart'; import 'package:fishjam_client/src/models/events.dart'; @@ -97,8 +95,8 @@ class ReconnectManager { /// a network that keeps changing restarts the budget, so this caps it. int _totalAttempts = 0; - /// When the attempt in progress started; `null` when none is in progress. - DateTime? _attemptStartedAt; + /// Whether an attempt has started and not yet been lost or joined. + bool _attemptInProgress = false; /// While offline no attempt starts, and a loss waits for the network /// instead of using up the budget. @@ -142,7 +140,7 @@ class ReconnectManager { case ReconnectState.reconnecting: if (_timer != null) return true; _generation++; - _attemptStartedAt = null; + _attemptInProgress = false; if (!_online) { _waitForNetwork(); return true; @@ -182,7 +180,7 @@ class ReconnectManager { if (!_online) { // The session got through just before the network went away; it will // not survive. - _attemptStartedAt = null; + _attemptInProgress = false; _waitForNetwork(); return; } @@ -206,7 +204,7 @@ class ReconnectManager { if (!_online) { // Nothing reports the loss of this session while it is not yet // joined, so it is abandoned like one that joined offline. - _attemptStartedAt = null; + _attemptInProgress = false; _waitForNetwork(); return; } @@ -225,21 +223,18 @@ class ReconnectManager { _waitForNetwork(); } - /// The device has a network again, or a different one; the change began - /// at [since]. While reconnecting, the attempt budget starts over (earlier - /// attempts failed on a network that is gone) and an attempt starts at - /// once, unless one that started after [since] is in progress: that one - /// already runs on the new network. - void onNetworkAvailable({required DateTime since}) { + /// The device has a network again, or a different one. While + /// reconnecting, the attempt budget starts over (earlier attempts failed on + /// a network that is gone) and a new attempt starts at once, superseding + /// any in progress. + void onNetworkAvailable() { _online = true; _cancelOfflineTimer(); if (_state != ReconnectState.reconnecting) return; _attempt = 0; - final startedAt = _attemptStartedAt; - if (startedAt != null && startedAt.isAfter(since)) return; if (_totalAttempts >= _maxTotalAttempts) { // An attempt still in progress decides; with none, nothing would. - if (startedAt == null && _timer == null) _giveUp(); + if (!_attemptInProgress && _timer == null) _giveUp(); return; } _cancelTimer(); @@ -282,7 +277,7 @@ class ReconnectManager { void _startAttempt(int attempt) { _attempt = attempt; _totalAttempts++; - _attemptStartedAt = clock.now(); + _attemptInProgress = true; final generation = ++_generation; unawaited( _host @@ -315,7 +310,7 @@ class ReconnectManager { _snapshot = null; _attempt = 0; _totalAttempts = 0; - _attemptStartedAt = null; + _attemptInProgress = false; if (release) { _host.releaseQueue(); } else { diff --git a/pubspec.lock b/pubspec.lock index 834cf2b..3d2f08e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -50,7 +50,7 @@ packages: source: hosted version: "1.4.1" clock: - dependency: "direct main" + dependency: transitive description: name: clock sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e diff --git a/pubspec.yaml b/pubspec.yaml index d2afa20..b6b4e8f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,7 +12,6 @@ environment: flutter: ">=3.47.0" dependencies: - clock: ^1.1.3 collection: ^1.19.0 connectivity_plus: ^7.3.2 flutter: diff --git a/test/client/connectivity_missing_plugin_test.dart b/test/client/connectivity_missing_plugin_test.dart index 1d4f0a7..b1e0673 100644 --- a/test/client/connectivity_missing_plugin_test.dart +++ b/test/client/connectivity_missing_plugin_test.dart @@ -15,7 +15,7 @@ void main() { sink: (level, message, {error, stackTrace}) => logs.add('${level.name}: $message'), ), - onChanged: (_, _) {}, + onChanged: (_) {}, ); // Platform channels answer in real time. diff --git a/test/signaling/network_watcher_test.dart b/test/signaling/network_watcher_test.dart index 08bd793..0b9111b 100644 --- a/test/signaling/network_watcher_test.dart +++ b/test/signaling/network_watcher_test.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:clock/clock.dart'; import 'package:fake_async/fake_async.dart'; import 'package:fishjam_client/src/logger.dart'; import 'package:fishjam_client/src/signaling/network_monitor.dart'; @@ -10,7 +9,7 @@ import '../fakes/fake_network.dart'; void main() { late FakeNetworkMonitor monitor; - late List<(NetworkChange, DateTime)> changes; + late List changes; late List logs; late NetworkWatcher watcher; @@ -25,7 +24,7 @@ void main() { sink: (level, message, {error, stackTrace}) => logs.add('${level.name}: $message'), ), - onChanged: (change, began) => changes.add((change, began)), + onChanged: changes.add, ); }); @@ -36,8 +35,6 @@ void main() { body(async); }); - List kinds() => [for (final (c, _) in changes) c]; - test('the networks in use at start are the baseline, not a change', () { run((async) { monitor.report({'wifi'}); @@ -47,13 +44,12 @@ void main() { }); }); - test('a report is acted on at once, at the time it arrived', () { + test('a report is acted on at once', () { run((async) { - final began = clock.now(); monitor.report({'cellular'}); async.flushMicrotasks(); - expect(changes, [(NetworkChange.changed, began)]); + expect(changes, [NetworkChange.changed]); }); }); @@ -65,7 +61,7 @@ void main() { ..report({'wifi'}); async.flushMicrotasks(); - expect(kinds(), [NetworkChange.lost, NetworkChange.available]); + expect(changes, [NetworkChange.lost, NetworkChange.available]); }); }); @@ -78,7 +74,7 @@ void main() { ..report({'wifi'}); async.flushMicrotasks(); - expect(kinds(), [NetworkChange.lost, NetworkChange.available]); + expect(changes, [NetworkChange.lost, NetworkChange.available]); }); }); @@ -90,7 +86,7 @@ void main() { monitor.report({'cellular'}); async.flushMicrotasks(); - expect(kinds(), [NetworkChange.changed]); + expect(changes, [NetworkChange.changed]); }); }); diff --git a/test/signaling/reconnect_test.dart b/test/signaling/reconnect_test.dart index 376498e..88e017c 100644 --- a/test/signaling/reconnect_test.dart +++ b/test/signaling/reconnect_test.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:clock/clock.dart'; import 'package:fake_async/fake_async.dart'; import 'package:fishjam_client/src/models/config.dart'; import 'package:fishjam_client/src/models/errors.dart'; @@ -579,7 +578,7 @@ void main() { final m = manager() ..onRetryableLoss(_loss, snapshot()) ..onNetworkLost() - ..onNetworkAvailable(since: clock.now()); + ..onNetworkAvailable(); async.flushMicrotasks(); expect(host.count('startSession'), 1); @@ -602,7 +601,7 @@ void main() { m.onRetryableLoss(_loss, snapshot()); expect(host.count('startSession'), 2); - m.onNetworkAvailable(since: clock.now()); + m.onNetworkAvailable(); async.flushMicrotasks(); expect(host.count('startSession'), 3); async.elapse(const Duration(seconds: 5)); @@ -617,22 +616,7 @@ void main() { }); }); - test('an attempt that started after the change began is kept: it already ' - 'runs on the new network', () { - fakeAsync((async) { - host.startGate = Completer(); - final m = manager()..onRetryableLoss(_loss, snapshot()); - final began = clock.now(); - async.elapse(const Duration(milliseconds: 500)); - expect(host.count('startSession'), 1); - - m.onNetworkAvailable(since: began); - async.flushMicrotasks(); - expect(host.count('startSession'), 1); - }); - }); - - test('an attempt that started before the change began is abandoned; its ' + test('an attempt in progress is abandoned; its ' 'late start error is ignored', () { fakeAsync((async) { final gate = host.startGate = Completer(); @@ -642,7 +626,7 @@ void main() { expect(host.count('startSession'), 1); async.elapse(const Duration(milliseconds: 100)); - m.onNetworkAvailable(since: clock.now()); + m.onNetworkAvailable(); async.flushMicrotasks(); expect(host.count('startSession'), 2); @@ -666,7 +650,7 @@ void main() { unawaited(m.onSessionJoined()); async.flushMicrotasks(); - m.onNetworkAvailable(since: clock.now()); + m.onNetworkAvailable(); async.flushMicrotasks(); expect(host.count('startSession'), 2); @@ -687,7 +671,7 @@ void main() { expect(host.events.whereType(), isEmpty); expect(m.isReconnecting, isTrue); - m.onNetworkAvailable(since: clock.now()); + m.onNetworkAvailable(); async.flushMicrotasks(); expect(host.count('startSession'), 2); }); @@ -713,7 +697,7 @@ void main() { expect(host.count('releaseQueue'), 0); expect(m.isReconnecting, isTrue); - m.onNetworkAvailable(since: clock.now()); + m.onNetworkAvailable(); async.flushMicrotasks(); expect(host.count('startSession'), 2); }); @@ -743,7 +727,7 @@ void main() { ..onRetryableLoss(_loss, snapshot()) ..onNetworkLost(); async.elapse(const Duration(seconds: 5)); - m.onNetworkAvailable(since: clock.now()); + m.onNetworkAvailable(); async.elapse(const Duration(seconds: 30)); expect(host.events.whereType(), isEmpty); }); @@ -756,7 +740,7 @@ void main() { // A network that keeps changing restarts the budget every time. for (var i = 0; i < 6; i++) { async.elapse(const Duration(seconds: 1)); - m.onNetworkAvailable(since: clock.now()); + m.onNetworkAvailable(); async.flushMicrotasks(); m.onRetryableLoss(_loss, snapshot()); } @@ -770,14 +754,14 @@ void main() { fakeAsync((async) { final m = manager() ..onNetworkLost() - ..onNetworkAvailable(since: clock.now()); + ..onNetworkAvailable(); async.elapse(const Duration(seconds: 5)); expect(host.calls, isEmpty); m ..onRetryableLoss(_loss, snapshot()) ..reset() - ..onNetworkAvailable(since: clock.now()); + ..onNetworkAvailable(); async.elapse(const Duration(seconds: 5)); expect(host.count('startSession'), 0); }); From 34a44b7616562c0e9c2383b8f0c6373c37e836c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Sun, 25 Oct 2026 14:54:42 +0100 Subject: [PATCH 21/29] Abort a socket that is still opening when it is closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close() on a WebSocketTransport whose connect had not completed left the open running, so the socket could still open after the session using it was gone. The transport now opens through an HttpClient of its own and closing it aborts the open, which then fails like any socket that cannot be opened (SPEC §2.1). The connect timeout uses the same client, so the separate connectionTimeout and the TimeoutException special case go. Claude-Session: https://claude.ai/code/session_01Xn8b3WbMz12ct7ugWKC7hC --- SPEC.md | 2 +- lib/src/signaling/transport.dart | 32 +++++++++----- test/signaling/websocket_transport_test.dart | 45 ++++++++++++++------ 3 files changed, 55 insertions(+), 24 deletions(-) diff --git a/SPEC.md b/SPEC.md index f1f5411..b1c8ee8 100644 --- a/SPEC.md +++ b/SPEC.md @@ -39,7 +39,7 @@ Conventions: - The server pings every 30 s. The client additionally sends its own pings (interval 15 s) and treats a missing pong within one interval as a connection loss (retryable, [§6](#6-reconnection)). - Opening the socket (connection, TLS and upgrade) times out after 10 s; a timeout fails like a socket that - cannot be opened. + cannot be opened. Closing a socket that is still opening aborts the open, which fails the same way. ### 2.2 Messages diff --git a/lib/src/signaling/transport.dart b/lib/src/signaling/transport.dart index 493641f..6cb92b8 100644 --- a/lib/src/signaling/transport.dart +++ b/lib/src/signaling/transport.dart @@ -91,6 +91,10 @@ final class WebSocketTransport implements SignalingTransport { final FishjamLogger _logger; WebSocket? _socket; + + /// Opens [_socket]; its own, so that the connect timeout and [close] can + /// abort a connect in progress. + HttpClient? _client; final _incoming = StreamController(); final _closed = Completer(); @@ -105,21 +109,24 @@ final class WebSocketTransport implements SignalingTransport { @override Future connect(Uri url) async { - if (_socket != null) throw StateError('A transport can be used once'); - // Without a limit, a connect started on a network that went away hangs - // until the OS gives up, which can take minutes. - final client = HttpClient()..connectionTimeout = connectTimeout; - final connecting = WebSocket.connect(url.toString(), customClient: client); + if (_client != null) throw StateError('A transport can be used once'); + final client = _client = HttpClient(); try { - _socket = await connecting.timeout(connectTimeout) - ..pingInterval = pingInterval; + // Without a limit, a connect started on a network that went away hangs + // until the OS gives up, which can take minutes. + _socket = + await WebSocket.connect( + url.toString(), + customClient: client, + ).timeout(connectTimeout) + ..pingInterval = pingInterval; } on Object catch (e) { - if (e is TimeoutException) { - client.close(force: true); - connecting.ignore(); - } _finish(TransportClosed(error: e)); throw TransportException('Cannot connect to $url', e); + } finally { + // Aborts a connect that timed out; an open socket no longer belongs to + // the client. + client.close(force: true); } _socket!.listen( (data) { @@ -148,6 +155,9 @@ final class WebSocketTransport implements SignalingTransport { Future close([int code = 1000, String? reason]) async { final socket = _socket; if (socket == null) { + // A connect in progress fails at once instead of opening a socket + // nobody uses. + _client?.close(force: true); _finish(TransportClosed(code: code, reason: reason)); return; } diff --git a/test/signaling/websocket_transport_test.dart b/test/signaling/websocket_transport_test.dart index 6fb5671..e7114c2 100644 --- a/test/signaling/websocket_transport_test.dart +++ b/test/signaling/websocket_transport_test.dart @@ -17,6 +17,21 @@ Future<(HttpServer, Uri)> _serve( return (server, Uri.parse('ws://127.0.0.1:${server.port}/socket')); } +/// A server that accepts the TCP connection and never answers the upgrade, +/// like a peer on a network that went away mid-handshake. +Future _silentServer() async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final accepted = []; + server.listen(accepted.add); + addTearDown(() async { + for (final socket in accepted) { + socket.destroy(); + } + await server.close(); + }); + return Uri.parse('ws://127.0.0.1:${server.port}/socket'); +} + void main() { test('exchanges binary frames and drops text frames', () async { final serverSide = Completer(); @@ -115,23 +130,13 @@ void main() { test( 'a server that never answers the upgrade times the connect out', () async { - // Accepts the TCP connection and stays silent, like a peer on a network - // that went away mid-handshake. - final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); - final accepted = []; - server.listen(accepted.add); - addTearDown(() async { - for (final socket in accepted) { - socket.destroy(); - } - await server.close(); - }); + final url = await _silentServer(); final transport = WebSocketTransport( connectTimeout: const Duration(milliseconds: 200), ); await expectLater( - transport.connect(Uri.parse('ws://127.0.0.1:${server.port}/socket')), + transport.connect(url), throwsA(isA()), ); expect((await transport.onClose).error, isA()); @@ -139,6 +144,22 @@ void main() { }, ); + test('close aborts a connect in progress', () async { + final url = await _silentServer(); + final transport = WebSocketTransport(); + final connecting = expectLater( + transport.connect(url), + throwsA(isA()), + ); + await Future.delayed(const Duration(milliseconds: 50)); + + await transport.close(); + + // Without the abort it would wait for the 10 s connect timeout. + await connecting.timeout(const Duration(seconds: 2)); + expect(transport.isOpen, isFalse); + }); + test('a transport is single use', () async { final (server, url) = await _serve((socket) => socket.listen(null)); addTearDown(server.close); From fbdb7587ddb3c86ac2db58828255ed6be9e286b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Sun, 25 Oct 2026 14:54:59 +0100 Subject: [PATCH 22/29] Let only the newest network watch subscribe start() read its run number only after awaiting stop(), so two starts in a row both saw the same number, both subscribed, and stop() ended only one of them. The run number is now taken before any await, the baseline is set once the subscription exists, and stop() is synchronous, so it takes effect at once. Claude-Session: https://claude.ai/code/session_01Xn8b3WbMz12ct7ugWKC7hC --- lib/src/fishjam_client.dart | 2 +- lib/src/signaling/network_monitor.dart | 20 +++++++++---------- .../connectivity_missing_plugin_test.dart | 2 +- test/signaling/network_watcher_test.dart | 17 ++++++++++++++-- 4 files changed, 27 insertions(+), 14 deletions(-) diff --git a/lib/src/fishjam_client.dart b/lib/src/fishjam_client.dart index 9434cdc..ef8e743 100644 --- a/lib/src/fishjam_client.dart +++ b/lib/src/fishjam_client.dart @@ -524,7 +524,7 @@ class FishjamClient { // to fail. final connecting = _connecting; _connecting = null; - unawaited(_network?.stop()); + _network?.stop(); await _teardown(session, sendDisconnect: sendDisconnect); _setCommunicationMode(on: false); if (error != null) _emit(error); diff --git a/lib/src/signaling/network_monitor.dart b/lib/src/signaling/network_monitor.dart index 11f75f4..c56337b 100644 --- a/lib/src/signaling/network_monitor.dart +++ b/lib/src/signaling/network_monitor.dart @@ -41,7 +41,8 @@ class NetworkWatcher { StreamSubscription>? _subscription; - /// Bumped by [stop], so a [start] still reading the start value gives up. + /// Bumped by [stop] (and so by every [start]): a start still reading the + /// start value gives up when it is no longer the latest. int _runs = 0; /// The networks of the last report; `null` before the baseline. @@ -49,35 +50,34 @@ class NetworkWatcher { /// Starts watching with a new baseline (stops a previous watch first). Future start() async { - await stop(); + stop(); final run = _runs; try { // Read first: a platform that cannot be asked reports a failed // subscription to the framework's error handler, not to the stream. final networks = await _monitor.current(); if (run != _runs) return; - _last = networks; _subscription = _monitor.changes.listen( _onReport, onError: (Object e) { _logger.warn('Network reports failed; stopped watching them', e); - unawaited(stop()); + stop(); }, ); + _last = networks; } on Object catch (e) { // A newer start may be watching already; this one only had to give up. - if (run != _runs) return; - _logger.warn('Cannot read the network state; not watching it', e); - await stop(); + if (run == _runs) { + _logger.warn('Cannot read the network state; not watching it', e); + } } } - Future stop() async { + void stop() { _runs++; - final cancelled = _subscription?.cancel(); + unawaited(_subscription?.cancel()); _subscription = null; _last = null; - await cancelled; } void _onReport(Set networks) { diff --git a/test/client/connectivity_missing_plugin_test.dart b/test/client/connectivity_missing_plugin_test.dart index b1e0673..2490b8f 100644 --- a/test/client/connectivity_missing_plugin_test.dart +++ b/test/client/connectivity_missing_plugin_test.dart @@ -21,7 +21,7 @@ void main() { // Platform channels answer in real time. await tester.runAsync(() async { await watcher.start(); - await watcher.stop(); + watcher.stop(); }); expect(logs, [startsWith('warn')]); diff --git a/test/signaling/network_watcher_test.dart b/test/signaling/network_watcher_test.dart index 0b9111b..281c530 100644 --- a/test/signaling/network_watcher_test.dart +++ b/test/signaling/network_watcher_test.dart @@ -107,12 +107,25 @@ void main() { }); }); + test('of two starts in a row only the newer one listens, so stop ends ' + 'watching', () { + fakeAsync((async) { + unawaited(watcher.start()); + unawaited(watcher.start()); + async.flushMicrotasks(); + watcher.stop(); + async.flushMicrotasks(); + + expect(monitor.isWatched, isFalse); + }); + }); + test('stop while the start value is read: nothing is listened to', () { fakeAsync((async) { final gate = monitor.currentGate = Completer(); unawaited(watcher.start()); async.flushMicrotasks(); - unawaited(watcher.stop()); + watcher.stop(); gate.complete(); async.flushMicrotasks(); @@ -176,7 +189,7 @@ void main() { test('stop ends watching; start after stop takes a new baseline', () { run((async) { - unawaited(watcher.stop()); + watcher.stop(); async.flushMicrotasks(); expect(monitor.isWatched, isFalse); From d979f2b5dd28d3a654c73ee074506ffdcbe83eb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Sun, 25 Oct 2026 14:55:05 +0100 Subject: [PATCH 23/29] Drop the transceiver direction getters nothing calls Since receive transceivers are tracked instead of asked for their direction, getDirection() and getCurrentDirection() had no caller left; they go from the seam, the adapter and the fake. Stopping excess receive transceivers is now two passes over one snapshot, those without a mid first, which is what it did before, written plainly. Claude-Session: https://claude.ai/code/session_01Xn8b3WbMz12ct7ugWKC7hC --- lib/src/engine/receive_transceivers.dart | 48 ++++++++++--------- .../rtc/flutter_webrtc_adapter_mappers.dart | 9 ---- ...lutter_webrtc_adapter_peer_connection.dart | 10 ---- lib/src/rtc/rtc_adapter.dart | 5 -- test/fakes/fake_rtc.dart | 16 +------ test/fakes/fake_rtc_test.dart | 26 +++++----- 6 files changed, 41 insertions(+), 73 deletions(-) diff --git a/lib/src/engine/receive_transceivers.dart b/lib/src/engine/receive_transceivers.dart index 5f723d2..c6a80b4 100644 --- a/lib/src/engine/receive_transceivers.dart +++ b/lib/src/engine/receive_transceivers.dart @@ -12,6 +12,9 @@ import 'package:fishjam_client/src/rtc/rtc_adapter.dart'; /// transceiver disposes the handles of the ones without a mid, and on iOS all /// transceivers without a mid share one id. class ReceiveTransceivers { + /// Missing transceivers are added video first (SPEC §9). + static const List _kinds = [MediaKind.video, MediaKind.audio]; + final _live = >{ MediaKind.video: [], MediaKind.audio: [], @@ -24,10 +27,12 @@ class ReceiveTransceivers { server.MediaEvent_OfferData_TrackTypes types, ) async { final wanted = {MediaKind.video: types.video, MediaKind.audio: types.audio}; + // Without an excess there is nothing to stop, and no native + // `getTransceivers()` is made. if (_live.entries.any((e) => e.value.length > wanted[e.key]!)) { await _stopExcess(pc, wanted); } - for (final kind in [MediaKind.video, MediaKind.audio]) { + for (final kind in _kinds) { for (var i = _live[kind]!.length; i < wanted[kind]!; i++) { final transceiver = await pc.addTransceiverOfKind( kind, @@ -39,9 +44,10 @@ class ReceiveTransceivers { } } - /// Transceivers without a mid were never negotiated: they are stopped - /// first. They are also stopped before any negotiated one, from one - /// snapshot, because stopping a negotiated one invalidates their handles. + /// Stops the excess, all from one snapshot: first the transceivers + /// without a mid (never negotiated) of every kind, then negotiated ones, + /// because stopping a negotiated one invalidates the handles of those + /// without a mid. Future _stopExcess( RtcPeerConnection pc, Map wanted, @@ -49,26 +55,24 @@ class ReceiveTransceivers { final snapshot = { for (final t in await pc.getTransceivers()) ?t.receiver.track?.id: t, }; - final victims = <(MediaKind, String, RtcRtpTransceiver)>[]; - for (final kind in [MediaKind.video, MediaKind.audio]) { + for (final ids in _live.values) { // A recorded transceiver missing from the snapshot is gone already. - final live = _live[kind]!..removeWhere((id) => !snapshot.containsKey(id)); - final excess = live.length - wanted[kind]!; - if (excess <= 0) continue; - final ordered = [ - ...live.where((id) => snapshot[id]!.mid == null), - ...live.where((id) => snapshot[id]!.mid != null), - ]; - victims.addAll([ - for (final id in ordered.take(excess)) (kind, id, snapshot[id]!), - ]); + ids.removeWhere((id) => !snapshot.containsKey(id)); } - for (final (kind, id, transceiver) in [ - ...victims.where((v) => v.$3.mid == null), - ...victims.where((v) => v.$3.mid != null), - ]) { - await transceiver.stop(); - _live[kind]!.remove(id); + for (final negotiated in [false, true]) { + for (final kind in _kinds) { + final ids = _live[kind]!; + final excess = ids.length - wanted[kind]!; + if (excess <= 0) continue; + final victims = ids + .where((id) => (snapshot[id]!.mid != null) == negotiated) + .take(excess) + .toList(); + for (final id in victims) { + await snapshot[id]!.stop(); + ids.remove(id); + } + } } } } diff --git a/lib/src/rtc/flutter_webrtc_adapter_mappers.dart b/lib/src/rtc/flutter_webrtc_adapter_mappers.dart index 0e98282..0a1c878 100644 --- a/lib/src/rtc/flutter_webrtc_adapter_mappers.dart +++ b/lib/src/rtc/flutter_webrtc_adapter_mappers.dart @@ -23,15 +23,6 @@ webrtc.RTCSessionDescription _toNativeDescription( SessionDescription description, ) => webrtc.RTCSessionDescription(description.sdp, description.type.name); -TransceiverDirection _fromNativeDirection(webrtc.TransceiverDirection d) => - switch (d) { - webrtc.TransceiverDirection.SendRecv => TransceiverDirection.sendrecv, - webrtc.TransceiverDirection.SendOnly => TransceiverDirection.sendonly, - webrtc.TransceiverDirection.RecvOnly => TransceiverDirection.recvonly, - webrtc.TransceiverDirection.Inactive => TransceiverDirection.inactive, - webrtc.TransceiverDirection.Stopped => TransceiverDirection.stopped, - }; - webrtc.TransceiverDirection _toNativeDirection(TransceiverDirection d) => switch (d) { TransceiverDirection.sendrecv => webrtc.TransceiverDirection.SendRecv, diff --git a/lib/src/rtc/flutter_webrtc_adapter_peer_connection.dart b/lib/src/rtc/flutter_webrtc_adapter_peer_connection.dart index d915768..7fd42df 100644 --- a/lib/src/rtc/flutter_webrtc_adapter_peer_connection.dart +++ b/lib/src/rtc/flutter_webrtc_adapter_peer_connection.dart @@ -200,16 +200,6 @@ final class _Transceiver implements RtcRtpTransceiver { @override String? get mid => _midOrNull(_transceiver.mid); - @override - Future getDirection() async => - _fromNativeDirection(await _transceiver.getDirection()); - - @override - Future getCurrentDirection() async { - final direction = await _transceiver.getCurrentDirection(); - return direction == null ? null : _fromNativeDirection(direction); - } - @override RtcRtpSender get sender => _Sender(_transceiver.sender); diff --git a/lib/src/rtc/rtc_adapter.dart b/lib/src/rtc/rtc_adapter.dart index 557dc1e..29df4bb 100644 --- a/lib/src/rtc/rtc_adapter.dart +++ b/lib/src/rtc/rtc_adapter.dart @@ -99,11 +99,6 @@ abstract interface class RtcRtpTransceiver { /// Media id; `null` until a local description was applied. String? get mid; - Future getDirection(); - - /// Negotiated direction; `null` before the first negotiation. - Future getCurrentDirection(); - RtcRtpSender get sender; RtcRtpReceiver get receiver; diff --git a/test/fakes/fake_rtc.dart b/test/fakes/fake_rtc.dart index b1bf96f..3f25f9e 100644 --- a/test/fakes/fake_rtc.dart +++ b/test/fakes/fake_rtc.dart @@ -154,8 +154,8 @@ class FakeTransceiverState { /// /// Models how `flutter_webrtc` reaches native transceivers on Android: a view /// without a mid holds a native handle that the next native -/// `getTransceivers()` disposes, and a call on a view with a mid makes that -/// call itself (it looks the transceiver up by mid). A call on a disposed +/// `getTransceivers()` disposes, and [stop] on a view with a mid makes that +/// call itself (it looks the transceiver up by mid). [stop] on a disposed /// handle throws. class FakeRtpTransceiver implements RtcRtpTransceiver { FakeRtpTransceiver._(this.state, this._pc) @@ -179,18 +179,6 @@ class FakeRtpTransceiver implements RtcRtpTransceiver { } } - @override - Future getDirection() async { - _resolve(); - return state.direction; - } - - @override - Future getCurrentDirection() async { - _resolve(); - return state.currentDirection; - } - @override RtcRtpSender get sender => state.sender; diff --git a/test/fakes/fake_rtc_test.dart b/test/fakes/fake_rtc_test.dart index b045045..f0db36b 100644 --- a/test/fakes/fake_rtc_test.dart +++ b/test/fakes/fake_rtc_test.dart @@ -15,14 +15,11 @@ void main() { group('transceivers', () { test('model flutter_webrtc on Android: a lookup by mid disposes the ' 'handles of transceivers without a mid', () async { - final pc = FakePeerConnection(const RtcConfiguration()); await pc.addTransceiverOfKind( MediaKind.audio, const TransceiverInit(direction: TransceiverDirection.recvonly), ); - await pc.setLocalDescription( - const SessionDescription(type: SdpType.offer, sdp: 'v=0'), - ); + await pc.setLocalDescription(await pc.createOffer()); await pc.addTransceiverOfKind( MediaKind.video, const TransceiverInit(direction: TransceiverDirection.recvonly), @@ -31,9 +28,7 @@ void main() { expect(negotiated.mid, isNotNull); expect(fresh.mid, isNull); - await fresh.getDirection(); - await negotiated.getDirection(); - await expectLater(fresh.getDirection(), throwsStateError); + await negotiated.stop(); await expectLater(fresh.stop(), throwsStateError); }); @@ -81,8 +76,10 @@ void main() { const SessionDescription(type: SdpType.answer, sdp: 'answer'), ); - final t = (await pc.getTransceivers()).single; - expect(await t.getCurrentDirection(), TransceiverDirection.recvonly); + expect( + pc.transceiverStates.single.currentDirection, + TransceiverDirection.recvonly, + ); expect(pc.signalingState, SignalingState.stable); expect(pc.remoteDescription?.sdp, 'answer'); await Future.delayed(Duration.zero); @@ -98,9 +95,9 @@ void main() { await t.stop(); - final fresh = (await pc.getTransceivers()).single; - expect(await fresh.getDirection(), TransceiverDirection.stopped); - expect(await fresh.getCurrentDirection(), TransceiverDirection.stopped); + final state = pc.transceiverStates.single; + expect(state.isStopped, isTrue); + expect(state.currentDirection, TransceiverDirection.stopped); expect(pc.calls.last.method, 'stopTransceiver'); }); @@ -116,7 +113,10 @@ void main() { final fresh = (await pc.getTransceivers()).single; expect(fresh.sender.track, isNull); - expect(await fresh.getDirection(), TransceiverDirection.inactive); + expect( + pc.transceiverStates.single.direction, + TransceiverDirection.inactive, + ); expect((await pc.getSenders()).single.track, isNull); }, ); From b1de917597b7b6b2941e2254b2302a5f651588e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Sun, 25 Oct 2026 14:55:17 +0100 Subject: [PATCH 24/29] Open a new connect() after the previous session ends; simplify reconnection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A connect() made while the session given up before it was still torn down (from a ReconnectionFailed listener, or without awaiting leave()) opened at once: the old Disconnected came after the new Joined and the old teardown left the audio communication mode off during the new call. connect() now opens its socket once that teardown has finished (SPEC §3.2.6). Reconnection, simplified without changing what the app sees: - LocalTrackRemoved is emitted in one place, right after the next Joined, for every local track the app was told about, instead of by the manager and the client with flags to avoid reporting a track twice. - The manager loses duplicated resets and a dead condition, its offline handling in onSessionJoined is one flow, and giving up has one helper. - The network is watched only with reconnection enabled (SPEC §6.1). The fixture replays use the default reconnection config, which publishes tracks again since that default changed. The client tests share rejoin() and negotiate() helpers for the repeated join and negotiation steps. Claude-Session: https://claude.ai/code/session_01Xn8b3WbMz12ct7ugWKC7hC --- SPEC.md | 22 +-- example/lib/room_screen.dart | 7 +- lib/src/fishjam_client.dart | 70 ++++---- lib/src/signaling/reconnect.dart | 105 ++++++------ test/client/audio_routing_test.dart | 27 ++++ test/client/client_fixtures_test.dart | 19 ++- test/client/client_harness.dart | 42 +++-- test/client/fishjam_client_test.dart | 221 +++++++++++++++----------- test/signaling/reconnect_test.dart | 46 ++---- 9 files changed, 307 insertions(+), 252 deletions(-) diff --git a/SPEC.md b/SPEC.md index b1c8ee8..33a9e25 100644 --- a/SPEC.md +++ b/SPEC.md @@ -111,8 +111,10 @@ idle ──connect()──▶ connecting ──authenticated──▶ authentica Reconnection is **not** attempted for failures before the first `Joined`. 6. Calling `connect()` while not `idle`/`disconnected` throws `StateError`. The status is `disconnected` from the moment a session is given up, so `connect()` may be called from a listener of an event emitted - then (e.g. `ReconnectionFailed`); the teardown of the given-up session settles only the `connect()` call - that started it. + then (e.g. `ReconnectionFailed`) or without awaiting `leave()`. Such a call opens its socket only once the + given-up session is torn down and its `Disconnected` emitted, so nothing of the old session (events, the + audio mode of [§20b](#20b-audio-output-routing)) lands in the new one. That teardown settles only the + `connect()` call that started it. ### 3.3 `leave()` @@ -186,7 +188,7 @@ States: `idle`, `reconnecting`, `failed`. | reconnecting | retryable loss in the new session before `connected` (including a new session that cannot be started at all) | schedule next attempt if attempts remain | reconnecting | | reconnecting | retryable loss, no attempts left | clear queue; emit `ReconnectionFailed`, then `Disconnected(DisconnectReason.reconnectionFailed)` | failed | | reconnecting | terminal close (auth/join/session ended/protocol) | cancel timer; clear queue; emit the matching error event and `Disconnected` | failed | -| reconnecting | `connected` | emit `TrackRemoved` for every remote track and then `PeerLeft` / `ComponentRemoved` for every remote endpoint the app was told about by the lost session; emit `Joined` (its snapshot is the source of truth); emit `LocalTrackRemoved(oldTrackId)` for every snapshotted track (once per reconnection, also without `addTracksOnReconnect`); if `addTracksOnReconnect`, re-add every snapshotted track whose media track is still live (skip, do not abort on, ended or muted tracks), all at once and in snapshot order, so they ride one negotiation cycle ([§8.3](#83-command-queue)); when all re-adds settled (success or failure): flush queue, emit `Reconnected`, reset attempt counter | idle | +| reconnecting | `connected` | emit `TrackRemoved` for every remote track and then `PeerLeft` / `ComponentRemoved` for every remote endpoint the app was told about by the lost session; emit `Joined` (its snapshot is the source of truth); emit `LocalTrackRemoved` for every local track id the app was told about by the lost sessions (also without `addTracksOnReconnect`); if `addTracksOnReconnect`, re-add every snapshotted track whose media track is still live (skip, do not abort on, ended or muted tracks), all at once and in snapshot order, so they ride one negotiation cycle ([§8.3](#83-command-queue)); when all re-adds settled (success or failure): flush queue, emit `Reconnected`, reset attempt counter | idle | | any | `leave()` | cancel timer; clear queue | idle | - Delay of attempt `n` (1-based) is `initialDelay + (n - 1) * delay`. @@ -200,10 +202,10 @@ States: `idle`, `reconnecting`, `failed`. - An attempt replaced after its `Joined` without a loss of its own (it joined while offline, or a new network appeared while its tracks were restored, [§6.1](#61-network-changes)) counts as lost too: the remote endpoints it announced are reported gone before the next `Joined`. -- Every local track id of the lost session ends with `LocalTrackRemoved`: the tracks went away with the - session. A re-added track gets a **new** track id; `LocalTrackAdded` is emitted for it after the removals. - If an attempt that already re-added tracks is lost before `Reconnected`, those new ids are reported - removed when the next attempt starts. +- Every local track id of a lost session ends with `LocalTrackRemoved` right after the next `Joined` (or with + the `Disconnected` that ends a failed reconnection): the tracks went away with the session. This includes + the new ids of tracks an attempt re-added before it was lost. A re-added track gets a **new** track id; + `LocalTrackAdded` is emitted for it after the removals. - Only tracks the app got `LocalTrackAdded` for (and no `LocalTrackRemoved` yet) are snapshotted, reported removed and re-added. A track whose `addTrack` was still negotiating when the session was lost is not: its `addTrack` fails, so the app never learned its id and does not expect it published. Neither is a track an @@ -217,8 +219,9 @@ The client watches the kinds of network the device uses (Wi-Fi, cellular, ethern reports them. A socket and a peer connection are bound to the network they were opened on, so losing it means the session will not survive; reacting at once avoids waiting for a pong timeout or an ICE failure. -- Watching starts at `connect()` and stops when the client is disconnected. The first value read is the - baseline. **Online** means at least one network is reported; **offline** means none. +- With reconnection enabled, watching starts at `connect()` and stops when the client is disconnected; with + reconnection disabled the network is not watched. The first value read is the baseline. **Online** means at + least one network is reported; **offline** means none. - Every report is acted on when it arrives, compared with the previous report. The brief "offline" some platforms report while switching networks is not waited out: no attempt starts while offline, and the network reported after it starts one at once. @@ -235,7 +238,6 @@ the session will not survive; reacting at once avoids waiting for a pong timeout |---|---|---| | joined, reconnection enabled | changed | retryable loss `network changed` (§6), without waiting for the socket or ICE to fail | | joined, reconnection enabled | lost | retryable loss `network lost` (§6) | -| joined, reconnection disabled | any | nothing: the session ends only when its socket or peer connection does | | before the first `Joined` of a `connect()` | any | no loss (the attempt fails or succeeds on its own); whether the device is online is remembered | | reconnecting | lost | a scheduled attempt is cancelled; no attempt starts while offline, and a loss while offline waits for the network instead of using up an attempt | | reconnecting | available or changed | the attempt budget starts over (`maxAttempts` on the new network). A scheduled attempt is cancelled, an attempt in progress is abandoned (its session torn down silently) and a new attempt starts at once | diff --git a/example/lib/room_screen.dart b/example/lib/room_screen.dart index 40ec053..aa1adb7 100644 --- a/example/lib/room_screen.dart +++ b/example/lib/room_screen.dart @@ -31,10 +31,9 @@ class _RemoteVideo { } class _RoomScreenState extends State { - // Tracks are published again after a reconnection (with new ids). - final _client = FishjamClient( - reconnect: const ReconnectConfig(addTracksOnReconnect: true), - ); + // Tracks are published again after a reconnection, with new ids + // (`ReconnectConfig.addTracksOnReconnect`, on by default). + final _client = FishjamClient(); final _localRenderer = RTCVideoRenderer(); final _remote = {}; final _subscriptions = >[]; diff --git a/lib/src/fishjam_client.dart b/lib/src/fishjam_client.dart index ef8e743..c59f808 100644 --- a/lib/src/fishjam_client.dart +++ b/lib/src/fishjam_client.dart @@ -75,7 +75,8 @@ class FishjamClient { }) : _adapter = rtcAdapter ?? const FlutterWebrtcAdapter(), _logger = FishjamLogger(enabled: debug) { _reconnect = ReconnectManager(config: reconnect, host: _Host(this)); - if (networkMonitor != null) { + // Network changes matter only to reconnection. + if (networkMonitor != null && reconnect.isEnabled) { _network = NetworkWatcher( monitor: networkMonitor, logger: _logger, @@ -130,6 +131,10 @@ class FishjamClient { /// Settles the pending `connect()` call exactly once. Completer? _connecting; + + /// Completes when the teardown of the session given up last has finished + /// (its `Disconnected` was emitted); never fails. + Future _ending = Future.value(); String _url = ''; String _token = ''; bool _disposed = false; @@ -174,13 +179,20 @@ class FishjamClient { _status = ConnectionStatus.connecting; unawaited(_network?.start()); final connecting = _connecting = Completer(); - final session = _newSession(); + unawaited(_openAfterEnding(_newSession(), peerMetadata)); + return await connecting.future; + } + + /// Opens [session] once the session given up before it has ended (e.g. + /// when connect() is called from a `ReconnectionFailed` listener), so that + /// session's `Disconnected` and audio mode change come first. + Future _openAfterEnding(_Session session, Object? peerMetadata) async { try { - await _open(session, peerMetadata); + await _ending; + if (_isCurrent(session)) await _open(session, peerMetadata); } on Object { // Reported through `closed`, which settles connect(). } - return await connecting.future; } /// Leaves the room gracefully. A no-op when not connected. @@ -384,6 +396,11 @@ class FishjamClient { // While reconnecting, the status turns `joined` with `Reconnected`. if (!_reconnect.isReconnecting) _status = ConnectionStatus.joined; _emit(joined); + // Every local track the app was told about belongs to a lost session (the + // set starts empty at connect()) and went away with it. + for (final trackId in _announcedLocalTracks.toList()) { + _emit(LocalTrackRemoved(trackId)); + } _stats.start(); _setCommunicationMode(on: true); final connecting = _connecting; @@ -452,7 +469,9 @@ class FishjamClient { /// socket and the peer connection are bound to the network that went away. void _onNetworkChanged(NetworkChange change) { _logger.debug('Network ${change.name}'); - if (!reconnect.isEnabled) return; + // The loss comes first, so the change below acts on the reconnection it + // starts: a new network starts the first attempt at once, and offline + // cancels it. if (_status == ConnectionStatus.joined && change != NetworkChange.available) { unawaited( @@ -525,10 +544,16 @@ class FishjamClient { final connecting = _connecting; _connecting = null; _network?.stop(); - await _teardown(session, sendDisconnect: sendDisconnect); - _setCommunicationMode(on: false); - if (error != null) _emit(error); - _emit(disconnected); + final ended = Completer(); + _ending = ended.future; + try { + await _teardown(session, sendDisconnect: sendDisconnect); + _setCommunicationMode(on: false); + if (error != null) _emit(error); + _emit(disconnected); + } finally { + ended.complete(); + } if (connecting != null && !connecting.isCompleted) { connecting.completeError( connectError ?? @@ -665,9 +690,6 @@ class _Session { /// Whether `Joined` was emitted for this session. bool isJoined = false; - /// Whether the session was started by a reconnection attempt. - bool isReconnectAttempt = false; - /// Whether a reconnection replaces this session; it reports nothing more. bool isLost = false; @@ -684,22 +706,13 @@ class _Host implements ReconnectHost { @override Future startSession(Object? peerMetadata) async { final old = _client._session; - // Without a reported loss (an attempt replaced after its `Joined`) this is - // where it is given up. - if (old != null) _client._abandon(old); // The new session becomes current first, so the old one is torn down // silently (no `Disconnected`) and its late reports are ignored. - final session = _client._newSession()..isReconnectAttempt = true; + final session = _client._newSession(); if (old != null) { - // Tracks a previous attempt restored were announced with - // `LocalTrackAdded`; they go away with that session. - if (old.isReconnectAttempt) { - for (final track in old.localTracks.tracks) { - if (_client._announcedLocalTracks.contains(track.trackId)) { - _client._emit(LocalTrackRemoved(track.trackId)); - } - } - } + // Without a loss of its own (an attempt replaced after its `Joined`), + // this is where it is given up. + _client._abandon(old); // A socket on a network that went away can take seconds to close // (the close handshake never completes); the new session does not // need it gone. @@ -740,12 +753,7 @@ class _Host implements ReconnectHost { @override void emit(FishjamEvent event) { - if (event is Reconnected) { - _client._status = ConnectionStatus.joined; - // Its tracks are now the snapshot of the next reconnection, which - // reports them removed itself. - _client._session?.isReconnectAttempt = false; - } + if (event is Reconnected) _client._status = ConnectionStatus.joined; _client._emit(event); } } diff --git a/lib/src/signaling/reconnect.dart b/lib/src/signaling/reconnect.dart index ea120bf..70f97f4 100644 --- a/lib/src/signaling/reconnect.dart +++ b/lib/src/signaling/reconnect.dart @@ -62,18 +62,17 @@ abstract interface class ReconnectHost { /// session that fails to open — through [onRetryableLoss] or /// [onTerminalClose], and every `connected` through [onSessionJoined]. /// - The caller reports every network change through [onNetworkLost] or -/// [onNetworkAvailable]; while joined it also reports a lost or changed -/// network as a retryable loss. -/// - The manager emits only `ReconnectionStarted`, `ReconnectionFailed`, -/// `Reconnected` and, on the first `connected` of a reconnection, -/// `LocalTrackRemoved` for every snapshotted track (restored tracks get -/// new ids). It never emits `Disconnected`: when +/// [onNetworkAvailable]. While joined it first reports a lost or changed +/// network as a retryable loss, so the change then acts on the +/// reconnection that loss started. +/// - The manager emits only `ReconnectionStarted`, `ReconnectionFailed` and +/// `Reconnected`. It never emits `Disconnected`: when /// [onRetryableLoss] returns `false` the caller ends the session with /// `Disconnected(reconnectionFailed)` if [state] is /// [ReconnectState.failed], else `Disconnected(connectionLost)`; after a /// terminal close the caller emits the error event and `Disconnected`. -/// The exception is giving up on its own (see [ReconnectHost.endSession]), -/// where the manager calls [ReconnectHost.endSession]. +/// When the manager gives up on its own, it ends the session itself +/// through [ReconnectHost.endSession] (the cases are listed there). /// - With `addTracksOnReconnect`, only tracks with a live media track are /// re-added. Muted tracks (no media track) and ended tracks are /// not; a failed re-add does not stop the others. @@ -95,7 +94,9 @@ class ReconnectManager { /// a network that keeps changing restarts the budget, so this caps it. int _totalAttempts = 0; - /// Whether an attempt has started and not yet been lost or joined. + /// Whether the latest attempt is still open: started, and neither lost nor + /// left waiting for the network (it stays set while a joined attempt + /// restores its tracks). bool _attemptInProgress = false; /// While offline no attempt starts, and a loss waits for the network @@ -105,10 +106,6 @@ class ReconnectManager { /// Runs while reconnecting offline; gives up at `offlineTimeout`. Timer? _offlineTimer; - /// Whether `LocalTrackRemoved` was emitted for the snapshot's tracks in - /// the current reconnection (once, on the first `connected`). - bool _removalsReported = false; - /// Bumped whenever the current attempt is superseded, so a pending track /// restoration of an outdated session does not complete the reconnection. int _generation = 0; @@ -129,29 +126,28 @@ class ReconnectManager { if (!_config.isEnabled) return false; _state = ReconnectState.reconnecting; _snapshot = snapshot; - _attempt = 0; - _totalAttempts = 0; - _removalsReported = false; _host ..holdQueue() ..emit(const ReconnectionStarted()); - _online ? _scheduleNextAttempt() : _waitForNetwork(); + if (_online) { + _scheduleNextAttempt(); + } else { + _startOfflineTimeout(); + } return true; case ReconnectState.reconnecting: if (_timer != null) return true; _generation++; _attemptInProgress = false; - if (!_online) { - _waitForNetwork(); - return true; - } + // Offline, the loss waits for the network (the offline timeout runs + // since it went away) instead of using up an attempt. + if (!_online) return true; if (_attempt < _config.maxAttempts && _totalAttempts < _maxTotalAttempts) { _scheduleNextAttempt(); return true; } - _finish(ReconnectState.failed); - _host.emit(const ReconnectionFailed()); + _fail(); return false; } } @@ -168,46 +164,32 @@ class ReconnectManager { } /// A session reached `connected`. In [ReconnectState.reconnecting]: - /// reports every snapshotted track removed (once per reconnection), /// restores tracks, releases the queue and emits `Reconnected`. /// /// Only live tracks are restored, all at once; ended or muted ones are /// skipped and a failed restore does not stop the others. If the session - /// is lost or the device goes offline while restoring, `Reconnected` is not - /// emitted for it. + /// is lost or the device goes offline before it is restored, `Reconnected` + /// is not emitted for it. Future onSessionJoined() async { if (_state != ReconnectState.reconnecting) return; - if (!_online) { - // The session got through just before the network went away; it will - // not survive. - _attemptInProgress = false; - _waitForNetwork(); - return; - } - final generation = _generation; - final tracks = _snapshot!.tracks; - if (!_removalsReported) { - _removalsReported = true; - for (final track in tracks) { - _host.emit(LocalTrackRemoved(track.trackId)); - } - } - if (_config.addTracksOnReconnect) { + if (_online && _config.addTracksOnReconnect) { + final generation = _generation; // Started together, so they ride one negotiation cycle and the media // comes back at once. The host reports a failure; the others still go. await Future.wait([ - for (final track in tracks) + for (final track in _snapshot!.tracks) if (track.rtcTrack?.isLive ?? false) _host.restoreTrack(track).then((_) {}, onError: (_) {}), ]); if (generation != _generation) return; - if (!_online) { - // Nothing reports the loss of this session while it is not yet - // joined, so it is abandoned like one that joined offline. - _attemptInProgress = false; - _waitForNetwork(); - return; - } + } + if (!_online) { + // It got through just before the network went away, or lost it while + // restoring: it will not survive, and a network change is no loss while + // reconnecting, so the reconnection waits for the network (the offline + // timeout runs since it went away). + _attemptInProgress = false; + return; } _finish(ReconnectState.idle, release: true); _host.emit(const Reconnected()); @@ -220,7 +202,7 @@ class ReconnectManager { _online = false; if (_state != ReconnectState.reconnecting) return; _cancelTimer(); - _waitForNetwork(); + _startOfflineTimeout(); } /// The device has a network again, or a different one. While @@ -231,10 +213,9 @@ class ReconnectManager { _online = true; _cancelOfflineTimer(); if (_state != ReconnectState.reconnecting) return; - _attempt = 0; if (_totalAttempts >= _maxTotalAttempts) { // An attempt still in progress decides; with none, nothing would. - if (!_attemptInProgress && _timer == null) _giveUp(); + if (!_attemptInProgress) _giveUp(); return; } _cancelTimer(); @@ -251,18 +232,26 @@ class ReconnectManager { int get _maxTotalAttempts => 3 * _config.maxAttempts; - void _waitForNetwork() { + /// Gives up after `offlineTimeout` unless [onNetworkAvailable] comes + /// first. + void _startOfflineTimeout() { _offlineTimer ??= Timer(_config.offlineTimeout, () { _offlineTimer = null; _giveUp(); }); } - void _giveUp() { + /// Clears the queue and emits `ReconnectionFailed`; the caller ends the + /// session. + void _fail() { _finish(ReconnectState.failed); - _host - ..emit(const ReconnectionFailed()) - ..endSession(DisconnectReason.reconnectionFailed); + _host.emit(const ReconnectionFailed()); + } + + /// [_fail] when the manager gives up on its own, so it ends the session. + void _giveUp() { + _fail(); + _host.endSession(DisconnectReason.reconnectionFailed); } void _scheduleNextAttempt() { diff --git a/test/client/audio_routing_test.dart b/test/client/audio_routing_test.dart index 1dfc8c3..b2a58a3 100644 --- a/test/client/audio_routing_test.dart +++ b/test/client/audio_routing_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:fishjam_client/fishjam_client.dart'; import 'package:fishjam_client/src/proto/fishjam/media_events/server/server.pb.dart' as server; @@ -62,6 +64,31 @@ void main() { expect(calls, ['enterCommunicationMode', 'exitCommunicationMode']); }); + test('a connect() made while the previous session is torn down opens ' + 'after it, so the mode is exited and entered again', () async { + await h.join(); + final gate = h.transport.closeGate = Completer(); + final leaving = h.client.leave(); + await settle(); + final connecting = h.client.connect(url: 'ws://f', token: 't'); + await settle(); + expect(h.transport.isOpen, isFalse); + + gate.complete(); + await leaving; + await settle(); + await h.rejoin(); + await connecting; + + expect(calls, [ + 'enterCommunicationMode', + 'exitCommunicationMode', + 'enterCommunicationMode', + ]); + expect(h.events.whereType(), hasLength(1)); + expect(h.events.last, isA()); + }); + test('never entered when connect() fails before Joined', () async { final connecting = h.client.connect(url: 'ws://f', token: 't'); final failed = expectLater( diff --git a/test/client/client_fixtures_test.dart b/test/client/client_fixtures_test.dart index 9cc6c55..7fd78be 100644 --- a/test/client/client_fixtures_test.dart +++ b/test/client/client_fixtures_test.dart @@ -111,16 +111,21 @@ Object? _sync(void Function() call) { return null; } +/// The fixture's `reconnect` settings; missing ones keep the SDK defaults. ReconnectConfig _reconnectConfig(Map config) { + const defaults = ReconnectConfig(); final json = config['reconnect'] as Map?; - if (json == null) return const ReconnectConfig(); + if (json == null) return defaults; + Duration? ms(String key) => switch (json[key]) { + final int value => Duration(milliseconds: value), + _ => null, + }; return ReconnectConfig( - maxAttempts: (json['maxAttempts'] as int?) ?? 3, - initialDelay: Duration( - milliseconds: (json['initialDelayMs'] as int?) ?? 500, - ), - delay: Duration(milliseconds: (json['delayMs'] as int?) ?? 500), - addTracksOnReconnect: (json['addTracksOnReconnect'] as bool?) ?? false, + maxAttempts: json['maxAttempts'] as int? ?? defaults.maxAttempts, + initialDelay: ms('initialDelayMs') ?? defaults.initialDelay, + delay: ms('delayMs') ?? defaults.delay, + addTracksOnReconnect: + json['addTracksOnReconnect'] as bool? ?? defaults.addTracksOnReconnect, ); } diff --git a/test/client/client_harness.dart b/test/client/client_harness.dart index c159dca..8b255ab 100644 --- a/test/client/client_harness.dart +++ b/test/client/client_harness.dart @@ -92,9 +92,19 @@ class ClientHarness { peerMetadata: peerMetadata, ); await settle(); + await rejoin(roomType: roomType, endpoints: endpoints); + await connected; + } + + /// The server lets the current transport in: authenticates it with + /// [roomType] and sends `connected` with [endpoints] besides the local + /// peer (a first join or a reconnection attempt). + Future rejoin({ + PeerMessage_RoomType roomType = PeerMessage_RoomType.ROOM_TYPE_CONFERENCE, + Map endpoints = const {}, + }) async { await authenticate(roomType: roomType); await connectedEvent(endpoints: endpoints); - await connected; } /// The server accepts the `authRequest` on the current transport. @@ -131,21 +141,29 @@ class ClientHarness { await settle(); } - /// The server runs one negotiation cycle: `offerData`, then an answer - /// that maps the mids of the client's offer. + /// The server runs one negotiation cycle: [offerData], then [answer]. Future negotiate({int audio = 0, int video = 0}) async { - await receive( - server.MediaEvent( - offerData: server.MediaEvent_OfferData( - tracksTypes: server.MediaEvent_OfferData_TrackTypes( - audio: audio, - video: video, - ), + await offerData(audio: audio, video: video); + await answer(); + } + + /// The server sends `offerData` asking for [audio] and [video] receive + /// transceivers. + Future offerData({int audio = 0, int video = 0}) => receive( + server.MediaEvent( + offerData: server.MediaEvent_OfferData( + tracksTypes: server.MediaEvent_OfferData_TrackTypes( + audio: audio, + video: video, ), ), - ); + ), + ); + + /// The server answers the client's last offer, mapping its mids. + Future answer() { final offer = sentOf(peer.MediaEvent_Content.sdpOffer).last.sdpOffer; - await receive( + return receive( server.MediaEvent( sdpAnswer: server.MediaEvent_SdpAnswer( sdp: 'v=0\r\ns=fake-answer\r\n', diff --git a/test/client/fishjam_client_test.dart b/test/client/fishjam_client_test.dart index f12b243..8cabb34 100644 --- a/test/client/fishjam_client_test.dart +++ b/test/client/fishjam_client_test.dart @@ -162,6 +162,54 @@ void main() { expect(h.eventsOf().single.reason, DisconnectReason.left); }); + test('connect() during the teardown of the previous session opens its ' + 'socket once that session has ended', () async { + h = ClientHarness(); + await h.join(); + final gate = h.transport.closeGate = Completer(); + final leaving = h.client.leave(); + await settle(); + final connecting = h.client.connect( + url: 'ws://fishjam.test', + token: 'peer-token', + ); + await settle(); + expect(h.transport.isOpen, isFalse); + h.events.clear(); + + gate.complete(); + await leaving; + await settle(); + await h.rejoin(); + await connecting; + expect(h.events.map((e) => e.runtimeType), [ + Disconnected, + AuthSucceeded, + Joined, + ]); + }); + + test('leave() while connect() waits for the previous teardown settles ' + 'it; its socket never opens', () async { + h = ClientHarness(); + await h.join(); + final gate = h.transport.closeGate = Completer(); + final leaving = h.client.leave(); + await settle(); + final connecting = expectLater( + h.client.connect(url: 'ws://fishjam.test', token: 'peer-token'), + throwsA(isA()), + ); + await settle(); + await h.client.leave(); + + gate.complete(); + await leaving; + await connecting; + expect(h.transport.connectedUrl, isNull); + expect(h.client.status, ConnectionStatus.disconnected); + }); + test('audio-only room: AuthSucceeded reports it and video tracks are ' 'rejected synchronously (SPEC §20)', () async { h = ClientHarness(); @@ -389,8 +437,7 @@ void main() { Future reconnectAndRestore() async { h.transport.serverClose(code: 1011, reason: 'boom'); await _wait(40); - await h.authenticate(); - await h.connectedEvent(); + await h.rejoin(); await h.negotiate(); await settle(); } @@ -410,8 +457,7 @@ void main() { expect(h.transports.created, hasLength(2)); expect(firstTransport.closedByClient, isTrue); expect(firstPc.isClosed, isTrue); - await h.authenticate(); - await h.connectedEvent(); + await h.rejoin(); await settle(); expect(h.eventsOf(), hasLength(1)); expect(h.client.status, ConnectionStatus.joined); @@ -425,23 +471,9 @@ void main() { final adding = h.client.addTrack(h.track('audio')); final result = expectLater(adding, throwsA(anything)); await settle(); - await h.receive( - server.MediaEvent( - offerData: server.MediaEvent_OfferData( - tracksTypes: server.MediaEvent_OfferData_TrackTypes(), - ), - ), - ); - final offer = h.sentOf(_C.sdpOffer).last.sdpOffer; + await h.offerData(); h.pc.failNext('setRemoteDescription', StateError('bad answer')); - await h.receive( - server.MediaEvent( - sdpAnswer: server.MediaEvent_SdpAnswer( - sdp: 'v=0', - midToTrackId: offer.midToTrackId.entries, - ), - ), - ); + await h.answer(); await result; await settle(); expect(h.eventsOf(), hasLength(1)); @@ -463,8 +495,7 @@ void main() { h.transport.serverClose(code: 1011, reason: 'boom'); await _wait(40); - await h.authenticate(); - await h.connectedEvent(); + await h.rejoin(); // The restored camera is being negotiated. expect(h.sentOf(_C.renegotiateTracks), isNotEmpty); await h.negotiate(); @@ -536,12 +567,10 @@ void main() { h.transport.serverClose(code: 1011, reason: 'boom'); await _wait(40); - await h.authenticate(); - await h.connectedEvent(); + await h.rejoin(); h.transport.serverClose(code: 1011, reason: 'boom'); // restore pending await _wait(60); - await h.authenticate(); - await h.connectedEvent(); + await h.rejoin(); await h.negotiate(); await settle(); @@ -561,8 +590,7 @@ void main() { h.events.clear(); h.transport.loseConnection(); await _wait(40); - await h.authenticate(); - await h.connectedEvent(); + await h.rejoin(); await settle(); expect(h.eventsOf().single.trackId, cam); expect(h.eventsOf(), isEmpty); @@ -630,8 +658,7 @@ void main() { client.events.listen(events.add); final connecting = client.connect(url: 'ws://f', token: 't'); await settle(); - await h.authenticate(); - await h.connectedEvent(); + await h.rejoin(); await connecting; failing = true; h.transport.serverClose(code: 1011, reason: 'boom'); @@ -687,8 +714,7 @@ void main() { expect(h.eventsOf(), isEmpty); await _wait(40); - await h.authenticate(); - await h.connectedEvent(); + await h.rejoin(); // Joined, but the track is still being restored. expect(h.eventsOf(), hasLength(2)); expect(h.client.status, ConnectionStatus.reconnecting); @@ -725,8 +751,7 @@ void main() { h.events.clear(); h.transport.serverClose(code: 1011, reason: 'boom'); await _wait(40); - await h.authenticate(); - await h.connectedEvent(endpoints: {'bob': endpoints['bob']!}); + await h.rejoin(endpoints: {'bob': endpoints['bob']!}); await settle(); final described = [ @@ -758,6 +783,9 @@ void main() { initialDelay: Duration(seconds: 5), delay: Duration(seconds: 5), ); + const offlineFor50ms = ReconnectConfig( + offlineTimeout: Duration(milliseconds: 50), + ); test('a change while joined replaces the session at once', () async { h = ClientHarness(reconnect: slow); @@ -768,8 +796,7 @@ void main() { expect(h.eventsOf(), hasLength(1)); expect(h.transports.created, hasLength(2)); expect(first.closedByClient, isTrue); - await h.authenticate(); - await h.connectedEvent(); + await h.rejoin(); expect(h.eventsOf(), hasLength(1)); expect(h.eventsOf(), isEmpty); }); @@ -829,11 +856,7 @@ void main() { }); test('offline for offlineTimeout ends the reconnection', () async { - h = ClientHarness( - reconnect: const ReconnectConfig( - offlineTimeout: Duration(milliseconds: 50), - ), - ); + h = ClientHarness(reconnect: offlineFor50ms); await h.join(); h.network.report({}); await _wait(100); @@ -875,13 +898,11 @@ void main() { await _wait(10); h.network.report({}); await _wait(10); - await h.authenticate(); - await h.connectedEvent(endpoints: {'bob': bob}); + await h.rejoin(endpoints: {'bob': bob}); h.network.report({'wifi'}); await _wait(10); expect(h.transports.created, hasLength(3)); - await h.authenticate(); - await h.connectedEvent(); + await h.rejoin(); await settle(); expect(remoteEvents(), [ @@ -900,14 +921,12 @@ void main() { h.events.clear(); h.network.report({'cellular'}); await _wait(10); - await h.authenticate(); // No negotiation follows, so the restore stays pending. - await h.connectedEvent(endpoints: {'bob': bob}); + await h.rejoin(endpoints: {'bob': bob}); h.network.report({'wifi'}); await _wait(10); expect(h.transports.created, hasLength(3)); - await h.authenticate(); - await h.connectedEvent(); + await h.rejoin(); await settle(); expect(remoteEvents(), [ @@ -919,6 +938,45 @@ void main() { }); }); + test('an attempt that joins while offline reports the old local tracks ' + 'removed right after its Joined', () async { + h = ClientHarness(reconnect: slow); + await h.join(); + final cam = await h.publish('video'); + h.events.clear(); + List sequence() => [ + for (final e in h.events) + switch (e) { + Joined() => 'Joined', + LocalTrackAdded(:final track) => 'added ${track.trackId}', + LocalTrackRemoved(:final trackId) => 'removed $trackId', + Reconnected() => 'Reconnected', + _ => null, + }, + ].nonNulls.toList(); + + h.network.report({'cellular'}); + await _wait(10); + h.network.report({}); + await _wait(10); + await h.rejoin(); + expect(sequence(), ['Joined', 'removed $cam']); + + h.network.report({'wifi'}); + await _wait(10); + await h.rejoin(); + await h.negotiate(); + await settle(); + final cam2 = h.eventsOf().single.track.trackId; + expect(sequence(), [ + 'Joined', + 'removed $cam', + 'Joined', + 'added $cam2', + 'Reconnected', + ]); + }); + group('only local tracks the app was told about are reported removed and ' 'restored', () { List localEvents() => [ @@ -940,8 +998,7 @@ void main() { await expectLater(adding, throwsStateError); await _wait(10); expect(h.transports.created, hasLength(2)); - await h.authenticate(); - await h.connectedEvent(); + await h.rejoin(); await settle(); expect(localEvents(), ['Reconnected']); @@ -955,57 +1012,30 @@ void main() { h.events.clear(); h.transport.serverClose(code: 1011, reason: 'boom'); await _wait(40); - await h.authenticate(); - await h.connectedEvent(); - await h.receive( - server.MediaEvent( - offerData: server.MediaEvent_OfferData( - tracksTypes: server.MediaEvent_OfferData_TrackTypes(), - ), - ), - ); - final offer = h.sentOf(_C.sdpOffer).last.sdpOffer; - final attempt = h.transport; + await h.rejoin(); + await h.offerData(); h.pc.setConnectionState(PeerConnectionState.failed); await settle(); - attempt.receive( - PeerMessage( - serverMediaEvent: server.MediaEvent( - sdpAnswer: server.MediaEvent_SdpAnswer( - sdp: 'v=0\r\ns=fake-answer\r\n', - midToTrackId: offer.midToTrackId.entries, - ), - ), - ), - ); + // The answer reaches the attempt after it was given up; the next + // attempt starts 40 ms after the loss. + await h.answer(); await _wait(40); - await h.authenticate(); - await h.connectedEvent(); + await h.rejoin(); await h.negotiate(); await settle(); - final events = localEvents(); - expect(events.first, 'removed $first'); - final added = { - for (final e in events) - if (e.startsWith('added ')) e.substring(6), - }; - final removed = [ - for (final e in events.skip(1)) - if (e.startsWith('removed ')) e.substring(8), - ]; - expect(removed.where((id) => !added.contains(id)), isEmpty); - expect(events.last, 'Reconnected'); + final restored = h.eventsOf().single.track.trackId; + expect(localEvents(), [ + 'removed $first', + 'added $restored', + 'Reconnected', + ]); }); }); test('connect() from a ReconnectionFailed listener is not failed by the ' 'teardown of the given-up session', () async { - h = ClientHarness( - reconnect: const ReconnectConfig( - offlineTimeout: Duration(milliseconds: 50), - ), - ); + h = ClientHarness(reconnect: offlineFor50ms); await h.join(); Object? retryError; var retried = false; @@ -1023,8 +1053,7 @@ void main() { await _wait(100); expect(retried, isTrue); expect(retryError, isNull); - await h.authenticate(); - await h.connectedEvent(); + await h.rejoin(); await settle(); expect(retryError, isNull); @@ -1045,17 +1074,17 @@ void main() { await settle(); h.network.report({'cellular'}); await settle(); - await h.authenticate(); - await h.connectedEvent(); + await h.rejoin(); await connected; await _wait(40); expect(h.eventsOf(), isEmpty); expect(h.transports.created, hasLength(1)); }); - test('with reconnection disabled, a change is ignored', () async { + test('with reconnection disabled, the network is not watched', () async { h = ClientHarness(reconnect: ReconnectConfig.disabled); await h.join(); + expect(h.network.isWatched, isFalse); h.network.report({'cellular'}); await _wait(40); expect(h.client.status, ConnectionStatus.joined); diff --git a/test/signaling/reconnect_test.dart b/test/signaling/reconnect_test.dart index 88e017c..f282fd1 100644 --- a/test/signaling/reconnect_test.dart +++ b/test/signaling/reconnect_test.dart @@ -96,6 +96,9 @@ const _config = ReconnectConfig( addTracksOnReconnect: false, ); +// The SDK defaults: tracks are restored. +const _restoring = ReconnectConfig(); + void main() { late _Host host; @@ -252,13 +255,7 @@ void main() { async.flushMicrotasks(); expect(host.restored, isEmpty); - // Every old track id is reported removed, before Reconnected. - expect( - host.events.whereType().map((e) => e.trackId), - ['t1'], - ); - expect(host.calls.sublist(host.calls.length - 3), [ - 'emit:LocalTrackRemoved', + expect(host.calls.sublist(host.calls.length - 2), [ 'releaseQueue', 'emit:Reconnected', ]); @@ -270,7 +267,7 @@ void main() { 'Reconnected', () { fakeAsync((async) { final ended = FakeMediaTrack.video()..end(); - final m = manager(const ReconnectConfig()) + final m = manager(_restoring) ..onRetryableLoss( _loss, snapshot( @@ -313,19 +310,6 @@ void main() { 'restoreTrack:mic', ]); expect(host.restored, unorderedEquals(['cam', 'mic'])); - // Every old track id is reported removed, in order, before the - // first restore (restored tracks get new ids). - expect( - host.events.whereType().map((e) => e.trackId), - ['cam', 'ended', 'muted', 'broken', 'mic'], - ); - final firstRestore = host.calls.indexOf('restoreTrack:cam'); - expect( - host.calls - .sublist(0, firstRestore) - .where((c) => c == 'emit:LocalTrackRemoved'), - hasLength(5), - ); expect(host.calls.sublist(host.calls.length - 2), [ 'releaseQueue', 'emit:Reconnected', @@ -337,7 +321,7 @@ void main() { test('a loss during restoration cancels Reconnected and schedules the ' 'next attempt', () { fakeAsync((async) { - final m = manager(const ReconnectConfig()) + final m = manager(_restoring) ..onRetryableLoss( _loss, snapshot( @@ -364,14 +348,9 @@ void main() { async.elapse(const Duration(milliseconds: 1000)); expect(host.count('startSession'), 2); - // The next attempt restores the snapshot again, but the old ids - // were already reported removed. + // The next attempt restores the snapshot again. unawaited(m.onSessionJoined()); async.flushMicrotasks(); - expect( - host.events.whereType().map((e) => e.trackId), - ['cam', 'mic'], - ); expect(host.count('restoreTrack:cam'), 2); expect(host.count('emit:Reconnected'), 1); }); @@ -401,7 +380,7 @@ void main() { test('a terminal close during restoration prevents Reconnected', () { fakeAsync((async) { - final m = manager(const ReconnectConfig()) + final m = manager(_restoring) ..onRetryableLoss( _loss, snapshot(tracks: [_track('cam', media: FakeMediaTrack.video())]), @@ -616,8 +595,8 @@ void main() { }); }); - test('an attempt in progress is abandoned; its ' - 'late start error is ignored', () { + test('a new network abandons the attempt in progress; its late start ' + 'error is ignored', () { fakeAsync((async) { final gate = host.startGate = Completer(); host.failingStarts = 1; @@ -625,7 +604,6 @@ void main() { async.elapse(const Duration(milliseconds: 500)); expect(host.count('startSession'), 1); - async.elapse(const Duration(milliseconds: 100)); m.onNetworkAvailable(); async.flushMicrotasks(); expect(host.count('startSession'), 2); @@ -641,7 +619,7 @@ void main() { 'and cancels Reconnected', () { fakeAsync((async) { final pending = host.pendingRestores['t1'] = Completer(); - final m = manager(const ReconnectConfig()) + final m = manager(_restoring) ..onRetryableLoss( _loss, snapshot(tracks: [_track('t1', media: FakeMediaTrack.video())]), @@ -681,7 +659,7 @@ void main() { 'complete the reconnection; the network back starts a new attempt', () { fakeAsync((async) { final pending = host.pendingRestores['t1'] = Completer(); - final m = manager(const ReconnectConfig()) + final m = manager(_restoring) ..onRetryableLoss( _loss, snapshot(tracks: [_track('t1', media: FakeMediaTrack.video())]), From 09b902b9a7aeff3758769f032998be062135ca40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Sun, 25 Oct 2026 15:04:51 +0100 Subject: [PATCH 25/29] Warn about a receive transceiver that cannot be recorded A receive transceiver whose receiver has no track has no id to record it by, so it is not counted and the next offerData adds another one. That was silent; it is now logged as a warning. Claude-Session: https://claude.ai/code/session_01Xn8b3WbMz12ct7ugWKC7hC --- lib/src/engine/receive_transceivers.dart | 15 ++++++++++++++- lib/src/engine/rtc_engine.dart | 2 +- test/engine/receive_transceivers_test.dart | 22 ++++++++++++++++++++++ test/fakes/fake_rtc.dart | 13 +++++++++---- 4 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 test/engine/receive_transceivers_test.dart diff --git a/lib/src/engine/receive_transceivers.dart b/lib/src/engine/receive_transceivers.dart index c6a80b4..677cecb 100644 --- a/lib/src/engine/receive_transceivers.dart +++ b/lib/src/engine/receive_transceivers.dart @@ -1,3 +1,4 @@ +import 'package:fishjam_client/src/logger.dart'; import 'package:fishjam_client/src/proto/fishjam/media_events/server/server.pb.dart' as server; import 'package:fishjam_client/src/rtc/rtc_adapter.dart'; @@ -12,6 +13,10 @@ import 'package:fishjam_client/src/rtc/rtc_adapter.dart'; /// transceiver disposes the handles of the ones without a mid, and on iOS all /// transceivers without a mid share one id. class ReceiveTransceivers { + ReceiveTransceivers({required this._logger}); + + final FishjamLogger _logger; + /// Missing transceivers are added video first (SPEC §9). static const List _kinds = [MediaKind.video, MediaKind.audio]; @@ -39,7 +44,15 @@ class ReceiveTransceivers { const TransceiverInit(direction: TransceiverDirection.recvonly), ); final id = transceiver.receiver.track?.id; - if (id != null) _live[kind]!.add(id); + if (id == null) { + // Unrecorded, it is added again on the next offerData. + _logger.warn( + 'A ${kind.name} receive transceiver has no receiver ' + 'track; it is not counted', + ); + } else { + _live[kind]!.add(id); + } } } } diff --git a/lib/src/engine/rtc_engine.dart b/lib/src/engine/rtc_engine.dart index 8fd66f9..7418b6c 100644 --- a/lib/src/engine/rtc_engine.dart +++ b/lib/src/engine/rtc_engine.dart @@ -91,7 +91,7 @@ class RtcEngine { RtcPeerConnection? _pc; /// The receive transceivers of [_pc]; one engine has one peer connection. - final _receive = ReceiveTransceivers(); + late final _receive = ReceiveTransceivers(logger: _logger); final _subscriptions = >[]; bool _hasRemoteDescription = false; final _pendingCandidates = []; diff --git a/test/engine/receive_transceivers_test.dart b/test/engine/receive_transceivers_test.dart new file mode 100644 index 0000000..5c8f482 --- /dev/null +++ b/test/engine/receive_transceivers_test.dart @@ -0,0 +1,22 @@ +import 'package:fishjam_client/src/engine/receive_transceivers.dart'; +import 'package:fishjam_client/src/proto/fishjam/media_events/server/server.pb.dart' + as server; +import 'package:fishjam_client/src/rtc/rtc_types.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../fakes/fake_rtc.dart'; +import '../tracks/log_capture.dart'; + +void main() { + test('a receive transceiver added without a receiver track is reported, ' + 'since it cannot be recorded', () async { + final logs = LogCapture(); + final receive = ReceiveTransceivers(logger: logs.logger); + final pc = FakePeerConnection(const RtcConfiguration()) + ..receiversWithoutTrack = true; + + await receive.sync(pc, server.MediaEvent_OfferData_TrackTypes(video: 1)); + + expect(logs.warnings, [contains('no receiver track')]); + }); +} diff --git a/test/fakes/fake_rtc.dart b/test/fakes/fake_rtc.dart index 3f25f9e..833a096 100644 --- a/test/fakes/fake_rtc.dart +++ b/test/fakes/fake_rtc.dart @@ -254,15 +254,17 @@ class FakeRtpSender implements RtcRtpSender { Future> getStats() async => stats; } -/// A fake receiver; it always has a remote track of its kind. +/// A fake receiver; it has a remote track of its kind unless created while +/// [FakePeerConnection.receiversWithoutTrack] was set. class FakeRtpReceiver implements RtcRtpReceiver { - FakeRtpReceiver._(this.kind) : track = FakeMediaTrack(kind: kind); + FakeRtpReceiver._(this.kind, {required bool withTrack}) + : track = withTrack ? FakeMediaTrack(kind: kind) : null; @override final MediaKind kind; @override - final RtcMediaTrack track; + final RtcMediaTrack? track; } /// A fake data channel. @@ -350,6 +352,9 @@ class FakePeerConnection implements RtcPeerConnection { /// Whether [close] was called. bool isClosed = false; + /// Transceivers added while set get a receiver without a track. + bool receiversWithoutTrack = false; + final List _transceivers = []; /// Bumped by every native `getTransceivers()`; views without a mid from an @@ -493,7 +498,7 @@ class FakePeerConnection implements RtcPeerConnection { track, init.sendEncodings.isEmpty ? const [RtpEncoding()] : init.sendEncodings, ), - receiver: FakeRtpReceiver._(kind), + receiver: FakeRtpReceiver._(kind, withTrack: !receiversWithoutTrack), ); _transceivers.add(state); return FakeRtpTransceiver._(state, this); From 97a8f85e33bf4e1f018e0000a0d42631addf2a1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Sun, 25 Oct 2026 15:04:51 +0100 Subject: [PATCH 26/29] Decide on a retryable loss synchronously A network change reports the loss and then acts on the reconnection it started, which worked only because the async loss handler ran its decision before its first await. The decision is now a plain synchronous method; only ending the session, when reconnection is refused, runs on. Claude-Session: https://claude.ai/code/session_01Xn8b3WbMz12ct7ugWKC7hC --- lib/src/fishjam_client.dart | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/src/fishjam_client.dart b/lib/src/fishjam_client.dart index c59f808..656a50e 100644 --- a/lib/src/fishjam_client.dart +++ b/lib/src/fishjam_client.dart @@ -426,7 +426,7 @@ class FishjamClient { connectError: connectExceptionFor(reason), ); } else if (reason is RetryableClose) { - await _onRetryableLoss(reason); + _onRetryableLoss(reason); } else { _reconnect.onTerminalClose(reason); await _finish(disconnectedFor(reason), error: errorEventFor(reason)); @@ -437,9 +437,7 @@ class FishjamClient { /// (the engine already emitted `ConnectionError`): a retryable loss. void _onConnectionFailed(_Session session) { if (!_isCurrent(session)) return; - unawaited( - _onRetryableLoss(const RetryableClose(reason: 'peer connection')), - ); + _onRetryableLoss(const RetryableClose(reason: 'peer connection')); } void _onServerError(_Session session) => @@ -474,9 +472,7 @@ class FishjamClient { // cancels it. if (_status == ConnectionStatus.joined && change != NetworkChange.available) { - unawaited( - _onRetryableLoss(RetryableClose(reason: 'network ${change.name}')), - ); + _onRetryableLoss(RetryableClose(reason: 'network ${change.name}')); } if (change == NetworkChange.lost) { _reconnect.onNetworkLost(); @@ -485,7 +481,9 @@ class FishjamClient { } } - Future _onRetryableLoss(RetryableClose reason) async { + /// Decides at once whether to reconnect, so a caller can act on the + /// reconnection right after; only ending the session runs on. + void _onRetryableLoss(RetryableClose reason) { final session = _session!; final snapshot = ReconnectSnapshot( peerMetadata: session.localTracks.peerMetadata, @@ -499,9 +497,11 @@ class FishjamClient { _status = ConnectionStatus.reconnecting; _stats.stop(); } else if (_reconnect.state == ReconnectState.failed) { - await _finish(const Disconnected(DisconnectReason.reconnectionFailed)); + unawaited( + _finish(const Disconnected(DisconnectReason.reconnectionFailed)), + ); } else { - await _finish(disconnectedFor(reason)); + unawaited(_finish(disconnectedFor(reason))); } } From 2e48e72d981d3048d50207de982dc0e9626b1e82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Sun, 25 Oct 2026 19:19:38 +0100 Subject: [PATCH 27/29] Name mobile networks as connectivity_plus does The network monitor reported connectivity_plus's `mobile` as `cellular`. Nothing depends on the name, so every kind now keeps the plugin's name. Claude-Session: https://claude.ai/code/session_01Xn8b3WbMz12ct7ugWKC7hC --- README.md | 4 ++-- SPEC.md | 6 ++--- lib/src/connectivity_network_monitor.dart | 5 +--- lib/src/signaling/network_monitor.dart | 4 ++-- .../connectivity_network_monitor_test.dart | 2 +- test/client/fishjam_client_test.dart | 24 +++++++++---------- test/signaling/network_watcher_test.dart | 12 +++++----- 7 files changed, 27 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 1245f29..b43cffd 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,9 @@ await client.dispose(); A lost connection is re-established automatically (`ReconnectConfig`): -- A change of network (Wi-Fi to cellular, a different Wi-Fi, going offline) starts reconnecting at once instead +- A change of network (Wi-Fi to mobile, a different Wi-Fi, going offline) starts reconnecting at once instead of waiting for the old connection to time out; while the device is offline, attempts wait for a network - (at most `ReconnectConfig.offlineTimeout`, 60 s by default). Gaining a network (cellular next to Wi-Fi) is + (at most `ReconnectConfig.offlineTimeout`, 60 s by default). Gaining a network (mobile next to Wi-Fi) is not a change. - From `ReconnectionStarted` until `Reconnected` the status is `reconnecting` and calls that need the session (track operations, metadata updates, `createDataChannels()`) fail at once with `StateError`. diff --git a/SPEC.md b/SPEC.md index 33a9e25..6fbe8c3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -215,7 +215,7 @@ States: `idle`, `reconnecting`, `failed`. ### 6.1 Network changes -The client watches the kinds of network the device uses (Wi-Fi, cellular, ethernet, VPN, other), as the OS +The client watches the kinds of network the device uses (Wi-Fi, mobile, ethernet, VPN, other), as the OS reports them. A socket and a peer connection are bound to the network they were opened on, so losing it means the session will not survive; reacting at once avoids waiting for a pong timeout or an ICE failure. @@ -228,8 +228,8 @@ the session will not survive; reacting at once avoids waiting for a pong timeout - Kinds of change: - **lost**: now offline; - **available**: online after being offline; - - **changed**: a network went away while another remains (Wi-Fi → cellular, a VPN going down). - - Only gaining a network (cellular added next to Wi-Fi, a VPN coming up) and a repeat of the same set are no + - **changed**: a network went away while another remains (Wi-Fi → mobile, a VPN going down). + - Only gaining a network (mobile added next to Wi-Fi, a VPN coming up) and a repeat of the same set are no change: the connection keeps its path. - A switch between two Wi-Fi networks looks like the same set: some platforms report it as offline and then online (`lost`, then `available`), others not at all, and then the socket or ICE reports the loss. diff --git a/lib/src/connectivity_network_monitor.dart b/lib/src/connectivity_network_monitor.dart index 2adffd6..1e4ba0f 100644 --- a/lib/src/connectivity_network_monitor.dart +++ b/lib/src/connectivity_network_monitor.dart @@ -13,9 +13,6 @@ final class ConnectivityNetworkMonitor implements NetworkMonitor { static Set _kinds(List results) => { for (final result in results) - if (result == ConnectivityResult.mobile) - 'cellular' - else if (result != ConnectivityResult.none) - result.name, + if (result != ConnectivityResult.none) result.name, }; } diff --git a/lib/src/signaling/network_monitor.dart b/lib/src/signaling/network_monitor.dart index c56337b..edfbb37 100644 --- a/lib/src/signaling/network_monitor.dart +++ b/lib/src/signaling/network_monitor.dart @@ -5,7 +5,7 @@ import 'package:fishjam_client/src/logger.dart'; /// The device's networks as the OS reports them. Signaling depends only on /// this seam; tests use `test/fakes/fake_network.dart`. abstract interface class NetworkMonitor { - /// The kinds of network in use (e.g. `wifi`, `cellular`); empty when + /// The kinds of network in use (e.g. `wifi`, `mobile`); empty when /// offline. Throws when the OS cannot be asked. Future> current(); @@ -90,7 +90,7 @@ class NetworkWatcher { if (now.isEmpty) return last.isEmpty ? null : NetworkChange.lost; if (last.isEmpty) return NetworkChange.available; // Only losing a network can break the connection's path; gaining one - // (cellular next to Wi-Fi, a VPN coming up) leaves it in place. + // (mobile data next to Wi-Fi, a VPN coming up) leaves it in place. return now.containsAll(last) ? null : NetworkChange.changed; } } diff --git a/test/client/connectivity_network_monitor_test.dart b/test/client/connectivity_network_monitor_test.dart index eba17c4..693739c 100644 --- a/test/client/connectivity_network_monitor_test.dart +++ b/test/client/connectivity_network_monitor_test.dart @@ -23,7 +23,7 @@ void main() { test('reports the kinds of network in use', () async { platform.current = [ConnectivityResult.mobile, ConnectivityResult.vpn]; - expect(await ConnectivityNetworkMonitor().current(), {'cellular', 'vpn'}); + expect(await ConnectivityNetworkMonitor().current(), {'mobile', 'vpn'}); }); test('none is reported as no network', () async { diff --git a/test/client/fishjam_client_test.dart b/test/client/fishjam_client_test.dart index 8cabb34..7031906 100644 --- a/test/client/fishjam_client_test.dart +++ b/test/client/fishjam_client_test.dart @@ -791,7 +791,7 @@ void main() { h = ClientHarness(reconnect: slow); await h.join(); final first = h.transport; - h.network.report({'cellular'}); + h.network.report({'mobile'}); await settle(); expect(h.eventsOf(), hasLength(1)); expect(h.transports.created, hasLength(2)); @@ -805,7 +805,7 @@ void main() { h = ClientHarness(reconnect: slow); await h.join(); h.transport.closeGate = Completer(); - h.network.report({'cellular'}); + h.network.report({'mobile'}); await settle(); expect(h.transports.created, hasLength(2)); expect(h.transport.sentMessages.single.hasAuthRequest(), isTrue); @@ -815,7 +815,7 @@ void main() { h = ClientHarness(reconnect: slow); await h.join(); final gate = h.transport.closeGate = Completer(); - h.network.report({'cellular'}); + h.network.report({'mobile'}); await settle(); gate.completeError(StateError('socket failed')); await _wait(10); @@ -830,7 +830,7 @@ void main() { await _wait(100); expect(h.eventsOf(), hasLength(1)); expect(h.transports.created, hasLength(1)); - h.network.report({'cellular'}); + h.network.report({'mobile'}); await settle(); expect(h.transports.created, hasLength(2)); }); @@ -838,7 +838,7 @@ void main() { test('gaining a network next to the current one changes nothing', () async { h = ClientHarness(reconnect: _fast); await h.join(); - h.network.report({'wifi', 'cellular'}); + h.network.report({'wifi', 'mobile'}); await _wait(40); expect(h.eventsOf(), isEmpty); expect(h.transports.created, hasLength(1)); @@ -849,7 +849,7 @@ void main() { await h.join(); h.network.report({}); await _wait(10); - h.network.report({'cellular'}); + h.network.report({'mobile'}); await _wait(100); expect(h.eventsOf(), hasLength(1)); expect(h.transports.created, hasLength(2)); @@ -894,7 +894,7 @@ void main() { h = ClientHarness(reconnect: slow); await h.join(); h.events.clear(); - h.network.report({'cellular'}); + h.network.report({'mobile'}); await _wait(10); h.network.report({}); await _wait(10); @@ -919,7 +919,7 @@ void main() { await h.join(); await h.publish('audio'); h.events.clear(); - h.network.report({'cellular'}); + h.network.report({'mobile'}); await _wait(10); // No negotiation follows, so the restore stays pending. await h.rejoin(endpoints: {'bob': bob}); @@ -955,7 +955,7 @@ void main() { }, ].nonNulls.toList(); - h.network.report({'cellular'}); + h.network.report({'mobile'}); await _wait(10); h.network.report({}); await _wait(10); @@ -994,7 +994,7 @@ void main() { await h.join(); final adding = h.client.addTrack(h.track('audio')); await settle(); - h.network.report({'cellular'}); + h.network.report({'mobile'}); await expectLater(adding, throwsStateError); await _wait(10); expect(h.transports.created, hasLength(2)); @@ -1072,7 +1072,7 @@ void main() { h = ClientHarness(reconnect: _fast); final connected = h.client.connect(url: 'ws://fishjam.test', token: 't'); await settle(); - h.network.report({'cellular'}); + h.network.report({'mobile'}); await settle(); await h.rejoin(); await connected; @@ -1085,7 +1085,7 @@ void main() { h = ClientHarness(reconnect: ReconnectConfig.disabled); await h.join(); expect(h.network.isWatched, isFalse); - h.network.report({'cellular'}); + h.network.report({'mobile'}); await _wait(40); expect(h.client.status, ConnectionStatus.joined); expect(h.eventsOf(), isEmpty); diff --git a/test/signaling/network_watcher_test.dart b/test/signaling/network_watcher_test.dart index 281c530..025a6ef 100644 --- a/test/signaling/network_watcher_test.dart +++ b/test/signaling/network_watcher_test.dart @@ -46,7 +46,7 @@ void main() { test('a report is acted on at once', () { run((async) { - monitor.report({'cellular'}); + monitor.report({'mobile'}); async.flushMicrotasks(); expect(changes, [NetworkChange.changed]); @@ -80,11 +80,11 @@ void main() { test('only gaining a network is no change; losing one later is', () { run((async) { - monitor.report({'wifi', 'cellular'}); + monitor.report({'wifi', 'mobile'}); async.flushMicrotasks(); expect(changes, isEmpty); - monitor.report({'cellular'}); + monitor.report({'mobile'}); async.flushMicrotasks(); expect(changes, [NetworkChange.changed]); }); @@ -160,7 +160,7 @@ void main() { monitor.currentError = StateError('no plugin'); run((async) { expect(monitor.isWatched, isFalse); - monitor.report({'cellular'}); + monitor.report({'mobile'}); async.flushMicrotasks(); expect(changes, isEmpty); @@ -193,10 +193,10 @@ void main() { async.flushMicrotasks(); expect(monitor.isWatched, isFalse); - monitor.report({'cellular'}); + monitor.report({'mobile'}); unawaited(watcher.start()); async.flushMicrotasks(); - monitor.report({'cellular'}); + monitor.report({'mobile'}); async.flushMicrotasks(); expect(changes, isEmpty); }); From 6e1fed70d6e4778f702af1411c7885a2cd9814cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Sun, 25 Oct 2026 19:46:40 +0100 Subject: [PATCH 28/29] Clarify names and comments in the reconnection code Renames only; no behavior change. - NetworkMonitor -> NetworkSource (own file), NetworkWatcher -> NetworkChangeDetector, ConnectivityNetworkMonitor -> ConnectivityNetworkSource - ReconnectManager: _fail -> _markFailed, _giveUp -> _failAndEndSession, _attempt -> _attemptsOnThisNetwork, _timer -> _nextAttemptTimer - FishjamClient: _onRetryableLoss -> _reconnectOrEnd, _abandon -> _markSessionLost, _ending -> _previousSessionEnded - ReceiveTransceivers and the transport: clearer field and local names - Shorter, literal comments; drop the ones that restate the code Claude-Session: https://claude.ai/code/session_016r1PaAEmRovribj1Mq23Bg --- AGENTS.md | 2 +- ....dart => connectivity_network_source.dart} | 6 +- lib/src/engine/receive_transceivers.dart | 55 ++++++--- lib/src/engine/rtc_engine.dart | 5 +- lib/src/fishjam_client.dart | 116 +++++++++--------- lib/src/models/config.dart | 2 +- .../signaling/network_change_detector.dart | 84 +++++++++++++ lib/src/signaling/network_monitor.dart | 96 --------------- lib/src/signaling/network_source.dart | 9 ++ lib/src/signaling/reconnect.dart | 89 +++++++------- lib/src/signaling/transport.dart | 23 ++-- test/client/client_harness.dart | 4 +- .../connectivity_missing_plugin_test.dart | 12 +- ... => connectivity_network_source_test.dart} | 6 +- test/fakes/fake_network.dart | 8 +- ...dart => network_change_detector_test.dart} | 90 +++++++------- tool/check_coverage.sh | 4 +- 17 files changed, 312 insertions(+), 299 deletions(-) rename lib/src/{connectivity_network_monitor.dart => connectivity_network_source.dart} (70%) create mode 100644 lib/src/signaling/network_change_detector.dart delete mode 100644 lib/src/signaling/network_monitor.dart create mode 100644 lib/src/signaling/network_source.dart rename test/client/{connectivity_network_monitor_test.dart => connectivity_network_source_test.dart} (86%) rename test/signaling/{network_watcher_test.dart => network_change_detector_test.dart} (67%) diff --git a/AGENTS.md b/AGENTS.md index 95347bd..d7b7dde 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ Run analyze, format and tests before every commit. | `lib/src/livestream/` | WHIP/WHEP over plain HTTP, independent of the room connection | | `lib/src/models/`, `wire.dart`, `logger.dart`, `version.dart` | public immutable types and shared helpers | | `lib/src/fishjam_client.dart`, `lib/src/livestream_api.dart`, `lib/fishjam_client.dart` | the public facade; wiring and type conversion only | -| `lib/src/connectivity_network_monitor.dart` | the `NetworkMonitor` seam (`signaling/network_monitor.dart`) on `connectivity_plus`; the only file that imports it | +| `lib/src/connectivity_network_source.dart` | the `NetworkSource` (`signaling/network_source.dart`) on `connectivity_plus`; the only file that imports it | | `test/fakes/`, `test/support/`, `test/fixtures/` | fakes, fixture replayer, golden sequences | ## Testing tools diff --git a/lib/src/connectivity_network_monitor.dart b/lib/src/connectivity_network_source.dart similarity index 70% rename from lib/src/connectivity_network_monitor.dart rename to lib/src/connectivity_network_source.dart index 1e4ba0f..ba14be0 100644 --- a/lib/src/connectivity_network_monitor.dart +++ b/lib/src/connectivity_network_source.dart @@ -1,8 +1,8 @@ import 'package:connectivity_plus/connectivity_plus.dart'; -import 'package:fishjam_client/src/signaling/network_monitor.dart'; +import 'package:fishjam_client/src/signaling/network_source.dart'; -/// [NetworkMonitor] backed by `connectivity_plus`. -final class ConnectivityNetworkMonitor implements NetworkMonitor { +/// [NetworkSource] backed by `connectivity_plus`. +final class ConnectivityNetworkSource implements NetworkSource { @override Future> current() async => _kinds(await Connectivity().checkConnectivity()); diff --git a/lib/src/engine/receive_transceivers.dart b/lib/src/engine/receive_transceivers.dart index 677cecb..55573a2 100644 --- a/lib/src/engine/receive_transceivers.dart +++ b/lib/src/engine/receive_transceivers.dart @@ -20,7 +20,9 @@ class ReceiveTransceivers { /// Missing transceivers are added video first (SPEC §9). static const List _kinds = [MediaKind.video, MediaKind.audio]; - final _live = >{ + /// Receiver track ids of our receive-only transceivers, per kind. A + /// transceiver is identified by its receiver's track id (see the class doc). + final _receiverTrackIds = >{ MediaKind.video: [], MediaKind.audio: [], }; @@ -31,14 +33,22 @@ class ReceiveTransceivers { RtcPeerConnection pc, server.MediaEvent_OfferData_TrackTypes types, ) async { - final wanted = {MediaKind.video: types.video, MediaKind.audio: types.audio}; - // Without an excess there is nothing to stop, and no native - // `getTransceivers()` is made. - if (_live.entries.any((e) => e.value.length > wanted[e.key]!)) { - await _stopExcess(pc, wanted); + final wantedCount = { + MediaKind.video: types.video, + MediaKind.audio: types.audio, + }; + // Skip the native `getTransceivers()` call when nothing needs stopping. + if (_receiverTrackIds.entries.any( + (e) => e.value.length > wantedCount[e.key]!, + )) { + await _stopExcess(pc, wantedCount); } for (final kind in _kinds) { - for (var i = _live[kind]!.length; i < wanted[kind]!; i++) { + for ( + var i = _receiverTrackIds[kind]!.length; + i < wantedCount[kind]!; + i++ + ) { final transceiver = await pc.addTransceiverOfKind( kind, const TransceiverInit(direction: TransceiverDirection.recvonly), @@ -51,7 +61,7 @@ class ReceiveTransceivers { 'track; it is not counted', ); } else { - _live[kind]!.add(id); + _receiverTrackIds[kind]!.add(id); } } } @@ -63,26 +73,31 @@ class ReceiveTransceivers { /// without a mid. Future _stopExcess( RtcPeerConnection pc, - Map wanted, + Map wantedCount, ) async { - final snapshot = { + // One native call. Transceivers without a receiver track are skipped (the + // `?` key). + final transceiverByTrackId = { for (final t in await pc.getTransceivers()) ?t.receiver.track?.id: t, }; - for (final ids in _live.values) { - // A recorded transceiver missing from the snapshot is gone already. - ids.removeWhere((id) => !snapshot.containsKey(id)); + for (final ids in _receiverTrackIds.values) { + // Forget ids whose transceiver no longer exists (e.g. the server + // rejected its media section), so the lookups below cannot fail. + ids.removeWhere((id) => !transceiverByTrackId.containsKey(id)); } - for (final negotiated in [false, true]) { + for (final stopNegotiated in [false, true]) { for (final kind in _kinds) { - final ids = _live[kind]!; - final excess = ids.length - wanted[kind]!; + final ids = _receiverTrackIds[kind]!; + final excess = ids.length - wantedCount[kind]!; if (excess <= 0) continue; - final victims = ids - .where((id) => (snapshot[id]!.mid != null) == negotiated) + final toStop = ids + .where( + (id) => (transceiverByTrackId[id]!.mid != null) == stopNegotiated, + ) .take(excess) .toList(); - for (final id in victims) { - await snapshot[id]!.stop(); + for (final id in toStop) { + await transceiverByTrackId[id]!.stop(); ids.remove(id); } } diff --git a/lib/src/engine/rtc_engine.dart b/lib/src/engine/rtc_engine.dart index 7418b6c..8342c3e 100644 --- a/lib/src/engine/rtc_engine.dart +++ b/lib/src/engine/rtc_engine.dart @@ -90,8 +90,7 @@ class RtcEngine { List _iceServers = const []; RtcPeerConnection? _pc; - /// The receive transceivers of [_pc]; one engine has one peer connection. - late final _receive = ReceiveTransceivers(logger: _logger); + late final _receiveTransceivers = ReceiveTransceivers(logger: _logger); final _subscriptions = >[]; bool _hasRemoteDescription = false; final _pendingCandidates = []; @@ -485,7 +484,7 @@ class RtcEngine { } try { final pc = await _ensurePeerConnection(); - await _receive.sync(pc, offerData.tracksTypes); + await _receiveTransceivers.sync(pc, offerData.tracksTypes); await _commands.drainBatch(); final offer = await pc.createOffer().timeout(nativeCallTimeout); // Native stacks emit candidates during setLocalDescription, before diff --git a/lib/src/fishjam_client.dart b/lib/src/fishjam_client.dart index 656a50e..0c1e443 100644 --- a/lib/src/fishjam_client.dart +++ b/lib/src/fishjam_client.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:fishjam_client/src/audio_output/audio_output_manager.dart'; -import 'package:fishjam_client/src/connectivity_network_monitor.dart'; +import 'package:fishjam_client/src/connectivity_network_source.dart'; import 'package:fishjam_client/src/data/data_channels.dart'; import 'package:fishjam_client/src/engine/rtc_engine.dart'; import 'package:fishjam_client/src/logger.dart'; @@ -17,7 +17,8 @@ import 'package:fishjam_client/src/rtc/flutter_webrtc_adapter.dart'; import 'package:fishjam_client/src/rtc/rtc_adapter.dart'; import 'package:fishjam_client/src/signaling/auth.dart'; import 'package:fishjam_client/src/signaling/message_queue.dart'; -import 'package:fishjam_client/src/signaling/network_monitor.dart'; +import 'package:fishjam_client/src/signaling/network_change_detector.dart'; +import 'package:fishjam_client/src/signaling/network_source.dart'; import 'package:fishjam_client/src/signaling/reconnect.dart'; import 'package:fishjam_client/src/signaling/signaling_session.dart'; import 'package:fishjam_client/src/signaling/transport.dart'; @@ -56,12 +57,12 @@ class FishjamClient { }) : this.internal( debug: debug, reconnect: reconnect, - networkMonitor: ConnectivityNetworkMonitor(), + networkSource: ConnectivityNetworkSource(), audioOutput: AudioOutputManager.instance, ); /// A client on a custom WebRTC adapter, socket transport and network - /// monitor (tests only; they default to `flutter_webrtc`, `dart:io` + /// source (tests only; they default to `flutter_webrtc`, `dart:io` /// WebSockets and no network watching). Without `audioOutput` the audio /// mode is never changed. @internal @@ -70,15 +71,14 @@ class FishjamClient { this.reconnect = const ReconnectConfig(), RtcAdapter? rtcAdapter, this._transportFactory, - NetworkMonitor? networkMonitor, + NetworkSource? networkSource, this._audioOutput, }) : _adapter = rtcAdapter ?? const FlutterWebrtcAdapter(), _logger = FishjamLogger(enabled: debug) { _reconnect = ReconnectManager(config: reconnect, host: _Host(this)); - // Network changes matter only to reconnection. - if (networkMonitor != null && reconnect.isEnabled) { - _network = NetworkWatcher( - monitor: networkMonitor, + if (networkSource != null && reconnect.isEnabled) { + _networkChangeDetector = NetworkChangeDetector( + source: networkSource, logger: _logger, onChanged: _onNetworkChanged, ); @@ -117,7 +117,7 @@ class FishjamClient { late final ReconnectManager _reconnect; late final DataChannelManager _dataChannels; late final StatsReporter _stats; - NetworkWatcher? _network; + NetworkChangeDetector? _networkChangeDetector; ConnectionStatus _status = ConnectionStatus.idle; @@ -132,9 +132,9 @@ class FishjamClient { /// Settles the pending `connect()` call exactly once. Completer? _connecting; - /// Completes when the teardown of the session given up last has finished - /// (its `Disconnected` was emitted); never fails. - Future _ending = Future.value(); + /// Completes after the last ended session finished its teardown and + /// emitted `Disconnected`. A new `connect()` waits for it. Never fails. + Future _previousSessionEnded = Future.value(); String _url = ''; String _token = ''; bool _disposed = false; @@ -143,10 +143,9 @@ class FishjamClient { /// by a session that was lost; reported gone before the next `Joined`. ({List peers, List components})? _staleRemote; - /// Local track ids the app got `LocalTrackAdded` for and no - /// `LocalTrackRemoved` yet. A track is registered before its negotiation - /// ends, and an abandoned session's events are not forwarded, so only these - /// are reported removed or restored on a reconnection. + /// Ids of local tracks the app received `LocalTrackAdded` for, and not yet + /// `LocalTrackRemoved`. On reconnection only these are reported removed and + /// restored, because the app does not know about the others. final _announcedLocalTracks = {}; /// All client events (broadcast, not replayed). @@ -177,18 +176,22 @@ class FishjamClient { _staleRemote = null; _announcedLocalTracks.clear(); _status = ConnectionStatus.connecting; - unawaited(_network?.start()); + unawaited(_networkChangeDetector?.start()); final connecting = _connecting = Completer(); - unawaited(_openAfterEnding(_newSession(), peerMetadata)); + unawaited(_openAfterPreviousSessionEnded(_newSession(), peerMetadata)); return await connecting.future; } - /// Opens [session] once the session given up before it has ended (e.g. - /// when connect() is called from a `ReconnectionFailed` listener), so that - /// session's `Disconnected` and audio mode change come first. - Future _openAfterEnding(_Session session, Object? peerMetadata) async { + /// Waits for the previous session to finish its teardown, then opens + /// [session]. This keeps the event order when the app calls `connect()` from + /// a `ReconnectionFailed` listener: the old `Disconnected` comes before the + /// new session starts. + Future _openAfterPreviousSessionEnded( + _Session session, + Object? peerMetadata, + ) async { try { - await _ending; + await _previousSessionEnded; if (_isCurrent(session)) await _open(session, peerMetadata); } on Object { // Reported through `closed`, which settles connect(). @@ -426,7 +429,7 @@ class FishjamClient { connectError: connectExceptionFor(reason), ); } else if (reason is RetryableClose) { - _onRetryableLoss(reason); + _reconnectOrEnd(reason); } else { _reconnect.onTerminalClose(reason); await _finish(disconnectedFor(reason), error: errorEventFor(reason)); @@ -437,7 +440,7 @@ class FishjamClient { /// (the engine already emitted `ConnectionError`): a retryable loss. void _onConnectionFailed(_Session session) { if (!_isCurrent(session)) return; - _onRetryableLoss(const RetryableClose(reason: 'peer connection')); + _reconnectOrEnd(const RetryableClose(reason: 'peer connection')); } void _onServerError(_Session session) => @@ -463,16 +466,15 @@ class FishjamClient { }); } - /// Losing the network, or the one in use, while joined is a loss: the - /// socket and the peer connection are bound to the network that went away. + /// While joined, losing or switching the network ends the session: the + /// socket and peer connection are bound to the old network. void _onNetworkChanged(NetworkChange change) { _logger.debug('Network ${change.name}'); - // The loss comes first, so the change below acts on the reconnection it - // starts: a new network starts the first attempt at once, and offline - // cancels it. + // Report the loss first so a reconnection exists. The calls below then + // act on it: online starts an attempt now, offline makes it wait. if (_status == ConnectionStatus.joined && change != NetworkChange.available) { - _onRetryableLoss(RetryableClose(reason: 'network ${change.name}')); + _reconnectOrEnd(RetryableClose(reason: 'network ${change.name}')); } if (change == NetworkChange.lost) { _reconnect.onNetworkLost(); @@ -481,9 +483,11 @@ class FishjamClient { } } - /// Decides at once whether to reconnect, so a caller can act on the - /// reconnection right after; only ending the session runs on. - void _onRetryableLoss(RetryableClose reason) { + /// Starts a reconnection or ends the session. Synchronous on purpose: + /// [_onNetworkChanged] calls the reconnect manager right after and needs the + /// reconnection to exist. Only ending the session (`_finish`) continues in + /// the background. + void _reconnectOrEnd(RetryableClose reason) { final session = _session!; final snapshot = ReconnectSnapshot( peerMetadata: session.localTracks.peerMetadata, @@ -493,7 +497,7 @@ class FishjamClient { ], ); if (_reconnect.onRetryableLoss(reason, snapshot)) { - _abandon(session); + _markSessionLost(session); _status = ConnectionStatus.reconnecting; _stats.stop(); } else if (_reconnect.state == ReconnectState.failed) { @@ -505,10 +509,10 @@ class FishjamClient { } } - /// From here on [session] reports nothing. Every path that replaces a - /// session comes through here, so the remote endpoints it announced are - /// reported gone before the next `Joined` (D22). - void _abandon(_Session session) { + /// Ignores all later events from [session]. If it had joined, saves its + /// remote peers and components so they are reported as gone before the + /// next `Joined` (SPEC §22, D22). + void _markSessionLost(_Session session) { if (session.isLost) return; session.isLost = true; if (session.isJoined) { @@ -538,14 +542,15 @@ class FishjamClient { _session = null; _staleRemote = null; _status = ConnectionStatus.disconnected; - // Taken now: the app may call connect() again while the teardown runs - // (e.g. from a `ReconnectionFailed` listener), and that call is not ours - // to fail. + // Take the pending `connect()` completer before the teardown. The app may + // call `connect()` again during the teardown (e.g. from a + // `ReconnectionFailed` listener); that new call must not be failed by + // this one. final connecting = _connecting; _connecting = null; - _network?.stop(); + _networkChangeDetector?.stop(); final ended = Completer(); - _ending = ended.future; + _previousSessionEnded = ended.future; try { await _teardown(session, sendDisconnect: sendDisconnect); _setCommunicationMode(on: false); @@ -576,13 +581,13 @@ class FishjamClient { await _dataChannels.close(); if (sendDisconnect) session.engine.sendDisconnect(); await session.engine.close(); - final closing = session.signaling.close(); + final socketClosing = session.signaling.close(); if (waitForSocket) { - await closing; + await socketClosing; } else { // The socket has often failed already, and then so does its close. unawaited( - closing.catchError( + socketClosing.catchError( (Object e) => _logger.debug('Closing the old socket failed: $e'), ), ); @@ -706,16 +711,15 @@ class _Host implements ReconnectHost { @override Future startSession(Object? peerMetadata) async { final old = _client._session; - // The new session becomes current first, so the old one is torn down - // silently (no `Disconnected`) and its late reports are ignored. + // Make the new session current first, so tearing down the old one emits + // no `Disconnected` and its late events are ignored. final session = _client._newSession(); if (old != null) { - // Without a loss of its own (an attempt replaced after its `Joined`), - // this is where it is given up. - _client._abandon(old); - // A socket on a network that went away can take seconds to close - // (the close handshake never completes); the new session does not - // need it gone. + // The old session may not be marked lost yet (an attempt replaced + // after its `Joined`). + _client._markSessionLost(old); + // Do not wait for the old socket to close: on a dead network the close + // handshake can take seconds. await _client._teardown(old, sendDisconnect: false, waitForSocket: false); } if (!identical(_client._session, session)) return; diff --git a/lib/src/models/config.dart b/lib/src/models/config.dart index 0aadd14..dc8090a 100644 --- a/lib/src/models/config.dart +++ b/lib/src/models/config.dart @@ -33,7 +33,7 @@ final class ReconnectConfig { final bool addTracksOnReconnect; /// How long a reconnection waits for the device to get a network before - /// it gives up. + /// it fails with `ReconnectionFailed`. final Duration offlineTimeout; bool get isEnabled => maxAttempts > 0; diff --git a/lib/src/signaling/network_change_detector.dart b/lib/src/signaling/network_change_detector.dart new file mode 100644 index 0000000..93d952d --- /dev/null +++ b/lib/src/signaling/network_change_detector.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +import 'package:fishjam_client/src/logger.dart'; +import 'package:fishjam_client/src/signaling/network_source.dart'; + +enum NetworkChange { + /// The device went offline. + lost, + + /// The device is online again after being offline. + available, + + /// A network went away while another remains. + changed, +} + +/// Compares each OS report with the previous one and calls [_onChanged] with +/// the kind of change. If the OS cannot be asked, logs a warning and reports +/// nothing (SPEC §6.1). +class NetworkChangeDetector { + NetworkChangeDetector({ + required this._source, + required this._logger, + required this._onChanged, + }); + + final NetworkSource _source; + final FishjamLogger _logger; + final void Function(NetworkChange change) _onChanged; + + StreamSubscription>? _subscription; + int _generation = 0; + + /// `null` until [start] reads the first value. + Set? _previousNetworks; + + /// Starts watching with a new baseline (stops a previous watch first). + Future start() async { + stop(); + final generation = _generation; + try { + // Read first: a platform that cannot be asked reports a failed + // subscription to the framework's error handler, not to the stream. + final networks = await _source.current(); + if (generation != _generation) return; + _subscription = _source.changes.listen( + _onNetworksReported, + onError: (Object e) { + _logger.warn('Network reports failed; stopped watching them', e); + stop(); + }, + ); + _previousNetworks = networks; + } on Object catch (e) { + if (generation == _generation) { + _logger.warn('Cannot read the network state; not watching it', e); + } + } + } + + void stop() { + _generation++; + unawaited(_subscription?.cancel()); + _subscription = null; + _previousNetworks = null; + } + + void _onNetworksReported(Set networks) { + final change = _classifyNetworkChange(_previousNetworks!, networks); + _previousNetworks = networks; + if (change != null) _onChanged(change); + } + + static NetworkChange? _classifyNetworkChange( + Set previous, + Set current, + ) { + if (current.isEmpty) return previous.isEmpty ? null : NetworkChange.lost; + if (previous.isEmpty) return NetworkChange.available; + // Only losing a network can break the connection's path; gaining one + // (mobile data next to Wi-Fi, a VPN coming up) leaves it in place. + return current.containsAll(previous) ? null : NetworkChange.changed; + } +} diff --git a/lib/src/signaling/network_monitor.dart b/lib/src/signaling/network_monitor.dart deleted file mode 100644 index edfbb37..0000000 --- a/lib/src/signaling/network_monitor.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'dart:async'; - -import 'package:fishjam_client/src/logger.dart'; - -/// The device's networks as the OS reports them. Signaling depends only on -/// this seam; tests use `test/fakes/fake_network.dart`. -abstract interface class NetworkMonitor { - /// The kinds of network in use (e.g. `wifi`, `mobile`); empty when - /// offline. Throws when the OS cannot be asked. - Future> current(); - - /// Every report the OS makes; a report may repeat the previous one. - Stream> get changes; -} - -enum NetworkChange { - /// The device went offline. - lost, - - /// The device is online again after being offline. - available, - - /// A network went away while another remains. - changed, -} - -/// Turns [NetworkMonitor] reports into [NetworkChange]s (SPEC §6.1): the -/// first value is the baseline, and every report is compared with the one -/// before it as it arrives. -/// When the OS cannot be asked, it logs a warning and reports nothing. -class NetworkWatcher { - NetworkWatcher({ - required this._monitor, - required this._logger, - required this._onChanged, - }); - - final NetworkMonitor _monitor; - final FishjamLogger _logger; - final void Function(NetworkChange change) _onChanged; - - StreamSubscription>? _subscription; - - /// Bumped by [stop] (and so by every [start]): a start still reading the - /// start value gives up when it is no longer the latest. - int _runs = 0; - - /// The networks of the last report; `null` before the baseline. - Set? _last; - - /// Starts watching with a new baseline (stops a previous watch first). - Future start() async { - stop(); - final run = _runs; - try { - // Read first: a platform that cannot be asked reports a failed - // subscription to the framework's error handler, not to the stream. - final networks = await _monitor.current(); - if (run != _runs) return; - _subscription = _monitor.changes.listen( - _onReport, - onError: (Object e) { - _logger.warn('Network reports failed; stopped watching them', e); - stop(); - }, - ); - _last = networks; - } on Object catch (e) { - // A newer start may be watching already; this one only had to give up. - if (run == _runs) { - _logger.warn('Cannot read the network state; not watching it', e); - } - } - } - - void stop() { - _runs++; - unawaited(_subscription?.cancel()); - _subscription = null; - _last = null; - } - - void _onReport(Set networks) { - final change = _classify(_last!, networks); - _last = networks; - if (change != null) _onChanged(change); - } - - static NetworkChange? _classify(Set last, Set now) { - if (now.isEmpty) return last.isEmpty ? null : NetworkChange.lost; - if (last.isEmpty) return NetworkChange.available; - // Only losing a network can break the connection's path; gaining one - // (mobile data next to Wi-Fi, a VPN coming up) leaves it in place. - return now.containsAll(last) ? null : NetworkChange.changed; - } -} diff --git a/lib/src/signaling/network_source.dart b/lib/src/signaling/network_source.dart new file mode 100644 index 0000000..028d7c2 --- /dev/null +++ b/lib/src/signaling/network_source.dart @@ -0,0 +1,9 @@ +/// Reports which kinds of network the device uses, as the OS sees them. +abstract interface class NetworkSource { + /// The kinds of network in use (e.g. `wifi`, `mobile`); empty when + /// offline. Throws when the OS cannot be asked. + Future> current(); + + /// Every report the OS makes; a report may repeat the previous one. + Stream> get changes; +} diff --git a/lib/src/signaling/reconnect.dart b/lib/src/signaling/reconnect.dart index 70f97f4..76d6d58 100644 --- a/lib/src/signaling/reconnect.dart +++ b/lib/src/signaling/reconnect.dart @@ -35,7 +35,7 @@ abstract interface class ReconnectHost { Future startSession(Object? peerMetadata); /// Tears the session down and emits `Disconnected(reason)`. Called only - /// when the manager gives up on its own (`ReconnectionFailed` was emitted + /// where no caller waits for a result (`ReconnectionFailed` was emitted /// just before): [startSession] threw on the last attempt, the device was /// offline for `offlineTimeout`, or the network changed after the total /// attempt cap was reached. Later reports about the abandoned session must @@ -71,8 +71,9 @@ abstract interface class ReconnectHost { /// `Disconnected(reconnectionFailed)` if [state] is /// [ReconnectState.failed], else `Disconnected(connectionLost)`; after a /// terminal close the caller emits the error event and `Disconnected`. -/// When the manager gives up on its own, it ends the session itself -/// through [ReconnectHost.endSession] (the cases are listed there). +/// Where no caller waits for a result (timers, network events), the +/// manager ends the session itself through [ReconnectHost.endSession] (the +/// cases are listed there). /// - With `addTracksOnReconnect`, only tracks with a live media track are /// re-added. Muted tracks (no media track) and ended tracks are /// not; a failed re-add does not stop the others. @@ -84,11 +85,11 @@ class ReconnectManager { ReconnectState _state = ReconnectState.idle; ReconnectSnapshot? _snapshot; - Timer? _timer; + Timer? _nextAttemptTimer; /// Number of attempts started on the current network in the current /// reconnection (the budget of `maxAttempts`). - int _attempt = 0; + int _attemptsOnThisNetwork = 0; /// Number of attempts started in the current reconnection on any network: /// a network that keeps changing restarts the budget, so this caps it. @@ -103,7 +104,8 @@ class ReconnectManager { /// instead of using up the budget. bool _online = true; - /// Runs while reconnecting offline; gives up at `offlineTimeout`. + /// Runs while reconnecting offline; fires [_failAndEndSession] at + /// `offlineTimeout`. Timer? _offlineTimer; /// Bumped whenever the current attempt is superseded, so a pending track @@ -136,18 +138,17 @@ class ReconnectManager { } return true; case ReconnectState.reconnecting: - if (_timer != null) return true; + if (_nextAttemptTimer != null) return true; _generation++; _attemptInProgress = false; - // Offline, the loss waits for the network (the offline timeout runs - // since it went away) instead of using up an attempt. + // Offline: wait for the network; do not count an attempt. if (!_online) return true; - if (_attempt < _config.maxAttempts && + if (_attemptsOnThisNetwork < _config.maxAttempts && _totalAttempts < _maxTotalAttempts) { _scheduleNextAttempt(); return true; } - _fail(); + _markFailed(); return false; } } @@ -159,7 +160,7 @@ class ReconnectManager { if (_state == ReconnectState.reconnecting) { _finish(ReconnectState.failed); } else { - _cancelTimer(); + _cancelNextAttemptTimer(); } } @@ -174,8 +175,9 @@ class ReconnectManager { if (_state != ReconnectState.reconnecting) return; if (_online && _config.addTracksOnReconnect) { final generation = _generation; - // Started together, so they ride one negotiation cycle and the media - // comes back at once. The host reports a failure; the others still go. + // Restore all tracks in parallel so they share one renegotiation. + // Errors are swallowed here: the host already reports them, and one + // failure must not stop the others. await Future.wait([ for (final track in _snapshot!.tracks) if (track.rtcTrack?.isLive ?? false) @@ -184,10 +186,9 @@ class ReconnectManager { if (generation != _generation) return; } if (!_online) { - // It got through just before the network went away, or lost it while - // restoring: it will not survive, and a network change is no loss while - // reconnecting, so the reconnection waits for the network (the offline - // timeout runs since it went away). + // The device went offline before or during the restore. This session + // will not survive, so do not emit `Reconnected`; wait for the network + // instead (the offline timeout is already running). _attemptInProgress = false; return; } @@ -201,24 +202,23 @@ class ReconnectManager { void onNetworkLost() { _online = false; if (_state != ReconnectState.reconnecting) return; - _cancelTimer(); + _cancelNextAttemptTimer(); _startOfflineTimeout(); } - /// The device has a network again, or a different one. While - /// reconnecting, the attempt budget starts over (earlier attempts failed on - /// a network that is gone) and a new attempt starts at once, superseding - /// any in progress. + /// The device is online again or switched networks. While reconnecting: + /// reset the attempt budget and start a new attempt now. void onNetworkAvailable() { _online = true; _cancelOfflineTimer(); if (_state != ReconnectState.reconnecting) return; if (_totalAttempts >= _maxTotalAttempts) { - // An attempt still in progress decides; with none, nothing would. - if (!_attemptInProgress) _giveUp(); + // If an attempt is still running, its result decides; otherwise fail + // now. + if (!_attemptInProgress) _failAndEndSession(); return; } - _cancelTimer(); + _cancelNextAttemptTimer(); _startAttempt(1); } @@ -232,39 +232,40 @@ class ReconnectManager { int get _maxTotalAttempts => 3 * _config.maxAttempts; - /// Gives up after `offlineTimeout` unless [onNetworkAvailable] comes - /// first. + /// Calls [_failAndEndSession] after `offlineTimeout` unless + /// [onNetworkAvailable] comes first. void _startOfflineTimeout() { _offlineTimer ??= Timer(_config.offlineTimeout, () { _offlineTimer = null; - _giveUp(); + _failAndEndSession(); }); } - /// Clears the queue and emits `ReconnectionFailed`; the caller ends the - /// session. - void _fail() { + /// Emits `ReconnectionFailed`. The caller ends the session (used when + /// [onRetryableLoss] returns `false`). + void _markFailed() { _finish(ReconnectState.failed); _host.emit(const ReconnectionFailed()); } - /// [_fail] when the manager gives up on its own, so it ends the session. - void _giveUp() { - _fail(); + /// Emits `ReconnectionFailed` and ends the session. Used where no caller is + /// waiting for a result (timers, network events). + void _failAndEndSession() { + _markFailed(); _host.endSession(DisconnectReason.reconnectionFailed); } void _scheduleNextAttempt() { - final attempt = _attempt + 1; - _timer = Timer(_config.delayBeforeAttempt(attempt), () { - _timer = null; + final attempt = _attemptsOnThisNetwork + 1; + _nextAttemptTimer = Timer(_config.delayBeforeAttempt(attempt), () { + _nextAttemptTimer = null; _startAttempt(attempt); }); } /// Starts [attempt], superseding any attempt still in progress. void _startAttempt(int attempt) { - _attempt = attempt; + _attemptsOnThisNetwork = attempt; _totalAttempts++; _attemptInProgress = true; final generation = ++_generation; @@ -292,12 +293,12 @@ class ReconnectManager { /// Leaves the current reconnection: cancels the timer, invalidates pending /// restores, and releases (success) or clears (otherwise) the queue. void _finish(ReconnectState state, {bool release = false}) { - _cancelTimer(); + _cancelNextAttemptTimer(); _cancelOfflineTimer(); _generation++; _state = state; _snapshot = null; - _attempt = 0; + _attemptsOnThisNetwork = 0; _totalAttempts = 0; _attemptInProgress = false; if (release) { @@ -307,9 +308,9 @@ class ReconnectManager { } } - void _cancelTimer() { - _timer?.cancel(); - _timer = null; + void _cancelNextAttemptTimer() { + _nextAttemptTimer?.cancel(); + _nextAttemptTimer = null; } void _cancelOfflineTimer() { diff --git a/lib/src/signaling/transport.dart b/lib/src/signaling/transport.dart index 6cb92b8..60d1803 100644 --- a/lib/src/signaling/transport.dart +++ b/lib/src/signaling/transport.dart @@ -92,9 +92,10 @@ final class WebSocketTransport implements SignalingTransport { WebSocket? _socket; - /// Opens [_socket]; its own, so that the connect timeout and [close] can - /// abort a connect in progress. - HttpClient? _client; + /// A dedicated `HttpClient` for this socket. Force-closing it is the only + /// way to abort a connect that is still in progress (on timeout or + /// [close]). + HttpClient? _httpClient; final _incoming = StreamController(); final _closed = Completer(); @@ -109,24 +110,20 @@ final class WebSocketTransport implements SignalingTransport { @override Future connect(Uri url) async { - if (_client != null) throw StateError('A transport can be used once'); - final client = _client = HttpClient(); + if (_httpClient != null) throw StateError('A transport can be used once'); + final httpClient = _httpClient = HttpClient(); try { - // Without a limit, a connect started on a network that went away hangs - // until the OS gives up, which can take minutes. _socket = await WebSocket.connect( url.toString(), - customClient: client, + customClient: httpClient, ).timeout(connectTimeout) ..pingInterval = pingInterval; } on Object catch (e) { _finish(TransportClosed(error: e)); throw TransportException('Cannot connect to $url', e); } finally { - // Aborts a connect that timed out; an open socket no longer belongs to - // the client. - client.close(force: true); + httpClient.close(force: true); } _socket!.listen( (data) { @@ -155,9 +152,7 @@ final class WebSocketTransport implements SignalingTransport { Future close([int code = 1000, String? reason]) async { final socket = _socket; if (socket == null) { - // A connect in progress fails at once instead of opening a socket - // nobody uses. - _client?.close(force: true); + _httpClient?.close(force: true); _finish(TransportClosed(code: code, reason: reason)); return; } diff --git a/test/client/client_harness.dart b/test/client/client_harness.dart index 8b255ab..7528acc 100644 --- a/test/client/client_harness.dart +++ b/test/client/client_harness.dart @@ -31,7 +31,7 @@ class ClientHarness { reconnect: reconnect, rtcAdapter: rtc, transportFactory: transports.call, - networkMonitor: network, + networkSource: network, audioOutput: audioOutput, ); _subscription = client.events.listen(events.add); @@ -41,7 +41,7 @@ class ClientHarness { final transports = FakeTransportFactory(); /// The device's networks, as the OS reports them. - final network = FakeNetworkMonitor(); + final network = FakeNetworkSource(); /// The WebRTC fake. final FakeRtcAdapter rtc; diff --git a/test/client/connectivity_missing_plugin_test.dart b/test/client/connectivity_missing_plugin_test.dart index 2490b8f..4da938a 100644 --- a/test/client/connectivity_missing_plugin_test.dart +++ b/test/client/connectivity_missing_plugin_test.dart @@ -1,6 +1,6 @@ -import 'package:fishjam_client/src/connectivity_network_monitor.dart'; +import 'package:fishjam_client/src/connectivity_network_source.dart'; import 'package:fishjam_client/src/logger.dart'; -import 'package:fishjam_client/src/signaling/network_monitor.dart'; +import 'package:fishjam_client/src/signaling/network_change_detector.dart'; import 'package:flutter_test/flutter_test.dart'; // No platform implementation is registered here, as in an app's widget test. @@ -8,8 +8,8 @@ void main() { testWidgets('without the plugin, watching only logs a warning; nothing is ' 'reported to the framework', (tester) async { final logs = []; - final watcher = NetworkWatcher( - monitor: ConnectivityNetworkMonitor(), + final detector = NetworkChangeDetector( + source: ConnectivityNetworkSource(), logger: FishjamLogger( enabled: true, sink: (level, message, {error, stackTrace}) => @@ -20,8 +20,8 @@ void main() { // Platform channels answer in real time. await tester.runAsync(() async { - await watcher.start(); - watcher.stop(); + await detector.start(); + detector.stop(); }); expect(logs, [startsWith('warn')]); diff --git a/test/client/connectivity_network_monitor_test.dart b/test/client/connectivity_network_source_test.dart similarity index 86% rename from test/client/connectivity_network_monitor_test.dart rename to test/client/connectivity_network_source_test.dart index 693739c..b2a8b1b 100644 --- a/test/client/connectivity_network_monitor_test.dart +++ b/test/client/connectivity_network_source_test.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:connectivity_plus_platform_interface/connectivity_plus_platform_interface.dart'; -import 'package:fishjam_client/src/connectivity_network_monitor.dart'; +import 'package:fishjam_client/src/connectivity_network_source.dart'; import 'package:flutter_test/flutter_test.dart'; class _FakePlatform extends ConnectivityPlatform { @@ -23,12 +23,12 @@ void main() { test('reports the kinds of network in use', () async { platform.current = [ConnectivityResult.mobile, ConnectivityResult.vpn]; - expect(await ConnectivityNetworkMonitor().current(), {'mobile', 'vpn'}); + expect(await ConnectivityNetworkSource().current(), {'mobile', 'vpn'}); }); test('none is reported as no network', () async { final reports = >[]; - final subscription = ConnectivityNetworkMonitor().changes.listen( + final subscription = ConnectivityNetworkSource().changes.listen( reports.add, ); addTearDown(subscription.cancel); diff --git a/test/fakes/fake_network.dart b/test/fakes/fake_network.dart index 57bf2ac..42c707b 100644 --- a/test/fakes/fake_network.dart +++ b/test/fakes/fake_network.dart @@ -1,15 +1,15 @@ -/// Scriptable [NetworkMonitor] for unit tests. +/// Scriptable [NetworkSource] for unit tests. library; import 'dart:async'; -import 'package:fishjam_client/src/signaling/network_monitor.dart'; +import 'package:fishjam_client/src/signaling/network_source.dart'; /// A fake OS network report: the test sets the current networks and pushes /// reports. -class FakeNetworkMonitor implements NetworkMonitor { +class FakeNetworkSource implements NetworkSource { /// Starts with [networks] in use. - FakeNetworkMonitor({Set networks = const {'wifi'}}) + FakeNetworkSource({Set networks = const {'wifi'}}) : _current = networks; Set _current; diff --git a/test/signaling/network_watcher_test.dart b/test/signaling/network_change_detector_test.dart similarity index 67% rename from test/signaling/network_watcher_test.dart rename to test/signaling/network_change_detector_test.dart index 025a6ef..f9fcdb1 100644 --- a/test/signaling/network_watcher_test.dart +++ b/test/signaling/network_change_detector_test.dart @@ -2,23 +2,23 @@ import 'dart:async'; import 'package:fake_async/fake_async.dart'; import 'package:fishjam_client/src/logger.dart'; -import 'package:fishjam_client/src/signaling/network_monitor.dart'; +import 'package:fishjam_client/src/signaling/network_change_detector.dart'; import 'package:flutter_test/flutter_test.dart'; import '../fakes/fake_network.dart'; void main() { - late FakeNetworkMonitor monitor; + late FakeNetworkSource source; late List changes; late List logs; - late NetworkWatcher watcher; + late NetworkChangeDetector detector; setUp(() { - monitor = FakeNetworkMonitor(); + source = FakeNetworkSource(); changes = []; logs = []; - watcher = NetworkWatcher( - monitor: monitor, + detector = NetworkChangeDetector( + source: source, logger: FishjamLogger( enabled: true, sink: (level, message, {error, stackTrace}) => @@ -28,16 +28,16 @@ void main() { ); }); - /// Starts the watcher inside [body]'s fake time. + /// Starts the detector inside [body]'s fake time. void run(void Function(FakeAsync async) body) => fakeAsync((async) { - unawaited(watcher.start()); + unawaited(detector.start()); async.flushMicrotasks(); body(async); }); test('the networks in use at start are the baseline, not a change', () { run((async) { - monitor.report({'wifi'}); + source.report({'wifi'}); async.flushMicrotasks(); expect(changes, isEmpty); @@ -46,7 +46,7 @@ void main() { test('a report is acted on at once', () { run((async) { - monitor.report({'mobile'}); + source.report({'mobile'}); async.flushMicrotasks(); expect(changes, [NetworkChange.changed]); @@ -56,7 +56,7 @@ void main() { test('offline is lost; online after offline is available, even when it ' 'comes right after', () { run((async) { - monitor + source ..report({}) ..report({'wifi'}); async.flushMicrotasks(); @@ -67,7 +67,7 @@ void main() { test('a repeated report is no change', () { run((async) { - monitor + source ..report({}) ..report({}) ..report({'wifi'}) @@ -80,11 +80,11 @@ void main() { test('only gaining a network is no change; losing one later is', () { run((async) { - monitor.report({'wifi', 'mobile'}); + source.report({'wifi', 'mobile'}); async.flushMicrotasks(); expect(changes, isEmpty); - monitor.report({'mobile'}); + source.report({'mobile'}); async.flushMicrotasks(); expect(changes, [NetworkChange.changed]); }); @@ -93,15 +93,15 @@ void main() { test('reports are listened to only once the start value was read, so an ' 'OS that cannot be asked is never subscribed to', () { fakeAsync((async) { - final gate = monitor.currentGate = Completer(); - unawaited(watcher.start()); + final gate = source.currentGate = Completer(); + unawaited(detector.start()); async.flushMicrotasks(); - expect(monitor.isWatched, isFalse); + expect(source.isWatched, isFalse); gate.complete(); async.flushMicrotasks(); - expect(monitor.isWatched, isTrue); - monitor.report({'wifi'}); + expect(source.isWatched, isTrue); + source.report({'wifi'}); async.flushMicrotasks(); expect(changes, isEmpty); }); @@ -110,26 +110,26 @@ void main() { test('of two starts in a row only the newer one listens, so stop ends ' 'watching', () { fakeAsync((async) { - unawaited(watcher.start()); - unawaited(watcher.start()); + unawaited(detector.start()); + unawaited(detector.start()); async.flushMicrotasks(); - watcher.stop(); + detector.stop(); async.flushMicrotasks(); - expect(monitor.isWatched, isFalse); + expect(source.isWatched, isFalse); }); }); test('stop while the start value is read: nothing is listened to', () { fakeAsync((async) { - final gate = monitor.currentGate = Completer(); - unawaited(watcher.start()); + final gate = source.currentGate = Completer(); + unawaited(detector.start()); async.flushMicrotasks(); - watcher.stop(); + detector.stop(); gate.complete(); async.flushMicrotasks(); - expect(monitor.isWatched, isFalse); + expect(source.isWatched, isFalse); }); }); @@ -137,19 +137,19 @@ void main() { 'a superseded start whose read fails leaves the newer watch running', () { fakeAsync((async) { - final gate = monitor.currentGate = Completer(); - unawaited(watcher.start()); + final gate = source.currentGate = Completer(); + unawaited(detector.start()); async.flushMicrotasks(); - monitor.currentGate = null; - unawaited(watcher.start()); + source.currentGate = null; + unawaited(detector.start()); async.flushMicrotasks(); - expect(monitor.isWatched, isTrue); + expect(source.isWatched, isTrue); - monitor.currentError = StateError('no answer'); + source.currentError = StateError('no answer'); gate.complete(); async.flushMicrotasks(); - expect(monitor.isWatched, isTrue); + expect(source.isWatched, isTrue); expect(logs, isEmpty); }); }, @@ -157,10 +157,10 @@ void main() { test('when the OS cannot be asked, it warns, reports nothing and does not ' 'listen', () { - monitor.currentError = StateError('no plugin'); + source.currentError = StateError('no plugin'); run((async) { - expect(monitor.isWatched, isFalse); - monitor.report({'mobile'}); + expect(source.isWatched, isFalse); + source.report({'mobile'}); async.flushMicrotasks(); expect(changes, isEmpty); @@ -170,7 +170,7 @@ void main() { test('when the report stream cannot be opened, it warns and reports ' 'nothing', () { - monitor.changesError = StateError('no binding'); + source.changesError = StateError('no binding'); run((async) { expect(logs, [startsWith('warn')]); expect(changes, isEmpty); @@ -179,24 +179,24 @@ void main() { test('a failing report stream warns and stops watching', () { run((async) { - monitor.fail(StateError('channel gone')); + source.fail(StateError('channel gone')); async.flushMicrotasks(); expect(logs, [startsWith('warn')]); - expect(monitor.isWatched, isFalse); + expect(source.isWatched, isFalse); }); }); test('stop ends watching; start after stop takes a new baseline', () { run((async) { - watcher.stop(); + detector.stop(); async.flushMicrotasks(); - expect(monitor.isWatched, isFalse); + expect(source.isWatched, isFalse); - monitor.report({'mobile'}); - unawaited(watcher.start()); + source.report({'mobile'}); + unawaited(detector.start()); async.flushMicrotasks(); - monitor.report({'mobile'}); + source.report({'mobile'}); async.flushMicrotasks(); expect(changes, isEmpty); }); diff --git a/tool/check_coverage.sh b/tool/check_coverage.sh index 1d05e6c..2ca6ae1 100755 --- a/tool/check_coverage.sh +++ b/tool/check_coverage.sh @@ -13,6 +13,7 @@ # Never recorded (no executable code): # lib/fishjam_client.dart exports only # lib/src/version.dart constants only +# lib/src/signaling/network_source.dart interface only set -euo pipefail LCOV="${1:-coverage/lcov.info}" @@ -27,7 +28,8 @@ unrecorded=0 while IFS= read -r file; do case "$file" in lib/src/proto/* | lib/src/rtc/flutter_webrtc_adapter*.dart | \ - lib/fishjam_client.dart | lib/src/version.dart) continue ;; + lib/fishjam_client.dart | lib/src/version.dart | \ + lib/src/signaling/network_source.dart) continue ;; esac if ! grep -qx "SF:$file" "$LCOV"; then echo "FAIL: $file has no coverage record; load it from a test" >&2 From dd3af056f7598eae68d300bcaf051a2784723915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Sun, 25 Oct 2026 21:50:14 +0100 Subject: [PATCH 29/29] Rename NetworkSource members; fix two reconnection comments - NetworkSource: current() -> getNetworkTypes(), changes -> networkTypesChanged (and the fake's matching fields) - NetworkChangeDetector: _previousNetworks -> _previousNetworkStates, set before subscribing so it is never read unset - The track restore comment no longer claims failures are reported - Drop a comment in ReceiveTransceivers that restated the code Claude-Session: https://claude.ai/code/session_016r1PaAEmRovribj1Mq23Bg --- lib/src/connectivity_network_source.dart | 4 ++-- lib/src/engine/receive_transceivers.dart | 2 -- .../signaling/network_change_detector.dart | 14 +++++------ lib/src/signaling/network_source.dart | 4 ++-- lib/src/signaling/reconnect.dart | 6 ++--- .../connectivity_network_source_test.dart | 7 ++++-- test/fakes/fake_network.dart | 24 +++++++++---------- .../network_change_detector_test.dart | 14 +++++------ 8 files changed, 38 insertions(+), 37 deletions(-) diff --git a/lib/src/connectivity_network_source.dart b/lib/src/connectivity_network_source.dart index ba14be0..08f351f 100644 --- a/lib/src/connectivity_network_source.dart +++ b/lib/src/connectivity_network_source.dart @@ -4,11 +4,11 @@ import 'package:fishjam_client/src/signaling/network_source.dart'; /// [NetworkSource] backed by `connectivity_plus`. final class ConnectivityNetworkSource implements NetworkSource { @override - Future> current() async => + Future> getNetworkTypes() async => _kinds(await Connectivity().checkConnectivity()); @override - Stream> get changes => + Stream> get networkTypesChanged => Connectivity().onConnectivityChanged.map(_kinds); static Set _kinds(List results) => { diff --git a/lib/src/engine/receive_transceivers.dart b/lib/src/engine/receive_transceivers.dart index 55573a2..d9f6598 100644 --- a/lib/src/engine/receive_transceivers.dart +++ b/lib/src/engine/receive_transceivers.dart @@ -75,8 +75,6 @@ class ReceiveTransceivers { RtcPeerConnection pc, Map wantedCount, ) async { - // One native call. Transceivers without a receiver track are skipped (the - // `?` key). final transceiverByTrackId = { for (final t in await pc.getTransceivers()) ?t.receiver.track?.id: t, }; diff --git a/lib/src/signaling/network_change_detector.dart b/lib/src/signaling/network_change_detector.dart index 93d952d..6c73ff5 100644 --- a/lib/src/signaling/network_change_detector.dart +++ b/lib/src/signaling/network_change_detector.dart @@ -32,7 +32,7 @@ class NetworkChangeDetector { int _generation = 0; /// `null` until [start] reads the first value. - Set? _previousNetworks; + Set? _previousNetworkStates; /// Starts watching with a new baseline (stops a previous watch first). Future start() async { @@ -41,16 +41,16 @@ class NetworkChangeDetector { try { // Read first: a platform that cannot be asked reports a failed // subscription to the framework's error handler, not to the stream. - final networks = await _source.current(); + final networks = await _source.getNetworkTypes(); if (generation != _generation) return; - _subscription = _source.changes.listen( + _previousNetworkStates = networks; + _subscription = _source.networkTypesChanged.listen( _onNetworksReported, onError: (Object e) { _logger.warn('Network reports failed; stopped watching them', e); stop(); }, ); - _previousNetworks = networks; } on Object catch (e) { if (generation == _generation) { _logger.warn('Cannot read the network state; not watching it', e); @@ -62,12 +62,12 @@ class NetworkChangeDetector { _generation++; unawaited(_subscription?.cancel()); _subscription = null; - _previousNetworks = null; + _previousNetworkStates = null; } void _onNetworksReported(Set networks) { - final change = _classifyNetworkChange(_previousNetworks!, networks); - _previousNetworks = networks; + final change = _classifyNetworkChange(_previousNetworkStates!, networks); + _previousNetworkStates = networks; if (change != null) _onChanged(change); } diff --git a/lib/src/signaling/network_source.dart b/lib/src/signaling/network_source.dart index 028d7c2..242dcf5 100644 --- a/lib/src/signaling/network_source.dart +++ b/lib/src/signaling/network_source.dart @@ -2,8 +2,8 @@ abstract interface class NetworkSource { /// The kinds of network in use (e.g. `wifi`, `mobile`); empty when /// offline. Throws when the OS cannot be asked. - Future> current(); + Future> getNetworkTypes(); /// Every report the OS makes; a report may repeat the previous one. - Stream> get changes; + Stream> get networkTypesChanged; } diff --git a/lib/src/signaling/reconnect.dart b/lib/src/signaling/reconnect.dart index 76d6d58..0050bef 100644 --- a/lib/src/signaling/reconnect.dart +++ b/lib/src/signaling/reconnect.dart @@ -175,9 +175,9 @@ class ReconnectManager { if (_state != ReconnectState.reconnecting) return; if (_online && _config.addTracksOnReconnect) { final generation = _generation; - // Restore all tracks in parallel so they share one renegotiation. - // Errors are swallowed here: the host already reports them, and one - // failure must not stop the others. + // Start all restores at once so they usually share one renegotiation. + // A failed restore is ignored, so the others still run; that track is + // not published again and the app gets no `LocalTrackAdded` for it. await Future.wait([ for (final track in _snapshot!.tracks) if (track.rtcTrack?.isLive ?? false) diff --git a/test/client/connectivity_network_source_test.dart b/test/client/connectivity_network_source_test.dart index b2a8b1b..afb42e9 100644 --- a/test/client/connectivity_network_source_test.dart +++ b/test/client/connectivity_network_source_test.dart @@ -23,12 +23,15 @@ void main() { test('reports the kinds of network in use', () async { platform.current = [ConnectivityResult.mobile, ConnectivityResult.vpn]; - expect(await ConnectivityNetworkSource().current(), {'mobile', 'vpn'}); + expect(await ConnectivityNetworkSource().getNetworkTypes(), { + 'mobile', + 'vpn', + }); }); test('none is reported as no network', () async { final reports = >[]; - final subscription = ConnectivityNetworkSource().changes.listen( + final subscription = ConnectivityNetworkSource().networkTypesChanged.listen( reports.add, ); addTearDown(subscription.cancel); diff --git a/test/fakes/fake_network.dart b/test/fakes/fake_network.dart index 42c707b..9063cfc 100644 --- a/test/fakes/fake_network.dart +++ b/test/fakes/fake_network.dart @@ -15,30 +15,30 @@ class FakeNetworkSource implements NetworkSource { Set _current; final _reports = StreamController>.broadcast(); - /// When set, [current] waits for it. - Completer? currentGate; + /// When set, [getNetworkTypes] waits for it. + Completer? getNetworkTypesGate; - /// When set, [current] throws it. - Error? currentError; + /// When set, [getNetworkTypes] throws it. + Error? getNetworkTypesError; - /// When set, reading [changes] throws it. - Error? changesError; + /// When set, reading [networkTypesChanged] throws it. + Error? networkTypesChangedError; - /// Whether anyone listens to [changes]. + /// Whether anyone listens to [networkTypesChanged]. bool get isWatched => _reports.hasListener; @override - Future> current() async { - final gate = currentGate; + Future> getNetworkTypes() async { + final gate = getNetworkTypesGate; if (gate != null) await gate.future; - final error = currentError; + final error = getNetworkTypesError; if (error != null) throw error; return _current; } @override - Stream> get changes { - final error = changesError; + Stream> get networkTypesChanged { + final error = networkTypesChangedError; if (error != null) throw error; return _reports.stream; } diff --git a/test/signaling/network_change_detector_test.dart b/test/signaling/network_change_detector_test.dart index f9fcdb1..b27304b 100644 --- a/test/signaling/network_change_detector_test.dart +++ b/test/signaling/network_change_detector_test.dart @@ -93,7 +93,7 @@ void main() { test('reports are listened to only once the start value was read, so an ' 'OS that cannot be asked is never subscribed to', () { fakeAsync((async) { - final gate = source.currentGate = Completer(); + final gate = source.getNetworkTypesGate = Completer(); unawaited(detector.start()); async.flushMicrotasks(); expect(source.isWatched, isFalse); @@ -122,7 +122,7 @@ void main() { test('stop while the start value is read: nothing is listened to', () { fakeAsync((async) { - final gate = source.currentGate = Completer(); + final gate = source.getNetworkTypesGate = Completer(); unawaited(detector.start()); async.flushMicrotasks(); detector.stop(); @@ -137,15 +137,15 @@ void main() { 'a superseded start whose read fails leaves the newer watch running', () { fakeAsync((async) { - final gate = source.currentGate = Completer(); + final gate = source.getNetworkTypesGate = Completer(); unawaited(detector.start()); async.flushMicrotasks(); - source.currentGate = null; + source.getNetworkTypesGate = null; unawaited(detector.start()); async.flushMicrotasks(); expect(source.isWatched, isTrue); - source.currentError = StateError('no answer'); + source.getNetworkTypesError = StateError('no answer'); gate.complete(); async.flushMicrotasks(); @@ -157,7 +157,7 @@ void main() { test('when the OS cannot be asked, it warns, reports nothing and does not ' 'listen', () { - source.currentError = StateError('no plugin'); + source.getNetworkTypesError = StateError('no plugin'); run((async) { expect(source.isWatched, isFalse); source.report({'mobile'}); @@ -170,7 +170,7 @@ void main() { test('when the report stream cannot be opened, it warns and reports ' 'nothing', () { - source.changesError = StateError('no binding'); + source.networkTypesChangedError = StateError('no binding'); run((async) { expect(logs, [startsWith('warn')]); expect(changes, isEmpty);