mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +00:00
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>
288 lines
7.8 KiB
Dart
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);
|
|
}
|