Summary
mobile/lib/shared/relay/ is missing every connection-resilience mechanism that desktop/src/shared/api/relayClientSession.ts has accumulated. The most serious gap is that mobile has no stall detection at all, so a half-open socket on cellular is indistinguishable from a healthy idle one — the app sits believing it is connected and delivers nothing until the user force-quits.
Reported by a Play Store user of the current release: "constantly reconnecting and disconnecting and not being able to load, constantly having to force quit the app just to see a message."
All line references below are against 9810d8545 (main).
Why the stall is invisible today
The relay pings every 30s and closes after 3 missed pongs (crates/buzz-relay/src/connection.rs:373-400). That protects the relay from dead clients. It does nothing for the client: when Android's radio blackholes an idle TCP socket, the phone's WebSocket never receives a close frame, onDone/onError never fire (mobile/lib/shared/relay/relay_socket.dart:91-104), and RelaySessionNotifier is never told to reconnect. The socket is dead, the UI says connected.
Desktop solved exactly this in relayStallWatchdog.ts — a passive watchdog that treats inbound traffic (including the relay's own pings) as the liveness signal, and fires after two missed heartbeat windows:
// desktop/src/shared/api/relayClientSession.ts:79-80
const STALL_CHECK_INTERVAL_MS = 10_000;
const STALL_IDLE_TIMEOUT_MS = 60_000;
Mobile has no equivalent. grep -niE 'ping|pong|idle|keepalive|watchdog' mobile/lib/shared/relay/relay_socket.dart returns zero matches across all 251 lines.
The five gaps
| # |
Gap |
Mobile |
Desktop equivalent |
| 1 |
No stall detection |
nothing |
relayStallWatchdog.ts; wired at relayClientSession.ts:120-124, :596, :770 |
| 2 |
8s timeouts |
relay_socket.dart:99; relay_session.dart:133,185,251 |
25s — relayClientSession.ts:63-65 |
| 3 |
No backoff jitter |
relay_session.dart:398-412, flat delay * 2 |
±25% — relayClientSession.ts:985-989 |
| 4 |
Backoff resets on connect, not on stability |
relay_session.dart:378 resets to 1s the moment auth succeeds |
BACKOFF_RESET_STABLE_MS = 60_000 — relayClientSession.ts:73, applied :592 |
| 5 |
NOTICE frames dropped |
relay_session.dart:429-443 switches on EVENT/EOSE/CLOSED/OK only |
desktop parses relay rate-limited: notices |
Gaps 3+4 compound: a socket that connects and dies after 5s resets its own backoff to 1s every cycle, so a flapping connection hammers the relay at full rate indefinitely instead of backing off. Gap 5 means the relay's explicit "slow down" is ignored while that happens.
Gap 2 is worth stating plainly: mobile — the more degraded network environment — has tighter timeouts than desktop. 8s is not much more than DNS + TLS + NIP-42 round-trip on a weak cellular link, so a merely slow handshake is classified as a failure and triggers a reconnect, which is then also slow. That is a self-sustaining loop that never converges.
Suggested fix
Gap 1 is the one that matters and it is small. mobile/pubspec.yaml:20 pins web_socket_channel: ^3.0.1 (lockfile: 3.0.3), which already ships IOWebSocketChannel.connect(..., pingInterval:):
// web_socket_channel-3.0.3/lib/io.dart:23-27
/// [pingInterval] controls the interval for sending ping signals. If a ping
/// message is not answered by a pong message from the peer, the WebSocket is
/// assumed disconnected and the connection is closed with a `goingAway` code.
relay_socket.dart:66 currently calls the generic WebSocketChannel.connect(...), which routes to AdapterWebSocketChannel and exposes no pingInterval (lib/src/channel.dart:107-108). Mobile has no web target (mobile/ has android/ and ios/ only), so switching that one call to IOWebSocketChannel.connect(..., pingInterval: Duration(seconds: 20)) makes the dead socket surface as a normal close through the existing onDone path — no new lifecycle handling required, and _scheduleReconnect takes it from there.
That said, desktop deliberately chose a passive watchdog over active probing, for a Tauri-specific reason documented at the top of relayStallWatchdog.ts. Either shape works here; the port of desktop's passive watchdog is the more conservative choice if you'd rather keep the two clients symmetric.
Gaps 2–5 are constant changes and one added case in _handleMessage.
Testing notes
mobile/test/shared/relay/relay_session_test.dart already exists and exercises the session notifier, so reconnect-timing and NOTICE-handling cases have a home. There is currently no test covering socket liveness.
Offer
Happy to open a PR for this if it is not already in progress — stall detection first as its own change, then the four constant/handler fixes separately. Say the word and I will keep them independently reviewable.
Summary
mobile/lib/shared/relay/is missing every connection-resilience mechanism thatdesktop/src/shared/api/relayClientSession.tshas accumulated. The most serious gap is that mobile has no stall detection at all, so a half-open socket on cellular is indistinguishable from a healthy idle one — the app sits believing it is connected and delivers nothing until the user force-quits.Reported by a Play Store user of the current release: "constantly reconnecting and disconnecting and not being able to load, constantly having to force quit the app just to see a message."
All line references below are against
9810d8545(main).Why the stall is invisible today
The relay pings every 30s and closes after 3 missed pongs (
crates/buzz-relay/src/connection.rs:373-400). That protects the relay from dead clients. It does nothing for the client: when Android's radio blackholes an idle TCP socket, the phone'sWebSocketnever receives a close frame,onDone/onErrornever fire (mobile/lib/shared/relay/relay_socket.dart:91-104), andRelaySessionNotifieris never told to reconnect. The socket is dead, the UI says connected.Desktop solved exactly this in
relayStallWatchdog.ts— a passive watchdog that treats inbound traffic (including the relay's own pings) as the liveness signal, and fires after two missed heartbeat windows:Mobile has no equivalent.
grep -niE 'ping|pong|idle|keepalive|watchdog' mobile/lib/shared/relay/relay_socket.dartreturns zero matches across all 251 lines.The five gaps
relayStallWatchdog.ts; wired atrelayClientSession.ts:120-124,:596,:770relay_socket.dart:99;relay_session.dart:133,185,251relayClientSession.ts:63-65relay_session.dart:398-412, flatdelay * 2relayClientSession.ts:985-989relay_session.dart:378resets to 1s the moment auth succeedsBACKOFF_RESET_STABLE_MS = 60_000—relayClientSession.ts:73, applied:592NOTICEframes droppedrelay_session.dart:429-443switches onEVENT/EOSE/CLOSED/OKonlyrate-limited:noticesGaps 3+4 compound: a socket that connects and dies after 5s resets its own backoff to 1s every cycle, so a flapping connection hammers the relay at full rate indefinitely instead of backing off. Gap 5 means the relay's explicit "slow down" is ignored while that happens.
Gap 2 is worth stating plainly: mobile — the more degraded network environment — has tighter timeouts than desktop. 8s is not much more than DNS + TLS + NIP-42 round-trip on a weak cellular link, so a merely slow handshake is classified as a failure and triggers a reconnect, which is then also slow. That is a self-sustaining loop that never converges.
Suggested fix
Gap 1 is the one that matters and it is small.
mobile/pubspec.yaml:20pinsweb_socket_channel: ^3.0.1(lockfile: 3.0.3), which already shipsIOWebSocketChannel.connect(..., pingInterval:):relay_socket.dart:66currently calls the genericWebSocketChannel.connect(...), which routes toAdapterWebSocketChanneland exposes nopingInterval(lib/src/channel.dart:107-108). Mobile has no web target (mobile/hasandroid/andios/only), so switching that one call toIOWebSocketChannel.connect(..., pingInterval: Duration(seconds: 20))makes the dead socket surface as a normal close through the existingonDonepath — no new lifecycle handling required, and_scheduleReconnecttakes it from there.That said, desktop deliberately chose a passive watchdog over active probing, for a Tauri-specific reason documented at the top of
relayStallWatchdog.ts. Either shape works here; the port of desktop's passive watchdog is the more conservative choice if you'd rather keep the two clients symmetric.Gaps 2–5 are constant changes and one added
casein_handleMessage.Testing notes
mobile/test/shared/relay/relay_session_test.dartalready exists and exercises the session notifier, so reconnect-timing and NOTICE-handling cases have a home. There is currently no test covering socket liveness.Offer
Happy to open a PR for this if it is not already in progress — stall detection first as its own change, then the four constant/handler fixes separately. Say the word and I will keep them independently reviewable.