Skip to content

Mobile: no WebSocket stall detection — half-open cellular sockets look connected until force-quit (plus 4 resilience gaps vs desktop) #3224

Description

@Dirtywater97

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_000relayClientSession.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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions