Skip to content

Reconnect at once when the device network changes - #3

Merged
Magmusacy merged 30 commits into
mainfrom
FCE-3999/reconnect
Oct 26, 2026
Merged

Magmusacy merged 30 commits into
mainfrom
FCE-3999/reconnect

Conversation

@Magmusacy

@Magmusacy Magmusacy commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Reconnect as soon as the device's network changes, instead of waiting for the old connection to time out.

SDK

  • Network changes come from connectivity_plus. Losing or switching a network while joined starts a reconnection at once.
  • While offline, attempts wait for a network (ReconnectConfig.offlineTimeout, 60 s by default) instead of being used up.
  • A dead connection is noticed sooner: ICE disconnected for 3 s counts as lost.
  • Opening the socket times out after 10 s, so an attempt on a network with no internet fails and retries instead of waiting minutes for the OS.
  • Local tracks are restored together in one negotiation. Behavior change: addTracksOnReconnect now defaults to true.
  • connect() may be called from a ReconnectionFailed listener; it opens once the previous session has ended.
  • Fixes an Android failure to create an offer when a track was added after an earlier negotiation (camera, microphone or screen share during a call, or a reconnection restoring tracks).

Example app

  • Removed the data channel chat; the room screen shows only media, mute toggles and leave.
  • The join form can be prefilled from a file: copy example/.env.example to .env.local and run flutter run --dart-define-from-file=.env.local.

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
… (SPEC §9)

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
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
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
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
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
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
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
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
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
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
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
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
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
…t 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
@linear

linear Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

…ctionFailed

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
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
Brings in audio output routing. The client takes both a network monitor
and an audio output manager; the reconnection decision entries move to
D24–D27 after main's D23.

Claude-Session: https://claude.ai/code/session_01Q5vZKmaVuaPpgoRaUvLQT8
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
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
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
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
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
…ection

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
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
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
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
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
- 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
@Magmusacy
Magmusacy marked this pull request as ready for review October 25, 2026 21:02

@graszka22 graszka22 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This behaves differently than RN (addTracksOnReconnect default, detecting network changes) but i think flutter sdk's behaviour now makes more sense.

@Magmusacy
Magmusacy merged commit 6afb065 into main Oct 26, 2026
2 checks passed
@Magmusacy
Magmusacy deleted the FCE-3999/reconnect branch October 26, 2026 21:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants