block / block/buzz

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

Open
#3,224 8 comments 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
32.7k
Forks
4.3k
Avg merge
1d 13h
Merged PRs (30d)
253

Description

## 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:

```ts
// 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:)`:

```dart
// 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.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.