major bug fixes

This commit is contained in:
Forrest 2026-08-04 19:29:58 -04:00
parent 9728906556
commit d6144c4483
39 changed files with 483 additions and 53 deletions

View file

@ -116,6 +116,11 @@ class RemoteHost {
// ---- Handshake + per-client loop --------------------------------------
void _handleSocket(WebSocket socket) {
// Keepalive: ping idle clients so a controller that vanished without a
// close frame (crashed, walked out of Wi-Fi range) is detected and pruned
// instead of lingering as a zombie in `_clients`.
socket.pingInterval = const Duration(seconds: 15);
final nonce = RemoteAuth.newNonce();
var authed = false;

View file

@ -48,6 +48,38 @@ class RemoteSession {
StreamController<RemoteConnStatus>.broadcast();
RemoteConnStatus _current = RemoteConnStatus.disconnected;
/// True once [close] (or a hard denial / cold-connect give-up) has run —
/// stops all further reconnection.
bool _closed = false;
/// A dial is in flight; guards against overlapping [_dial] calls (e.g. a
/// backoff timer firing while [reconnectNow] also dials).
bool _dialing = false;
/// True once we've completed a handshake at least once. Distinguishes a
/// device that never answers (stale in the list — give up) from an
/// established session that later dropped (lock / Wi-Fi blip — persist).
bool _everConnected = false;
/// Consecutive failed dials since the last successful connection; indexes
/// [_kBackoff].
int _attempt = 0;
Timer? _retryTimer;
static const Duration _kDialTimeout = Duration(seconds: 8);
/// Keepalive interval. Periodic pings hold the socket open through brief
/// idle/doze windows and surface a dead peer promptly instead of leaving a
/// silently half-open connection.
static const Duration _kPingInterval = Duration(seconds: 5);
/// Reconnect backoff in seconds, held at the last value once reached.
static const List<int> _kBackoff = [0, 1, 2, 4, 8, 15];
/// How many times to retry before the *first* successful handshake before
/// giving up (a device that never answers is probably gone).
static const int _kMaxColdAttempts = 3;
/// Host-reported friendly name, available after [RemoteConnStatus.connected].
String? hostDevice;
@ -56,28 +88,89 @@ class RemoteSession {
RemoteConnStatus get currentStatus => _current;
/// Dial the host and start the handshake. Status transitions are emitted on
/// [status]; on success snapshots begin arriving on [snapshots].
/// [status]; on success snapshots begin arriving on [snapshots]. The session
/// then self-heals: a dropped socket is retried with backoff (see
/// [_scheduleRetry]) rather than surfacing a terminal status, so control
/// persists across a phone lock or Wi-Fi blip (bug-fixes #5).
Future<void> connect() async {
_closed = false;
_attempt = 0;
await _dial();
}
Future<void> _dial() async {
if (_closed || _dialing) return;
_dialing = true;
_set(RemoteConnStatus.connecting);
try {
final ws = await WebSocket.connect(device.wsUri.toString())
.timeout(const Duration(seconds: 8));
.timeout(_kDialTimeout);
if (_closed) {
unawaited(ws.close().catchError((_) {}));
return;
}
ws.pingInterval = _kPingInterval;
_socket = ws;
_sub = ws.listen(
_onData,
onDone: () => _set(RemoteConnStatus.disconnected),
onError: (_) => _set(RemoteConnStatus.error),
onDone: _onDropped,
onError: (_) => _onDropped(),
cancelOnError: true,
);
} catch (_) {
_set(RemoteConnStatus.error);
_onDropped();
} finally {
_dialing = false;
}
}
/// The socket went away (drop, dial failure, or refused). Unless we've been
/// [close]d, keep the session logically alive and retry.
void _onDropped() {
_sub = null;
_socket = null;
if (_closed) return;
_scheduleRetry();
}
void _scheduleRetry() {
// Give up only if we never got a handshake in the first place — an
// established session that dropped is retried indefinitely.
if (!_everConnected && _attempt >= _kMaxColdAttempts) {
_closed = true;
_set(RemoteConnStatus.error);
return;
}
_retryTimer?.cancel();
final i = _attempt < _kBackoff.length ? _attempt : _kBackoff.length - 1;
_attempt++;
_set(RemoteConnStatus.connecting);
_retryTimer = Timer(Duration(seconds: _kBackoff[i]), () {
_retryTimer = null;
unawaited(_dial());
});
}
/// Reset the backoff and re-dial immediately. Called when the app returns to
/// the foreground: a controller is suspended while the phone is locked, so
/// its socket to the host is usually dead on resume and we don't want to make
/// the user wait out the backoff before control is restored (bug-fixes #5).
void reconnectNow() {
if (_closed) return;
if (_current == RemoteConnStatus.connected && _socket != null) return;
_attempt = 0;
_retryTimer?.cancel();
_retryTimer = null;
unawaited(_dial());
}
/// Send a control command to the host (no-op if not connected).
void send(RemoteCommand cmd) => _sendMessage(CommandMessage(cmd));
Future<void> close() async {
_closed = true;
_retryTimer?.cancel();
_retryTimer = null;
await _sub?.cancel();
_sub = null;
try {
@ -109,8 +202,15 @@ class RemoteSession {
));
} else if (msg is WelcomeMessage) {
hostDevice = msg.device;
_everConnected = true;
_attempt = 0; // fresh backoff budget after a good connection
_set(RemoteConnStatus.connected);
} else if (msg is DenyMessage) {
// A rejection (wrong account / protocol) won't fix itself — go terminal
// and stop retrying.
_closed = true;
_retryTimer?.cancel();
_retryTimer = null;
_set(RemoteConnStatus.denied);
_socket?.close().catchError((_) {});
} else if (msg is SnapshotMessage) {