Files
buzz/mobile/test/shared/relay/relay_socket_liveness_test.dart
Spencer Lewis 83d0bd3203 fix(mobile): stop QR pairing to organization relays from hanging (#9001)
## Problem

Pairing a phone to an organization relay by QR (for example Block's
`buzz.block.builderlab.xyz`) never finishes. After the codes match and
the phone confirms, it sits on the loading surface. About 2 minutes
later it returns to the welcome screen with "Lost connection to pairing
relay." The desktop stays on "Finishing pairing on your phone…". This
reproduces every time on Buzz 0.27.0, iOS 27.2, with desktop 0.7.7
(internal tracking: AIDA-2420).

There are two causes:

1. **Validation can never pass for organization relays.** After the
payload arrives, `_validateCredentials` opens a plain `RelaySocket`
without a `Nostr-Federated-Identity` assertion. Organization relays
refuse that upgrade with HTTP 401 until organization sign-in, which a
freshly pairing phone hasn't done.
2. **The failure hangs instead of erroring.** `RelaySocket.connect()`
catches the failed `ready` but keeps `_channel`. The validator's
`finally` then awaits `disconnect()` → `sink.close()` on a channel that
never opened, and that never completes. The phone waits until the
pairing relay's 120 s connection cap closes the socket, and
`_handleDisconnected` reports that as a lost connection.

Standalone repro with `web_socket_channel` 3.0.3 against the real relay:

```
ready failed: ... was not upgraded to websocket, HTTP status code: 401
sink.close() DID NOT COMPLETE within 30s (hang)
```

## Change

- `RelaySocket.connect()` drops a channel whose upgrade failed.
- `disconnect()` waits at most `RelaySocket.closeTimeout` (2 s). An
upgrade that is still pending (connect timeout 10 s) can outlive
pairing's 8 s validation deadline, and its close never completes either.
- `PairingNotifier` skips the pre-sign-in check for
`OrganizationEnvironment.protects` relays. Admission is left to the
saved community's session and the existing organization sign-in banner,
which matches how the hosted branch already defers admission. The
predicate is injectable (`organizationRelay`) for tests. Other relays
are still validated before saving.

Trade-off: an identity that the organization relay would reject is now
saved, and that rejection only surfaces after organization sign-in. The
hosted path has the same property.

## Related issue

No existing GitHub issue covers this failure; searched open issues and
PRs for pairing relay, lost connection and `_validateCredentials`.
Closest:

- #8043 / #8060 cover the same `_validateCredentials` path from the
other side: a failed connect currently returns normally. They're
complementary. With #8060 merged, organization relays would fail pairing
with an explicit 401 instead of hanging, and this PR keeps them out of
that pre-check. Whichever merges second needs a small rebase in
`_validateCredentials`.
- #3181 is a different failure in the same flow.

## Testing

- New `relay_socket_liveness_test` cases check that `disconnect()`
completes in three situations: after a refused (401) upgrade, after a
timed-out handshake, and while the upgrade is still pending.
- New `pairing_provider_test` case: a malformed key is rejected locally
before anything is saved, an organization relay is saved without the
relay check, and a non-organization relay is still validated and
rejected.
- Each new test fails with its fix removed. The failures were
"disconnect() hung on a channel that never opened", "disconnect() hung
on a pending upgrade", and `validated` being true.
- `just mobile-check` and `just mobile-test` pass: 4229 passed, 40
skipped.
- **Simulator run (iOS 27.0 simulator, iPhone 18 Pro, Block organization
dart-defines) against desktop 0.7.7 on `buzz.block.builderlab.xyz`:**
pairing completed (desktop reported the phone paired; identity
transferred; community saved) instead of hanging until the pairing relay
closed. The app then showed the organization sign-in banner and opened
browser sign-in for block.builderlab.xyz, as expected. Okta in the
simulator requires FastPass, so the final relay connection after sign-in
was not exercised there.
- Human device testing is still pending.

## Follow-up

- Test on a real phone with an organization build. The simulator
couldn't complete Okta FastPass, so the relay connection after
organization sign-in still needs checking.

---------

Signed-off-by: Spencer Lewis <slewis@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:17:15 -08:00

309 lines
9.8 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:buzz/shared/relay/relay_socket.dart';
import 'package:buzz/shared/relay/relay_websocket.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
/// A server that completes the WS handshake then never speaks again: no pongs,
/// no close frame. Only a client-side ping timeout can notice.
Future<ServerSocket> _silentAfterHandshakeServer() async {
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
server.listen((client) {
client.listen(
(data) {
final match = RegExp(
r'Sec-WebSocket-Key: (.*)\r\n',
caseSensitive: false,
).firstMatch(String.fromCharCodes(data));
if (match == null) return;
final accept = base64.encode(
sha1
.convert(
utf8.encode(
'${match.group(1)!.trim()}258EAFA5-E914-47DA-95CA-C5AB0DC85B11',
),
)
.bytes,
);
client.write(
'HTTP/1.1 101 Switching Protocols\r\n'
'Upgrade: websocket\r\nConnection: Upgrade\r\n'
'Sec-WebSocket-Accept: $accept\r\n\r\n',
);
},
onError: (_) {},
onDone: () {},
);
});
return server;
}
/// A server that accepts TCP connections and then never answers, like a
/// stalled TLS or upgrade handshake on a congested network.
/// Accepts TCP and never answers the upgrade. [closed] completes when the
/// client drops the connection.
Future<(ServerSocket server, Future<void> closed)>
_stalledHandshakeServer() async {
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final closed = Completer<void>();
server.listen(
(client) => client.listen(
(_) {},
onError: (_) {},
onDone: () {
if (!closed.isCompleted) closed.complete();
},
),
);
return (server, closed.future);
}
void main() {
const testPingInterval = Duration(milliseconds: 150);
const testConnectTimeout = Duration(milliseconds: 200);
setUp(() {
RelaySocket.debugPingInterval = testPingInterval;
RelaySocket.debugConnectTimeout = testConnectTimeout;
});
tearDown(() {
RelaySocket.debugPingInterval = RelaySocket.pingInterval;
RelaySocket.debugConnectTimeout = relayWebSocketConnectTimeout;
});
for (final scoped in [false, true]) {
test('gives up on a handshake that never completes '
'(${scoped ? 'scoped headers' : 'no headers'})', () async {
final (server, clientClosed) = await _stalledHandshakeServer();
final disconnected = Completer<Object?>();
final socket = RelaySocket(
wsUrl: 'ws://127.0.0.1:${server.port}',
nsec: null,
onMessage: (_) {},
onConnected: () {},
onDisconnected: (error) {
if (!disconnected.isCompleted) disconnected.complete(error);
},
);
if (scoped) {
socket.headers = {'Nostr-Federated-Identity': 'Bearer synthetic'};
}
unawaited(socket.connect());
final error = await disconnected.future.timeout(
testConnectTimeout * 10,
onTimeout: () => fail(
'RelaySocket must fail a stalled handshake so the session can '
'schedule a reconnect',
),
);
expect(socket.state, SocketState.disconnected);
expect(error.toString(), contains('Relay connection timed out'));
// The abandoned handshake is closed, not left for the OS to time out.
await clientClosed.timeout(
testConnectTimeout * 10,
onTimeout: () => fail('the timed-out handshake was left open'),
);
socket.dispose();
await server.close();
});
}
// Pairing validation disconnects in a finally block after a failed connect;
// an unbounded close there stranded pairing until the pairing relay expired.
for (final refusal in [401, null]) {
test(
'disconnect completes after '
'${refusal == null ? 'a timed-out handshake' : 'a refused upgrade ($refusal)'}',
() async {
HttpServer? refusing;
ServerSocket? stalled;
if (refusal == null) {
stalled = (await _stalledHandshakeServer()).$1;
} else {
refusing = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
refusing.listen((request) async {
request.response.statusCode = refusal;
await request.response.close();
});
}
final url = 'ws://127.0.0.1:${(refusing?.port ?? stalled!.port)}';
Object? reported;
final socket = RelaySocket(
wsUrl: url,
nsec: null,
onMessage: (_) {},
onConnected: () {},
onDisconnected: (error) => reported = error,
);
await socket.connect();
expect(socket.state, SocketState.disconnected);
if (refusal != null) expect(relayUpgradeRefused(reported), isTrue);
await socket.disconnect().timeout(
testConnectTimeout * 10,
onTimeout: () =>
fail('disconnect() hung on a channel that never opened'),
);
await refusing?.close(force: true);
await stalled?.close();
},
);
}
test('disconnect is bounded while the upgrade is still pending', () async {
// Pairing's 8s validation deadline can fire before the 10s connect timeout.
RelaySocket.debugConnectTimeout = const Duration(seconds: 30);
final (server, _) = await _stalledHandshakeServer();
final socket = RelaySocket(
wsUrl: 'ws://127.0.0.1:${server.port}',
nsec: null,
onMessage: (_) {},
onConnected: () {},
onDisconnected: (_) {},
);
unawaited(socket.connect());
await Future<void>.delayed(const Duration(milliseconds: 100));
await socket.disconnect().timeout(
RelaySocket.closeTimeout * 2,
onTimeout: () => fail('disconnect() hung on a pending upgrade'),
);
expect(socket.state, SocketState.disconnected);
socket.dispose();
await server.close();
});
test('detects a peer that stops answering pings', () async {
final server = await _silentAfterHandshakeServer();
final disconnected = Completer<Object?>();
final socket = RelaySocket(
wsUrl: 'ws://127.0.0.1:${server.port}',
nsec: null,
onMessage: (_) {},
onConnected: () {},
onDisconnected: (error) {
if (!disconnected.isCompleted) disconnected.complete(error);
},
);
unawaited(socket.connect());
var detected = true;
try {
await disconnected.future.timeout(testPingInterval * 4);
} on TimeoutException {
detected = false;
}
expect(
detected,
isTrue,
reason:
'RelaySocket must surface an unanswered ping through onDisconnected',
);
socket.dispose();
await server.close();
});
test(
'scoped headers stay at the upgrade origin and healthy peers stay connected',
() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final paths = <String>[];
// A different port prevents a closed upgrade connection from masking a
// redirect attempt through connection-pool reuse.
final sink = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
sink.listen((request) async {
paths.add('sink');
expect(
request.headers.value('Nostr-Federated-Identity'),
'Bearer synthetic',
);
request.response.statusCode = 403;
await request.response.close();
});
server.listen((request) async {
paths.add(request.uri.path);
expect(
request.headers.value('Nostr-Federated-Identity'),
'Bearer synthetic',
);
if (request.uri.path == '/denied') {
request.response.statusCode = 403;
await request.response.close();
return;
}
if (request.uri.path == '/redirect') {
request.response.statusCode = 302;
request.response.headers.set(
'Location',
'http://127.0.0.1:${sink.port}/sink',
);
await request.response.close();
return;
}
final ws = await WebSocketTransformer.upgrade(request);
ws.listen((_) {}, onError: (_) {}, onDone: () {});
});
var tornDown = false;
final socket = RelaySocket(
wsUrl: 'ws://127.0.0.1:${server.port}',
nsec: null,
onMessage: (_) {},
onConnected: () {},
onDisconnected: (_) => tornDown = true,
);
socket.headers = {'Nostr-Federated-Identity': 'Bearer synthetic'};
unawaited(socket.connect());
await Future<void>.delayed(testPingInterval * 4);
expect(tornDown, isFalse);
await socket.disconnect();
final refused = Completer<void>();
final redirect = RelaySocket(
wsUrl: 'ws://127.0.0.1:${server.port}/redirect',
nsec: null,
onMessage: (_) {},
onConnected: () {},
onDisconnected: (_) {
if (!refused.isCompleted) refused.complete();
},
);
redirect.headers = {'Nostr-Federated-Identity': 'Bearer synthetic'};
await redirect.connect();
await refused.future;
expect(paths, ['/', '/redirect']);
redirect.dispose();
final denial = Completer<Object?>();
final denied = RelaySocket(
wsUrl: 'ws://127.0.0.1:${server.port}/denied',
nsec: null,
onMessage: (_) {},
onConnected: () {},
onDisconnected: (error) {
if (!denial.isCompleted) denial.complete(error);
},
);
denied.headers = {'Nostr-Federated-Identity': 'Bearer synthetic'};
await denied.connect();
expect(relayUpgradeRefused(await denial.future), isTrue);
denied.dispose();
await server.close(force: true);
await sink.close(force: true);
},
);
}