mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
## Summary A relay rolling deploy closes every socket with 1012. On reconnect, mobile replayed each live subscription's **original filter** (startup `since`, full `limit`) unless that subscription had received an event, so quiet subscriptions re-read history back to app start every time. Reconnects used a fixed 1s delay with no jitter. The delay also reset to 1s on every successful connect, so a client bounced from pod to pod during a deploy kept coming back at the same moment as every other client. **This PR makes two changes:** 1. **Resume cursor.** If a subscription reached EOSE on a connection, it records `liveUntil`: the time of the last frame received from the relay on that connection, capped at the clock at close. The replay then uses `since = max(original since, lastSeen − 5s, liveUntil − 960s)`, and never goes past the filter's `until`. - **Why 960s:** the relay accepts `created_at` up to ±900s from its clock (`MAX_TIMESTAMP_DRIFT_SECS` in `crates/buzz-relay/src/handlers/ingest.rs`). An event stored after the close can therefore be backdated by that much, and the extra 60s covers client clock skew. - **Only relay frames count as liveness.** Close and error paths never extend `liveUntil`, so a close noticed after the device sleeps doesn't overstate how long the subscription was live. - **Ambiguous EOSE is ignored.** If a subscription has a second REQ on the same socket, that subscription doesn't advance `liveUntil` for the rest of the socket. A CLOSED ends liveness for the rest of that socket, because the retry is a second REQ. Subscription IDs are never reused, so an old EOSE can't mark a re-subscribe as live. - **No cursor:** a subscription that never reached EOSE keeps its previous cursor, or replays as before. 2. **Reconnect spreading.** - After a 1012 or 1001 close, the first attempt waits a uniform random delay in [0, 10s]. A client already backing off waits the longer of that delay and its backoff. - Ordinary backoff gets symmetric ±25% jitter, capped at 30s. - The backoff resets only after a connection stays up for 60s, not on connect. This matches desktop. - Foreground resume and a manual `reconnect()` still reset to the base delay, as before. **Simpler?** The pure reconnect math (cursor, jitter, spread, stability) lives in a new `relay_reconnect_policy.dart` and is table-tested on its own. `relay_session.dart` was near its 1200-line cap, so its five small private bookkeeping classes moved verbatim to `relay_session_subscriptions.dart`. Net concept count is unchanged. **Identified debt (not changed here):** - Dart's WebSocket ping/pong isn't visible to the session, so a long-idle but healthy connection that drops replays back to its last application frame − 960s. This is still bounded, and much cheaper than replaying from app start. - The unread catch-up in `channels_provider.dart` runs one REQ per active channel (`since = readAt + 1`, `limit: 1000`) on reconnect and every 60s. - A retryable CLOSED is retried with a 30s cap, no jitter and no attempt limit. - `lastSeenCreatedAt` advances on stored events received before EOSE. If a relay returns stored events newest-first and the socket dies before EOSE, the next replay can skip older stored events. - The `lastSeen − 5s` term keeps its existing gap for events backdated after a close. ### Related issue N/A. This is part of a set of reconnect-load reductions. Companion PRs: relay read-pool warm (#8651), desktop (#8652), agent harness (#8653). ### Testing - Mobile gate on a Linux workstation, at the head SHA: - `dart format --set-exit-if-changed`: pass. - `flutter analyze`: no issues. - Gateway recipe tests: pass. - `flutter test`: 3881 pass, 1 fail. `push_gateway_origin_validator_test` fails the same way on `main` on that host, due to a host git-config error. - New `relay_reconnect_resume_test.dart`: - pure table tests for the cursor and the delay - a real-socket test that reads the 1012 close code - journeys through `RelaySessionNotifier`. They check that a close or error after an hour asleep doesn't extend liveness, that a second REQ or a delayed EOSE for an unsubscribed ID can't advance the cursor, and that a backdated event inside the 960s window is still replayed. - 22 production mutations, each failing the new tests. They cover every cursor term, the 960s window, the `until` clamp, the liveness cap, the ambiguous-EOSE guard, CLOSED handling, jitter, the 1012 spread and floor, the backoff reset, and close-code capture. - `fake_relay.dart` now draws a fixed jitter midpoint, so existing journey tests stay deterministic. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
284 lines
7.7 KiB
Dart
284 lines
7.7 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;
|
|
|
|
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,
|
|
),
|
|
);
|
|
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);
|
|
}
|