Files
buzz/mobile/lib/shared/relay/relay_socket.dart
Tom Brow b41caeb510 fix(mobile): time out relay connects that never finish the handshake (#8878)
When a phone's network silently stops working (crowded event Wi-Fi, a
weak cell signal), Buzz Mobile can stay offline for minutes. In a device
test, the app took 30s to notice the dead connection, then hid the
channel's messages behind a loading skeleton. It tried to reconnect
once, and that attempt hung for 5 minutes (303s). It never retried,
because it only retries after an attempt fails.

This PR gives every reconnect attempt a 10s limit. When an attempt runs
out of time, the app closes it and tries again with its normal backoff.

### What was wrong

Opening the relay connection has two code paths. The one used with
hosted relay auth already had a 10s limit. The other path had none, so a
stuck attempt waited for the operating system to give up, which takes
minutes. Both paths now use the same limit. Each attempt also gets its
own HTTP client, so a timed-out attempt is actually closed rather than
left running in the background.

### Validation

I tested on an iPhone SE (iOS 27.0.1) running a profile build, with its
relay traffic routed through a Mac proxy that could silently drop
everything.

- **Before:** during a 5.6 minute outage, the app made one reconnect
attempt, which hung for 303s.
- **After:** during a 2.5 minute outage, each attempt gave up after 10s
and the app kept retrying. It reconnected 36s after the network came
back, a delay mostly set by the 30s cap on time between retries.

Added tests: `relay_socket_liveness_test` now covers an attempt that
never completes, with and without hosted relay auth headers. It checks
that the attempt fails and that the abandoned connection is closed. The
test fails without the fix.

Two existing tests were opening real relay connections by accident or
design:

- `deep_link_dispatcher_test` now uses a fake channel list, like its
neighboring tests.
- `remote_agents_test` talks to a real local relay on purpose, so it now
uses a real HTTP client instead of the test framework's mock.

The full mobile test suite passes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
2026-11-01 07:32:28 -08:00

288 lines
7.8 KiB
Dart

import 'dart:io';
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';
import 'relay_websocket.dart';
import 'package:buzz/shared/crypto/event_signing.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;
@visibleForTesting
static Duration debugConnectTimeout = relayWebSocketConnectTimeout;
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;
int? _closeCode;
SocketState get state => _state;
/// The WebSocket close code the relay sent, once it has closed the
/// connection; null if the connection failed without one.
int? get closeCode => _closeCode;
/// Scoped assertion supplied by the connection owner before connecting.
Map<String, String> headers = const {};
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(
openRelayWebSocket(
Uri.parse(_wsUrl),
headers: headers,
pingInterval: debugPingInterval,
connectTimeout: debugConnectTimeout,
),
);
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: () {
_closeCode = _channel?.closeCode;
_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 = signNostrEvent(
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);
}
}
/// HTTP upgrade failures retain their status through WebSocketChannel.
bool relayUpgradeRefused(Object? error) {
if (error is WebSocketChannelException) error = error.inner;
return error is WebSocketException &&
(error.httpStatusCode == 401 || error.httpStatusCode == 403);
}