Files
buzz/mobile/lib/shared/relay/relay_socket.dart
ce56e34411 fix(mobile): recover stale relay sessions (#4372)
### Summary

Fixes [this
issue](buzz://message?channel=e62570dd-33ad-42c5-b92b-75f2689f9694&id=b726c366abfe62429ee3cdcd34d0c0fb98c33c3ea053480585bed71745412b56):
> I often don’t see my bot responses until after I post. they’re usually
time stamped correctly so I think it’s just a refresh issue?

### What changed?

Buzz Mobile now reconnects relay sessions after the app has remained
backgrounded beyond the existing 5-second grace period, even when the
session still reports a stale `connected` state. This makes resume
recovery independent of whether iOS runs the grace timer before or after
delivering `resumed`.

Reconnection is now based on elapsed background time rather than a
direct socket-health probe.
- If the app was backgrounded for at least the 5-second grace period,
the socket is presumed dead and the session reconnects regardless of
reported status.
- If it was backgrounded for less than that, a reported `connected`
status is still trusted.

In the sub-5-second window the socket is either genuinely alive, which
is the common case for a momentary background, or it is dead and the
client ping detects it within the two-interval worst case described
below. That is now a degraded-latency path, not a silent-forever path.

The mobile relay socket now uses `IOWebSocketChannel.connect` with a
30-second `pingInterval`. An unanswered ping closes the Dart socket
through the existing disconnect and reconnect path.

Detection takes up to two ping intervals, so about 60 seconds worst
case, not 30. One interval of idleness elapses and a ping is sent, then
a second interval elapses with no pong and the socket closes. Any
inbound pong restarts the first stage, so the clock measures idleness
rather than running on a fixed cadence.

### Why?

Buzz iOS can sometimes stop showing new bot or agent responses after a
phone has been locked for 5 to 10 minutes. When the user later posts a
message, the missing responses can appear all at once. iOS may suspend
Buzz before the short delayed cleanup that would normally close its
connection has a chance to run. Before this change, Buzz trusted the
resulting stale healthy status on resume and skipped reconnecting, so
the missing responses stayed hidden until a later post exposed the dead
connection.

A state-machine test with a stubbed connection reproduced this reported
pattern and showed that it matches this failure mode: the failed post
triggered a reconnect that fetched the missing messages. The same test
also checked the other candidate explanation, the bug tracked in
[#3053](https://github.com/block/buzz/pull/3053), where the relay has
closed the app's subscription. That state does not produce the pattern.
Posting succeeds and the user's own message appears, but nothing looks
for the missed messages, so they stay hidden. The test confirmed that
the missed messages were still available to fetch in that state, so the
missing step was a trigger to fetch them. This was not an end-to-end
reproduction on an iOS device or a live relay.

The new resume check covers the normal lock and unlock path. If the app
was backgrounded for less than the 5-second grace period, it still
trusts a connection marked as healthy. A dead connection in that window
is instead detected by the ping check, which can take up to about 60
seconds but prevents the app from remaining silently stuck. The ping
only runs while iOS is running the app, so it does not detect a
connection that died during suspension; the resume check owns the lock
and unlock path.

A pre-existing path also runs the same resume handling when network
connectivity returns while the app is already in the foreground. Because
the app was not backgrounded, this change does not alter that path,
which still trusts a connection marked as healthy and relies on the
slower ping check.

Recovery from a subscription that the relay explicitly closes remains in
[#3053](https://github.com/block/buzz/pull/3053), and the two changes
overlap in one file. Changes to how missed messages are backfilled or
replayed are out of scope.

### How is it tested?

Full mobile suite at base and head. Both runs have the same known
macOS-host-only failure in `ChannelDetailPage keeps follow mode off
while a tall newest message stays visible` at line 1053:

- Base: 1,021 passed, 1 skipped, 1 failed
- Head: 1,025 passed, 1 skipped, 1 failed

Added tests:

-
[`relay_session_test.dart`](https://github.com/block/buzz/tree/main/mobile/test/shared/relay/relay_session_test.dart):
long-background resume reconnect and within-grace control
-
[`relay_socket_liveness_test.dart`](https://github.com/block/buzz/tree/main/mobile/test/shared/relay/relay_socket_liveness_test.dart):
silent-peer disconnect and idle-but-healthy control

Mutation checks confirm that removing elapsed-background resume recovery
fails with one socket instead of two, and removing `pingInterval` leaves
the silent peer connected. Restored production code passes both
mutations' regression tests and the healthy idle control.

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
2026-08-03 12:28:50 -07:00

262 lines
6.9 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'nostr_models.dart';
/// Low-level websocket connection with NIP-42 authentication.
///
/// Handles the raw websocket lifecycle: connect, authenticate via NIP-42
/// challenge/response, send/receive JSON frames, and disconnect.
///
/// Does NOT handle reconnection — that is [RelaySessionNotifier]'s job.
enum SocketState { disconnected, connecting, authenticating, connected }
class RelayAuthRejectedException implements Exception {
final String message;
const RelayAuthRejectedException(this.message);
@override
String toString() => 'Relay authentication rejected: $message';
}
Exception classifyRelayAuthFailure(String message) {
if (message.startsWith('error:')) return Exception(message);
return RelayAuthRejectedException(message);
}
class RelaySocket {
/// Interval for sending a ping and awaiting its pong before disconnecting.
static const pingInterval = Duration(seconds: 30);
@visibleForTesting
static Duration debugPingInterval = pingInterval;
final String _wsUrl;
final String? _nsec;
final void Function(List<dynamic> message) _onMessage;
final void Function() _onConnected;
final void Function(Object? error) _onDisconnected;
WebSocketChannel? _channel;
StreamSubscription<dynamic>? _subscription;
SocketState _state = SocketState.disconnected;
Completer<void>? _authCompleter;
Timer? _authTimeout;
String? _pendingAuthEventId;
SocketState get state => _state;
RelaySocket({
required String wsUrl,
required String? nsec,
required void Function(List<dynamic> message) onMessage,
required void Function() onConnected,
required void Function(Object? error) onDisconnected,
}) : _wsUrl = wsUrl,
_nsec = nsec,
_onMessage = onMessage,
_onConnected = onConnected,
_onDisconnected = onDisconnected;
/// Connect to the relay and complete NIP-42 authentication.
Future<void> connect() async {
if (_state != SocketState.disconnected) return;
_state = SocketState.connecting;
try {
_channel = IOWebSocketChannel.connect(
Uri.parse(_wsUrl),
pingInterval: debugPingInterval,
);
await _channel!.ready;
} catch (e) {
_state = SocketState.disconnected;
_onDisconnected(e);
return;
}
// The channel may have been disposed while we were awaiting ready
// (e.g. provider rebuild triggered dispose() concurrently).
if (_channel == null) {
_state = SocketState.disconnected;
return;
}
_state = SocketState.authenticating;
_authCompleter = Completer<void>();
_subscription = _channel!.stream.listen(
_handleRawMessage,
onError: (Object error) {
_failAuth(error);
_resetConnection();
_onDisconnected(error);
},
onDone: () {
_failAuth(null);
_resetConnection();
_onDisconnected(null);
},
);
// Wait for auth to complete (or timeout).
_authTimeout = Timer(const Duration(seconds: 8), () {
if (_authCompleter != null && !_authCompleter!.isCompleted) {
_authCompleter!.completeError(
TimeoutException('NIP-42 auth timed out after 8s'),
);
}
});
try {
await _authCompleter!.future;
_authTimeout?.cancel();
_state = SocketState.connected;
_onConnected();
} catch (e) {
_authTimeout?.cancel();
await disconnect();
_onDisconnected(e);
}
}
/// Send a raw JSON array over the websocket.
void send(List<dynamic> payload) {
_channel?.sink.add(jsonEncode(payload));
}
/// Gracefully close the connection.
Future<void> disconnect() async {
_resetConnection();
final channel = _channel;
_channel = null;
if (channel != null) {
await channel.sink.close();
}
}
void dispose() {
_resetConnection();
_channel?.sink.close();
_channel = null;
}
void _resetConnection() {
_state = SocketState.disconnected;
_subscription?.cancel();
_subscription = null;
_authTimeout?.cancel();
_authTimeout = null;
_pendingAuthEventId = null;
}
void _failAuth(Object? error) {
if (_authCompleter != null && !_authCompleter!.isCompleted) {
_authCompleter!.completeError(error ?? Exception('Connection closed'));
}
}
void _handleRawMessage(dynamic raw) {
final String text;
if (raw is String) {
text = raw;
} else {
return; // Binary frames are not part of the Nostr protocol.
}
final List<dynamic> data;
try {
data = jsonDecode(text) as List<dynamic>;
} catch (_) {
return; // Malformed JSON.
}
if (data.isEmpty) return;
final type = data[0] as String;
switch (type) {
case 'AUTH':
_handleAuthChallenge(data);
case 'OK':
_handleOk(data);
default:
// Pass EVENT, EOSE, NOTICE, etc. upstream.
_onMessage(data);
}
}
/// Handle the relay's AUTH challenge: sign a kind:22242 event and respond.
void _handleAuthChallenge(List<dynamic> data) {
if (data.length < 2) return;
final challenge = data[1] as String;
if (_nsec == null) {
_failAuth(Exception('No nsec available for NIP-42 auth'));
return;
}
try {
// Decode bech32 nsec to hex private key.
final privkeyHex = nostr.Nip19.decode(payload: _nsec).data;
if (privkeyHex.isEmpty) {
_failAuth(Exception('Invalid nsec'));
return;
}
// Build the auth tags.
final tags = <List<String>>[
['relay', _wsUrl],
['challenge', challenge],
];
// Create and sign the kind:22242 AUTH event.
final event = nostr.Event.from(
kind: EventKind.auth,
content: '',
tags: tags,
secretKey: privkeyHex,
);
_pendingAuthEventId = event.id;
send(['AUTH', event.toMap()]);
} catch (e) {
_failAuth(e);
}
}
@visibleForTesting
void debugHandleOkForTest(List<dynamic> data) => _onMessage(data);
/// Handle OK frames. During auth, complete the auth flow.
void _handleOk(List<dynamic> data) {
if (data.length < 3) return;
final eventId = data[1] as String;
final accepted = data[2] as bool;
// Check if this OK is for our pending AUTH event.
if (_pendingAuthEventId != null && eventId == _pendingAuthEventId) {
_pendingAuthEventId = null;
if (accepted) {
if (_authCompleter != null && !_authCompleter!.isCompleted) {
_authCompleter!.complete();
}
} else {
final message = data.length > 3
? data[3] as String
: 'Auth rejected by relay';
_failAuth(classifyRelayAuthFailure(message));
}
return;
}
// Pass non-auth OK frames upstream for pending event tracking.
_onMessage(data);
}
}