mirror of
https://github.com/block/buzz.git
synced 2026-11-05 17:01:35 +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>
60 lines
2.0 KiB
Dart
60 lines
2.0 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
/// Bounds how long a relay WebSocket may take to connect and upgrade.
|
|
///
|
|
/// The OS gives up on a stalled TCP or TLS handshake only after minutes, and
|
|
/// reconnecting starts only once this attempt fails, so an unbounded attempt
|
|
/// leaves the app reconnecting for as long as the network stays stalled.
|
|
const relayWebSocketConnectTimeout = Duration(seconds: 10);
|
|
|
|
/// WebSocket's default HTTP client follows redirects, including custom headers.
|
|
/// Keep key-bound assertions at their original origin and bound upgrade time.
|
|
///
|
|
/// Each attempt gets its own HTTP client so a timed-out handshake can be
|
|
/// force-closed instead of left running until the OS gives up on it.
|
|
Future<WebSocket> openRelayWebSocket(
|
|
Uri uri, {
|
|
required Map<String, String> headers,
|
|
required Duration pingInterval,
|
|
Duration connectTimeout = relayWebSocketConnectTimeout,
|
|
}) async {
|
|
final HttpClient client = headers.isEmpty ? HttpClient() : _UpgradeClient();
|
|
try {
|
|
return await WebSocket.connect(
|
|
uri.toString(),
|
|
headers: headers.isEmpty ? null : headers,
|
|
customClient: client,
|
|
)
|
|
.then((socket) => socket..pingInterval = pingInterval)
|
|
.timeout(
|
|
connectTimeout,
|
|
onTimeout: () {
|
|
client.close(force: true);
|
|
throw TimeoutException('Relay connection timed out');
|
|
},
|
|
);
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|
|
|
|
/// WebSocket.connect uses only openUrl on its supplied HTTP client. Keep this
|
|
/// adapter limited to upgrades instead of exposing a second general transport.
|
|
class _UpgradeClient implements HttpClient {
|
|
final _inner = HttpClient();
|
|
|
|
@override
|
|
Future<HttpClientRequest> openUrl(String method, Uri url) async {
|
|
final request = await _inner.openUrl(method, url);
|
|
request.followRedirects = false;
|
|
return request;
|
|
}
|
|
|
|
@override
|
|
void close({bool force = false}) => _inner.close(force: force);
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|