diff --git a/README.md b/README.md index 0d7ec6d87..7ddd8cbac 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,10 @@ sudo moss it plezy - Synchronized playback with friends - Real-time play / pause / seek sync - Host handoff when the relay and every connected peer support safe transfers +- Automatic reconnect authenticates retained room membership; it never silently joins a reused room code. + +Self-hosted relays must support `authenticatedResume` for recovery. Deploy the updated relay before updating clients. +Older relays still accept explicit create/join, but failed recovery requires joining or creating a room again. ### Integrations - Discord Rich Presence[^desktop] diff --git a/lib/watch_together/services/relay_protocol.g.dart b/lib/watch_together/services/relay_protocol.g.dart index 693c059c3..af522c138 100644 --- a/lib/watch_together/services/relay_protocol.g.dart +++ b/lib/watch_together/services/relay_protocol.g.dart @@ -6,6 +6,7 @@ abstract final class RelayProtocol { static const String create = 'create'; static const String join = 'join'; + static const String resume = 'resume'; static const String broadcast = 'broadcast'; static const String sendTo = 'sendTo'; static const String ping = 'ping'; @@ -14,6 +15,7 @@ abstract final class RelayProtocol { static const String transferHost = 'transferHost'; static const String created = 'created'; static const String joined = 'joined'; + static const String resumed = 'resumed'; static const String peerJoined = 'peerJoined'; static const String peerLeft = 'peerLeft'; static const String message = 'message'; @@ -36,6 +38,7 @@ abstract final class RelayProtocol { static const String peerNotFoundCode = 'peer_not_found'; static const String hostTransferUnavailableCode = 'host_transfer_unavailable'; static const String atomicHostTransferFeature = 'atomicHostTransfer'; + static const String authenticatedResumeFeature = 'authenticatedResume'; static const String hostTransferCapability = 'hostTransfer'; static const int maxRoomSize = 8; diff --git a/lib/watch_together/services/watch_together_peer_service.dart b/lib/watch_together/services/watch_together_peer_service.dart index 3e040f2f4..534c455f1 100644 --- a/lib/watch_together/services/watch_together_peer_service.dart +++ b/lib/watch_together/services/watch_together_peer_service.dart @@ -69,6 +69,8 @@ class WatchTogetherPeerService with KeepaliveMixin { StreamSubscription? _channelSubscription; Completer? _setupCompleter; String? _setupRequestType; + bool _admitted = false; + bool _initialRequestMayHaveCommitted = false; final Set _connectedPeers = {}; String? _sessionId; String? _myPeerId; @@ -126,7 +128,7 @@ class WatchTogetherPeerService with KeepaliveMixin { /// Stream of connection state changes (true = connected, false = disconnected) Stream get onConnectionStateChanged => _connectionStateController.stream; - /// Emitted when the host has durably ended the relay room. + /// Emitted when this session can no longer be continued safely, for either role. Stream get onSessionEnded => _sessionEndedController.stream; /// Emitted with the new host's peer ID when the relay reassigns host @@ -140,6 +142,7 @@ class WatchTogetherPeerService with KeepaliveMixin { bool canTransferHostTo(String peerId) => !_teardownInProgress && + _admitted && _channel != null && _isHost && _relayEnforcesHostTransfer && @@ -163,16 +166,15 @@ class WatchTogetherPeerService with KeepaliveMixin { /// Whether this peer is the host. /// /// Derived, never stored: the relay is the authority on host identity and - /// names it in every admission and every `hostChanged`. Before it has - /// admitted us there is no authority yet, so the role is the one we - /// announced — which is what a release of a possibly-committed setup has to - /// go by. + /// names it in every admission and every `hostChanged`. Before admission, + /// the requested role is provisional; disconnected release always resumes + /// authenticated membership before choosing a terminal operation. bool get _isHost => _hostPeerId == null ? _announcedAsHost : _hostPeerId == _myPeerId; bool get isHost => _isHost; /// Whether currently connected to a session - bool get isConnected => _channel != null && _connectedPeers.isNotEmpty; + bool get isConnected => _admitted && _channel != null && _connectedPeers.isNotEmpty; /// List of connected peer IDs List get connectedPeers => _connectedPeers.toList(); @@ -222,6 +224,7 @@ class WatchTogetherPeerService with KeepaliveMixin { Duration? connectTimeout, String connectOperation = 'WatchTogether connect', }) async { + _requireCurrentConnection(epoch); final channel = await _connectToRelay(timeout: connectTimeout, operation: connectOperation); if (_disposed || epoch != _connectionEpoch || _sessionId == null) { unawaited(channel.sink.close()); @@ -239,17 +242,23 @@ class WatchTogetherPeerService with KeepaliveMixin { final completer = Completer(); _setupCompleter = completer; _setupRequestType = type; - if (type == RelayProtocol.create || type == RelayProtocol.join) { + final admission = type == RelayProtocol.create || type == RelayProtocol.join || type == RelayProtocol.resume; + if (admission) { + _admitted = false; _clearHostTransferEligibility(resetFeature: true); } final reconnectToken = _reconnectToken; + if (type == RelayProtocol.create || type == RelayProtocol.join) { + // Enqueue failures cannot prove that the relay did not commit admission. + _initialRequestMayHaveCommitted = true; + } _sendRaw({ 'type': type, 'sessionId': _sessionId, 'peerId': _myPeerId, 'reconnectToken': ?reconnectToken, 'protocolVersion': _relayProtocolVersion, - if (type == RelayProtocol.create || type == RelayProtocol.join) ...{ + if (admission) ...{ 'syncProtocolVersion': SyncMessage.protocolVersion, 'capabilities': [RelayProtocol.hostTransferCapability], }, @@ -301,6 +310,13 @@ class WatchTogetherPeerService with KeepaliveMixin { } List _acceptSetupResponse(Map msg, String type) { + final expectedType = switch (_setupRequestType) { + RelayProtocol.create => RelayProtocol.created, + RelayProtocol.join => RelayProtocol.joined, + RelayProtocol.resume => RelayProtocol.resumed, + _ => null, + }; + if (_setupCompleter == null || type != expectedType) throw _invalidSetupResponse(type); final responseSessionId = msg['sessionId']; final hostPeerId = msg['hostPeerId']; final reconnectToken = msg['reconnectToken']; @@ -328,10 +344,15 @@ class WatchTogetherPeerService with KeepaliveMixin { peers.add(peerId); } } + final features = msg['features']; + if (type == RelayProtocol.resumed && + (features is! List || !features.contains(RelayProtocol.authenticatedResumeFeature))) { + throw _invalidSetupResponse(type); + } _hostPeerId = hostPeerId; _reconnectToken = reconnectToken; - final features = msg['features']; + _admitted = true; _relayEnforcesHostTransfer = features is List && features.contains(RelayProtocol.atomicHostTransferFeature); _clearHostTransferEligibility(); return peers; @@ -347,6 +368,17 @@ class WatchTogetherPeerService with KeepaliveMixin { } void _failSetup(PeerError error) { + if (_setupRequestType == RelayProtocol.resume && !_isRetryableInitialSetupError(error)) { + error = PeerError( + type: error.type, + message: t.watchTogether.errors.sessionUnavailable, + serverCode: error.serverCode, + ); + if (!_initialSetupInProgress && !_teardownInProgress) { + _handleSessionEnded(error); + return; + } + } _safeAdd(_errorController, error); if (_setupCompleter case final completer? when !completer.isCompleted) { _setupCompleter = null; @@ -355,30 +387,20 @@ class WatchTogetherPeerService with KeepaliveMixin { } } - bool _isExhaustedGuestReconnectRoomNotFound(String code) => - code == RelayProtocol.roomNotFoundCode && - !_isHost && - !_initialSetupInProgress && - !_teardownInProgress && - _hostPeerId != null && - _setupRequestType == RelayProtocol.join && - _reconnectAttempts >= _maxReconnectAttempts; - - void _handleGuestSessionEnded() { - _teardownInProgress = true; - ++_connectionEpoch; - _reconnectTimer?.cancel(); - _reconnectTimer = null; - stopKeepalive(); - final error = PeerError(type: PeerErrorType.invalidSession, message: t.watchTogether.errors.sessionEnded); - if (_setupCompleter case final completer? when !completer.isCompleted) { - _setupCompleter = null; - _setupRequestType = null; - _safeAdd(_errorController, error); - completer.completeError(error); + void _handleSessionEnded([PeerError? error]) { + final endedError = + error ?? PeerError(type: PeerErrorType.invalidSession, message: t.watchTogether.errors.sessionEnded); + final completer = _setupCompleter; + _setupCompleter = null; + _setupRequestType = null; + if (completer != null && !completer.isCompleted) { + completer.completeError(endedError); } + _safeAdd(_errorController, endedError); + // Clear credentials and fence the channel synchronously before notifying + // consumers, so terminal cleanup cannot recover into a replacement room. + unawaited(disconnect()); _safeAdd(_sessionEndedController, null); - _handleWebSocketClosed(); } /// Handle an incoming server message (JSON string). @@ -388,7 +410,7 @@ class WatchTogetherPeerService with KeepaliveMixin { final type = msg['type'] as String?; switch (type) { - case RelayProtocol.created || RelayProtocol.joined: + case RelayProtocol.created || RelayProtocol.joined || RelayProtocol.resumed: final previousHostPeerId = _hostPeerId; late final List peers; try { @@ -418,6 +440,7 @@ class WatchTogetherPeerService with KeepaliveMixin { } case RelayProtocol.peerJoined: + if (!_admitted) break; final peerId = msg['peerId'] as String; appLogger.d('WatchTogether: Peer joined: $peerId'); _connectedPeers.add(peerId); @@ -425,6 +448,7 @@ class WatchTogetherPeerService with KeepaliveMixin { _safeAdd(_connectionStateController, true); case RelayProtocol.peerLeft: + if (!_admitted) break; final peerId = msg['peerId'] as String; appLogger.d('WatchTogether: Peer left: $peerId'); _connectedPeers.remove(peerId); @@ -434,6 +458,7 @@ class WatchTogetherPeerService with KeepaliveMixin { } case RelayProtocol.message: + if (!_admitted) break; final payload = msg['payload']; final serverFrom = msg['from'] as String?; if (payload != null) { @@ -452,12 +477,18 @@ class WatchTogetherPeerService with KeepaliveMixin { } case RelayProtocol.left: + if (_setupRequestType != RelayProtocol.leave) { + _failSetup(_invalidSetupResponse(RelayProtocol.left)); + break; + } try { _acceptTeardownResponse(msg, RelayProtocol.left); } on PeerError catch (error) { _failSetup(error); break; } + _admitted = false; + _reconnectToken = null; if (_setupCompleter case final completer? when !completer.isCompleted) { _setupCompleter = null; _setupRequestType = null; @@ -474,18 +505,23 @@ class WatchTogetherPeerService with KeepaliveMixin { final expectedTeardown = _setupRequestType == RelayProtocol.endSession || _setupRequestType == RelayProtocol.leave; if (expectedTeardown) { + _admitted = false; + _reconnectToken = null; if (_setupCompleter case final completer? when !completer.isCompleted) { _setupCompleter = null; _setupRequestType = null; completer.complete(); } - } else if (!_isHost) { - _handleGuestSessionEnded(); + } else if (_admitted || + _setupRequestType == RelayProtocol.resume || + _setupRequestType == RelayProtocol.join) { + _handleSessionEnded(); } else { _failSetup(_invalidSetupResponse(RelayProtocol.ended)); } case RelayProtocol.hostChanged: + if (!_admitted) break; final newHostPeerId = msg['hostPeerId']; if (msg['sessionId'] != _sessionId || newHostPeerId is! String || @@ -500,6 +536,7 @@ class WatchTogetherPeerService with KeepaliveMixin { _safeAdd(_hostChangedController, newHostPeerId); case RelayProtocol.hostTransferEligibility: + if (!_admitted) break; final targets = msg['hostTransferTargets']; if (!_relayEnforcesHostTransfer || msg['sessionId'] != _sessionId || @@ -517,19 +554,9 @@ class WatchTogetherPeerService with KeepaliveMixin { case RelayProtocol.error: final code = msg['code'] as String? ?? 'unknown'; final message = msg['message'] as String? ?? t.common.unknown; - if (_isExhaustedGuestReconnectRoomNotFound(code)) { - appLogger.d('WatchTogether: Room gone after reconnect retries; guest session ended'); - _handleGuestSessionEnded(); - break; - } appLogger.e('WatchTogether: Server error: $code - $message'); final error = PeerError(type: PeerErrorType.serverError, message: '$code: $message', serverCode: code); - _safeAdd(_errorController, error); - if (_setupCompleter case final completer? when !completer.isCompleted) { - _setupCompleter = null; - _setupRequestType = null; - completer.completeError(error); - } + _failSetup(error); case RelayProtocol.pong: // Handled by resetPongTimer() already @@ -571,8 +598,9 @@ class WatchTogetherPeerService with KeepaliveMixin { /// Handle the WebSocket being closed unexpectedly — attempt reconnection. void _handleWebSocketClosed() { final channel = _channel; - final shouldReconnect = !_initialSetupInProgress && !_teardownInProgress; + final shouldReconnect = !_initialSetupInProgress && !_teardownInProgress && _reconnectToken != null; if (shouldReconnect) ++_connectionEpoch; + _admitted = false; stopKeepalive(); unawaited(_channelSubscription?.cancel()); _channelSubscription = null; @@ -591,17 +619,13 @@ class WatchTogetherPeerService with KeepaliveMixin { } } - /// Attempt to reconnect to the relay and re-join/re-create the room. + /// Recover only the authenticated membership retained by the relay. void _attemptReconnect(int epoch) { if (_disposed || epoch != _connectionEpoch || _sessionId == null) return; if (_reconnectAttempts >= _maxReconnectAttempts) { appLogger.e('WatchTogether: Max reconnect attempts reached'); - _safeAdd( - _errorController, - const PeerError( - type: PeerErrorType.connectionFailed, - message: 'Lost connection to relay after multiple reconnect attempts', - ), + _handleSessionEnded( + PeerError(type: PeerErrorType.connectionFailed, message: t.watchTogether.errors.sessionUnavailable), ); return; } @@ -614,24 +638,9 @@ class WatchTogetherPeerService with KeepaliveMixin { _reconnectTimer = Timer(delay, () async { if (_disposed || epoch != _connectionEpoch || _sessionId == null) return; try { - final completer = await _connectAndAnnounce(RelayProtocol.join, epoch); + final completer = await _connectAndAnnounce(RelayProtocol.resume, epoch); if (_disposed || epoch != _connectionEpoch) return; - - try { - await completer.future.namedTimeout(const Duration(seconds: 10), operation: 'WatchTogether reconnect'); - } on PeerError catch (e) { - if (_disposed || epoch != _connectionEpoch) return; - if (_isHost && e.serverCode == RelayProtocol.roomNotFoundCode) { - appLogger.d('WatchTogether: Room gone, re-creating as host'); - final createCompleter = _announce(RelayProtocol.create); - await createCompleter.future.namedTimeout( - const Duration(seconds: 10), - operation: 'WatchTogether reconnect create', - ); - } else { - rethrow; - } - } + await completer.future.namedTimeout(const Duration(seconds: 10), operation: 'WatchTogether reconnect'); await debugReconnectSetupSucceededBarrier?.call(); @@ -646,6 +655,10 @@ class WatchTogetherPeerService with KeepaliveMixin { } catch (e) { if (_disposed || epoch != _connectionEpoch) return; appLogger.e('WatchTogether: Reconnect failed', error: e); + if (!_isRetryableInitialSetupError(e)) { + _handleSessionEnded(e is PeerError ? e : null); + return; + } _handleWebSocketClosed(); } }); @@ -659,12 +672,23 @@ class WatchTogetherPeerService with KeepaliveMixin { error.type == PeerErrorType.timeout)) || error is! PeerError; + void _requireCurrentConnection(int epoch) { + if (_disposed || epoch != _connectionEpoch || _sessionId == null) { + throw StateError('Watch Together connection attempt became stale'); + } + } + Future _resetTransportForInitialRetry() async { stopKeepalive(); final subscription = _channelSubscription; final channel = _channel; _channelSubscription = null; _channel = null; + _admitted = false; + final completer = _setupCompleter; + if (completer != null && !completer.isCompleted) { + completer.completeError(StateError('Watch Together connection cancelled')); + } _setupCompleter = null; _setupRequestType = null; _clearHostTransferEligibility(resetFeature: true); @@ -681,12 +705,16 @@ class WatchTogetherPeerService with KeepaliveMixin { try { for (var attempt = 0; attempt < _maxReconnectAttempts; attempt++) { try { - final completer = await _connectAndAnnounce(type, epoch); + _requireCurrentConnection(epoch); + final requestType = _initialRequestMayHaveCommitted ? RelayProtocol.resume : type; + final completer = await _connectAndAnnounce(requestType, epoch); await completer.future.timeout(debugInitialSetupTimeout, onTimeout: () => throw timeoutError); + _requireCurrentConnection(epoch); return; } catch (error) { if (_disposed || epoch != _connectionEpoch) rethrow; await _resetTransportForInitialRetry(); + _requireCurrentConnection(epoch); if (!_isRetryableInitialSetupError(error) || attempt + 1 >= _maxReconnectAttempts) { rethrow; } @@ -694,12 +722,14 @@ class WatchTogetherPeerService with KeepaliveMixin { } } } finally { - _initialSetupInProgress = false; + if (epoch == _connectionEpoch) _initialSetupInProgress = false; } } - Future _bestEffortReleaseFailedSetup(Object setupError) async { - if (!_isRetryableInitialSetupError(setupError)) return; + Future _bestEffortReleaseFailedSetup(Object setupError, int epoch) async { + if (epoch != _connectionEpoch || !_initialRequestMayHaveCommitted || !_isRetryableInitialSetupError(setupError)) { + return; + } try { await releaseSession(); } catch (releaseError) { @@ -730,6 +760,7 @@ class WatchTogetherPeerService with KeepaliveMixin { _reconnectToken = _mintReconnectToken(); _reconnectAttempts = 0; final epoch = ++_connectionEpoch; + final peerId = _myPeerId; try { await _performInitialSetup( @@ -742,8 +773,13 @@ class WatchTogetherPeerService with KeepaliveMixin { return _sessionId!; } catch (e) { appLogger.e('WatchTogether: Failed to create session', error: e); - await _bestEffortReleaseFailedSetup(e); - await disconnect(); + if (epoch == _connectionEpoch) { + await _bestEffortReleaseFailedSetup(e, epoch); + // Release owns a new epoch; it must not clear a newer explicit entry. + if (_sessionId == resolvedSessionId && _myPeerId == peerId) { + await disconnect(); + } + } rethrow; } } @@ -768,6 +804,7 @@ class WatchTogetherPeerService with KeepaliveMixin { _reconnectToken = _mintReconnectToken(); _reconnectAttempts = 0; final epoch = ++_connectionEpoch; + final peerId = _myPeerId; try { await _performInitialSetup( @@ -779,14 +816,19 @@ class WatchTogetherPeerService with KeepaliveMixin { appLogger.d('WatchTogether: Joined session: $_sessionId'); } catch (e) { appLogger.e('WatchTogether: Failed to join session', error: e); - await _bestEffortReleaseFailedSetup(e); - await disconnect(); + if (epoch == _connectionEpoch) { + await _bestEffortReleaseFailedSetup(e, epoch); + if (_sessionId == resolvedSessionId && _myPeerId == peerId) { + await disconnect(); + } + } rethrow; } } /// Broadcast a message to all connected peers void broadcast(SyncMessage message) { + if (!_admitted) return; final payload = message.toJson(); _sendRaw({'type': RelayProtocol.broadcast, 'payload': payload}); } @@ -796,6 +838,7 @@ class WatchTogetherPeerService with KeepaliveMixin { if (!RelayProtocol.isValidPeerId(peerId)) { throw ArgumentError.value(peerId, 'peerId', 'Must be 1–${RelayProtocol.maxPeerIdLength} letters, digits, _ or -'); } + if (!_admitted) return; final payload = message.toJson(); _sendRaw({'type': RelayProtocol.sendTo, 'to': peerId, 'payload': payload}); } @@ -846,13 +889,16 @@ class WatchTogetherPeerService with KeepaliveMixin { await _resetTransportForInitialRetry(); } for (var attempt = 0; attempt < _maxReconnectAttempts; attempt++) { + _requireCurrentConnection(epoch); // Which request a rejection below refers to. Re-admission failures // are terminal answers about our identity; release failures are not. var releaseRequested = false; try { - if (_channel == null) { + if (_channel == null || !_admitted) { + if (_channel != null) await _resetTransportForInitialRetry(); + _requireCurrentConnection(epoch); final reconnectCompleter = await _connectAndAnnounce( - RelayProtocol.join, + RelayProtocol.resume, epoch, connectTimeout: debugReleaseConnectTimeout, connectOperation: 'WatchTogether release reconnect', @@ -863,6 +909,7 @@ class WatchTogetherPeerService with KeepaliveMixin { ); } + _requireCurrentConnection(epoch); releaseRequested = true; final releaseCompleter = _announce(_isHost ? RelayProtocol.endSession : RelayProtocol.leave); await releaseCompleter.future.namedTimeout( @@ -871,27 +918,38 @@ class WatchTogetherPeerService with KeepaliveMixin { ); return; } catch (error) { + _requireCurrentConnection(epoch); if (error is PeerError) { - if (_isRoomAbsentCode(error.serverCode)) return; + if (_isRoomAbsentCode(error.serverCode)) { + _admitted = false; + _reconnectToken = null; + return; + } if (error.serverCode == RelayProtocol.peerIdUnavailableCode) { // From re-admission: the token no longer names an identity // here (a processed leave whose ACK was lost, an expired // reservation). Nothing is left to release. - if (!releaseRequested) return; + if (!releaseRequested) { + _admitted = false; + _reconnectToken = null; + return; + } // From the release itself: the relay refused the operation we // chose for the role we believed we had. A transfer that landed // while we were tearing down (a guest promoted to host, a host // demoted) makes exactly this rejection, and a role-mismatch // rejection is not a release. Reauthenticate through the - // token: the `joined` admission names the current host, and + // token: the `resumed` admission names the current host, and // the next pass sends the operation that role requires. appLogger.d('WatchTogether: Release rejected for role host=$_isHost; re-authenticating ownership'); await _resetTransportForInitialRetry(); + _requireCurrentConnection(epoch); if (attempt + 1 >= _maxReconnectAttempts) rethrow; continue; } } await _resetTransportForInitialRetry(); + _requireCurrentConnection(epoch); if (!_isRetryableInitialSetupError(error) || attempt + 1 >= _maxReconnectAttempts) { rethrow; } @@ -899,7 +957,7 @@ class WatchTogetherPeerService with KeepaliveMixin { } } } finally { - _teardownInProgress = false; + if (epoch == _connectionEpoch) _teardownInProgress = false; } } @@ -907,7 +965,7 @@ class WatchTogetherPeerService with KeepaliveMixin { /// intentional exits call [releaseSession] before this cleanup step. Future disconnect() async { appLogger.d('WatchTogether: Disconnecting...'); - ++_connectionEpoch; + final epoch = ++_connectionEpoch; _reconnectTimer?.cancel(); _reconnectTimer = null; stopKeepalive(); @@ -919,6 +977,9 @@ class WatchTogetherPeerService with KeepaliveMixin { final setupCompleter = _setupCompleter; _setupCompleter = null; _setupRequestType = null; + _admitted = false; + _initialSetupInProgress = false; + _initialRequestMayHaveCommitted = false; if (setupCompleter != null && !setupCompleter.isCompleted) { setupCompleter.completeError(StateError('Watch Together connection cancelled')); } @@ -938,7 +999,7 @@ class WatchTogetherPeerService with KeepaliveMixin { } catch (e) { appLogger.d('WatchTogether: channel close ignored', error: e); } - _safeAdd(_connectionStateController, false); + if (epoch == _connectionEpoch) _safeAdd(_connectionStateController, false); } /// Dispose all resources. diff --git a/relay_protocol.json b/relay_protocol.json index 35b41fe1e..fc7af4589 100644 --- a/relay_protocol.json +++ b/relay_protocol.json @@ -4,6 +4,7 @@ "clientMessageTypes": { "create": "create", "join": "join", + "resume": "resume", "broadcast": "broadcast", "sendTo": "sendTo", "ping": "ping", @@ -14,6 +15,7 @@ "serverMessageTypes": { "created": "created", "joined": "joined", + "resumed": "resumed", "peerJoined": "peerJoined", "peerLeft": "peerLeft", "message": "message", @@ -39,7 +41,8 @@ "hostTransferUnavailable": "host_transfer_unavailable" }, "features": { - "atomicHostTransfer": "atomicHostTransfer" + "atomicHostTransfer": "atomicHostTransfer", + "authenticatedResume": "authenticatedResume" }, "capabilities": { "hostTransfer": "hostTransfer" diff --git a/server/main.go b/server/main.go index 637b9d966..dc26b3347 100644 --- a/server/main.go +++ b/server/main.go @@ -1936,7 +1936,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { ReconnectToken: reconnectToken, ProtocolVersion: relayProtocolVersion, Peers: existingPeers, - Features: []string{relayFeatureAtomicHostTransfer}, + Features: []string{relayFeatureAtomicHostTransfer, relayFeatureAuthenticatedResume}, }) if hostWasAbsent { existing.broadcastExceptLocked(msg.PeerID, serverMsg{ @@ -2005,17 +2005,19 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { HostPeerID: msg.PeerID, ReconnectToken: reconnectToken, ProtocolVersion: msg.ProtocolVersion, - Features: []string{relayFeatureAtomicHostTransfer}, + Features: []string{relayFeatureAtomicHostTransfer, relayFeatureAuthenticatedResume}, }) room.publishHostTransferEligibilityLocked() room.mu.Unlock() - case relayTypeJoin: + case relayTypeJoin, relayTypeResume: + resumeOnly := msg.Type == relayTypeResume if !validRelayID(msg.SessionID, maxSessionIDLength) || !validRelayID(msg.PeerID, maxPeerIDLength) { client.sendJSON(serverMsg{Type: relayTypeError, Code: relayErrorInvalidMessage, Message: "Invalid sessionId or peerId"}) continue } - if !supportedRelayProtocolVersion(msg.ProtocolVersion) { + if !supportedRelayProtocolVersion(msg.ProtocolVersion) || + (resumeOnly && msg.ProtocolVersion != relayProtocolVersion) { client.sendJSON(serverMsg{ Type: relayTypeError, Code: relayErrorProtocolMismatch, @@ -2028,10 +2030,15 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { continue } - newToken, newVerifier, err := mintReconnectToken() - if err != nil { - client.sendJSON(serverMsg{Type: relayTypeError, Code: relayErrorInvalidMessage, Message: "Unable to join room"}) - continue + var newToken string + var newVerifier reconnectVerifier + if msg.ProtocolVersion == legacyRelayProtocolVersion { + var err error + newToken, newVerifier, err = mintReconnectToken() + if err != nil { + client.sendJSON(serverMsg{Type: relayTypeError, Code: relayErrorInvalidMessage, Message: "Unable to join room"}) + continue + } } presentedVerifier, tokenValid := reconnectVerifierFromToken(msg.ReconnectToken) @@ -2093,7 +2100,8 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { case occupied: authorized = false default: - authorized = tokenValid + // Only explicit initial admission may allocate an identity. + authorized = !resumeOnly && tokenValid responseToken = msg.ReconnectToken responseVerifier = presentedVerifier } @@ -2181,14 +2189,18 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { if s.beforeJoinRoomAck != nil { s.beforeJoinRoomAck() } + responseType := relayTypeJoined + if resumeOnly { + responseType = relayTypeResumed + } client.sendJSON(serverMsg{ - Type: relayTypeJoined, + Type: responseType, SessionID: msg.SessionID, HostPeerID: room.HostPeerID, ReconnectToken: responseToken, ProtocolVersion: roomProtocolVersion, Peers: existingPeers, - Features: []string{relayFeatureAtomicHostTransfer}, + Features: []string{relayFeatureAtomicHostTransfer, relayFeatureAuthenticatedResume}, }) room.broadcastExceptLocked(msg.PeerID, serverMsg{Type: relayTypePeerJoined, PeerID: msg.PeerID}) room.publishHostTransferEligibilityLocked() diff --git a/server/main_test.go b/server/main_test.go index cbdde990f..2d5909d47 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -2257,7 +2257,7 @@ func TestCreateNegotiatesModernProtocolWithClientKnownToken(t *testing.T) { } } -func TestModernCreateRetryAfterLostSetupResponseIsIdempotent(t *testing.T) { +func TestModernCreateLostSetupResponseResumesCommittedIdentity(t *testing.T) { h := newRelayHarness(t) hostToken, _ := mustReconnectToken(t) create := clientMsg{ @@ -2282,8 +2282,10 @@ func TestModernCreateRetryAfterLostSetupResponseIsIdempotent(t *testing.T) { } retry := h.dial(t, "1.1.1.42") - retry.send(create) - created := retry.expectAuthority(relayTypeCreated, "H") + resume := create + resume.Type = relayTypeResume + retry.send(resume) + created := retry.expectAuthority(relayTypeResumed, "H") if created.ReconnectToken != hostToken || created.ProtocolVersion != relayProtocolVersion { t.Fatalf("retry authority changed: tokenMatch=%v protocol=%d", created.ReconnectToken == hostToken, created.ProtocolVersion) } @@ -3070,7 +3072,7 @@ func TestFullRoomAllowsOnlyAuthenticatedLiveReplacements(t *testing.T) { unprovedHost := h.dial(t, "2.1.0.251") unprovedHost.send(clientMsg{ - Type: relayTypeJoin, + Type: relayTypeResume, SessionID: "FULL", PeerID: "H", ProtocolVersion: relayProtocolVersion, @@ -3078,7 +3080,7 @@ func TestFullRoomAllowsOnlyAuthenticatedLiveReplacements(t *testing.T) { unprovedHost.expectError(relayErrorPeerIdUnavailable) unprovedGuest := h.dial(t, "2.1.0.252") unprovedGuest.send(clientMsg{ - Type: relayTypeJoin, + Type: relayTypeResume, SessionID: "FULL", PeerID: "G1", ProtocolVersion: relayProtocolVersion, @@ -3086,7 +3088,7 @@ func TestFullRoomAllowsOnlyAuthenticatedLiveReplacements(t *testing.T) { unprovedGuest.expectError(relayErrorPeerIdUnavailable) wrongGuestToken, _ := mustReconnectToken(t) unprovedGuest.send(clientMsg{ - Type: relayTypeJoin, + Type: relayTypeResume, SessionID: "FULL", PeerID: "G1", ReconnectToken: wrongGuestToken, @@ -3096,13 +3098,13 @@ func TestFullRoomAllowsOnlyAuthenticatedLiveReplacements(t *testing.T) { newHost := h.dial(t, "2.1.0.253") newHost.send(clientMsg{ - Type: relayTypeJoin, + Type: relayTypeResume, SessionID: "FULL", PeerID: "H", ReconnectToken: created.ReconnectToken, ProtocolVersion: relayProtocolVersion, }) - hostJoined := newHost.expectAuthority(relayTypeJoined, "H") + hostJoined := newHost.expectAuthority(relayTypeResumed, "H") if len(hostJoined.Peers) != maxRoomSize-1 { t.Fatalf("replacement host peers=%v, want %d peers", hostJoined.Peers, maxRoomSize-1) } @@ -3116,13 +3118,13 @@ func TestFullRoomAllowsOnlyAuthenticatedLiveReplacements(t *testing.T) { newGuest := h.dial(t, "2.1.0.254") newGuest.send(clientMsg{ - Type: relayTypeJoin, + Type: relayTypeResume, SessionID: "FULL", PeerID: "G1", ReconnectToken: guestTokens["G1"], ProtocolVersion: relayProtocolVersion, }) - newGuest.expectAuthority(relayTypeJoined, "H") + newGuest.expectAuthority(relayTypeResumed, "H") if messages, err := guests["G1"].recvUntilClosed(2 * time.Second); err != nil { t.Fatalf("displaced guest did not close: %v (frames=%v)", err, messages) } @@ -3269,15 +3271,17 @@ func TestLegacySameSourceHostReconnectAndModernTokenEnforcement(t *testing.T) { }) } -func TestJoinAdmissionIsAtomicWithEmptyRoomCleanup(t *testing.T) { +func TestResumeAdmissionIsAtomicWithEmptyRoomCleanup(t *testing.T) { h := newRelayHarness(t) _, hostVerifier := mustReconnectToken(t) + guestToken, guestVerifier := mustReconnectToken(t) now := time.Now() room := &Room{ SessionID: "ATOMIC_CLEANUP", HostPeerID: "H", hostVerifier: hostVerifier, - peerReservations: make(map[string]peerReservation), + ProtocolVersion: relayProtocolVersion, + peerReservations: map[string]peerReservation{"G1": {verifier: guestVerifier, absentSince: now}}, Peers: make(map[string]*Client), CreatedAt: now.Add(-time.Hour), LastActivityAt: now.Add(-emptyRoomMaxAge - time.Second), @@ -3297,7 +3301,10 @@ func TestJoinAdmissionIsAtomicWithEmptyRoomCleanup(t *testing.T) { } joiner := h.dial(t, "2.2.0.1") - joiner.send(clientMsg{Type: relayTypeJoin, SessionID: room.SessionID, PeerID: "G1"}) + joiner.send(clientMsg{ + Type: relayTypeResume, SessionID: room.SessionID, PeerID: "G1", + ReconnectToken: guestToken, ProtocolVersion: relayProtocolVersion, + }) <-reached cleanupDone := make(chan struct{}) @@ -3312,7 +3319,7 @@ func TestJoinAdmissionIsAtomicWithEmptyRoomCleanup(t *testing.T) { } close(release) - joiner.expectAuthority(relayTypeJoined, "H") + joiner.expectAuthority(relayTypeResumed, "H") <-cleanupDone h.srv.mu.RLock() @@ -3329,7 +3336,11 @@ func TestJoinAdmissionIsAtomicWithEmptyRoomCleanup(t *testing.T) { } second := h.dial(t, "2.2.0.2") - second.send(clientMsg{Type: relayTypeJoin, SessionID: room.SessionID, PeerID: "G2"}) + secondToken, _ := mustReconnectToken(t) + second.send(clientMsg{ + Type: relayTypeJoin, SessionID: room.SessionID, PeerID: "G2", + ReconnectToken: secondToken, ProtocolVersion: relayProtocolVersion, + }) second.expectAuthority(relayTypeJoined, "H") joiner.expect(relayTypePeerJoined) second.send(clientMsg{Type: relayTypeBroadcast, Payload: json.RawMessage(`{"atomic":true}`)}) @@ -3338,15 +3349,17 @@ func TestJoinAdmissionIsAtomicWithEmptyRoomCleanup(t *testing.T) { } } -func TestJoinAdmissionIsAtomicWithReservedRoomCreate(t *testing.T) { +func TestResumeAdmissionIsAtomicWithReservedRoomCreate(t *testing.T) { h := newRelayHarness(t) _, hostVerifier := mustReconnectToken(t) + guestToken, guestVerifier := mustReconnectToken(t) now := time.Now() room := &Room{ SessionID: "ATOMIC_CREATE", HostPeerID: "H", hostVerifier: hostVerifier, - peerReservations: make(map[string]peerReservation), + ProtocolVersion: relayProtocolVersion, + peerReservations: map[string]peerReservation{"G": {verifier: guestVerifier, absentSince: now}}, Peers: make(map[string]*Client), CreatedAt: now, LastActivityAt: now, @@ -3366,11 +3379,18 @@ func TestJoinAdmissionIsAtomicWithReservedRoomCreate(t *testing.T) { } joiner := h.dial(t, "2.3.0.1") - joiner.send(clientMsg{Type: relayTypeJoin, SessionID: room.SessionID, PeerID: "G"}) + joiner.send(clientMsg{ + Type: relayTypeResume, SessionID: room.SessionID, PeerID: "G", + ReconnectToken: guestToken, ProtocolVersion: relayProtocolVersion, + }) <-reached creator := h.dial(t, "2.3.0.2") - creator.send(clientMsg{Type: relayTypeCreate, SessionID: room.SessionID, PeerID: "OTHER"}) + replacementToken, _ := mustReconnectToken(t) + creator.send(clientMsg{ + Type: relayTypeCreate, SessionID: room.SessionID, PeerID: "OTHER", + ReconnectToken: replacementToken, ProtocolVersion: relayProtocolVersion, + }) type readResult struct { message serverMsg err error @@ -3395,7 +3415,7 @@ func TestJoinAdmissionIsAtomicWithReservedRoomCreate(t *testing.T) { } close(release) - joiner.expectAuthority(relayTypeJoined, "H") + joiner.expectAuthority(relayTypeResumed, "H") result := <-createResult if result.err != nil { t.Fatalf("read create result: %v", result.err) @@ -3642,13 +3662,13 @@ func TestStalePeerSkipsCleanupBroadcast(t *testing.T) { g2 := h.dial(t, "6.1.0.3") g2.send(clientMsg{ - Type: relayTypeJoin, + Type: relayTypeResume, SessionID: "D2", PeerID: "G", ReconnectToken: guestToken, ProtocolVersion: relayProtocolVersion, }) - g2.expectAuthority(relayTypeJoined, "H") + g2.expectAuthority(relayTypeResumed, "H") host.expect(relayTypePeerJoined) if messages, err := g1.recvUntilClosed(2 * time.Second); err != nil { @@ -3670,6 +3690,195 @@ func TestStalePeerSkipsCleanupBroadcast(t *testing.T) { } } +func TestResumeCannotEnterReplacementRoom(t *testing.T) { + for _, replacementHost := range []string{"NEW_HOST", "H"} { + t.Run(replacementHost, func(t *testing.T) { + h := newRelayHarness(t) + host, guest, hostToken, guestToken := createModernRoomWithGuest(t, h, "REUSED", "6.5.0.1", "6.5.0.2") + if err := guest.conn.Close(); err != nil { + t.Fatal(err) + } + host.expect(relayTypePeerLeft) + host.send(clientMsg{Type: relayTypeEndSession, ReconnectToken: hostToken, ProtocolVersion: relayProtocolVersion}) + // The old client may lose this terminal ACK; the committed room is gone. + host.expect(relayTypeEnded) + + replacementToken, _ := mustReconnectToken(t) + replacement := h.dial(t, "6.5.0.3") + replacement.send(clientMsg{ + Type: relayTypeCreate, SessionID: "REUSED", PeerID: replacementHost, + ReconnectToken: replacementToken, ProtocolVersion: relayProtocolVersion, + }) + replacement.expectAuthority(relayTypeCreated, replacementHost) + for index, identity := range []struct{ peerID, token string }{{"H", hostToken}, {"G", guestToken}} { + stale := h.dial(t, fmt.Sprintf("6.5.1.%d", index+1)) + stale.send(clientMsg{ + Type: relayTypeResume, SessionID: "REUSED", PeerID: identity.peerID, + ReconnectToken: identity.token, ProtocolVersion: relayProtocolVersion, + }) + stale.expectError(relayErrorPeerIdUnavailable) + stale.send(clientMsg{Type: relayTypeBroadcast, Payload: json.RawMessage(`{"stale":true}`)}) + stale.expectError(relayErrorNotInRoom) + stale.send(clientMsg{Type: relayTypeEndSession, ReconnectToken: identity.token, ProtocolVersion: relayProtocolVersion}) + stale.expectError(relayErrorNotInRoom) + } + // No peerJoined or payload may precede this barrier on the replacement. + replacement.send(clientMsg{Type: relayTypePing}) + replacement.expect(relayTypePong) + h.srv.mu.RLock() + room := h.srv.rooms["REUSED"] + room.mu.RLock() + reserved, connected := len(room.peerReservations), len(room.Peers) + room.mu.RUnlock() + h.srv.mu.RUnlock() + if reserved != 0 || connected != 1 { + t.Fatalf("rejected resumes allocated membership: reserved=%d connected=%d", reserved, connected) + } + }) + } +} + +func TestResumeLostJoinAndLeaveAcknowledgements(t *testing.T) { + h := newRelayHarness(t) + hostToken, _ := mustReconnectToken(t) + host := h.dial(t, "6.5.2.1") + host.send(clientMsg{ + Type: relayTypeCreate, SessionID: "LOST_GUEST_ACK", PeerID: "H", + ReconnectToken: hostToken, ProtocolVersion: relayProtocolVersion, + }) + host.expectAuthority(relayTypeCreated, "H") + guestToken, _ := mustReconnectToken(t) + admission := clientMsg{ + Type: relayTypeJoin, SessionID: "LOST_GUEST_ACK", PeerID: "G", + ReconnectToken: guestToken, ProtocolVersion: relayProtocolVersion, + } + guest := h.dial(t, "6.5.2.2") + guest.send(admission) + host.expect(relayTypePeerJoined) + // Drop the first transport without reading its committed join ACK. + if err := guest.conn.Close(); err != nil { + t.Fatal(err) + } + host.expect(relayTypePeerLeft) + resumed := h.dial(t, "6.5.2.3") + admission.Type = relayTypeResume + resumed.send(admission) + ack := resumed.expectAuthority(relayTypeResumed, "H") + if !slices.Contains(ack.Features, relayFeatureAuthenticatedResume) { + t.Fatal("resume did not advertise authenticated admission") + } + host.expect(relayTypePeerJoined) + resumed.send(clientMsg{Type: relayTypeLeave, ReconnectToken: guestToken, ProtocolVersion: relayProtocolVersion}) + host.expect(relayTypePeerLeft) + // The leave committed, but the departing peer never reads its ACK. + if err := resumed.conn.Close(); err != nil { + t.Fatal(err) + } + cleanup := h.dial(t, "6.5.2.4") + cleanup.send(admission) + cleanup.expectError(relayErrorPeerIdUnavailable) + cleanup.send(clientMsg{Type: relayTypeLeave, ReconnectToken: guestToken, ProtocolVersion: relayProtocolVersion}) + cleanup.expectError(relayErrorNotInRoom) + host.send(clientMsg{Type: relayTypePing}) + host.expect(relayTypePong) +} + +func TestResumeRequiresUnexpiredExistingMembership(t *testing.T) { + stateFile := filepath.Join(t.TempDir(), "rooms.json") + h := newRelayHarnessAt(t, t.TempDir(), stateFile) + host, guest, _, guestToken := createModernRoomWithGuest(t, h, "EXPIRING_RESUME", "6.5.3.1", "6.5.3.2") + if err := guest.conn.Close(); err != nil { + t.Fatal(err) + } + host.expect(relayTypePeerLeft) + h.srv.mu.RLock() + room := h.srv.rooms["EXPIRING_RESUME"] + h.srv.mu.RUnlock() + expireDisconnectedReservations(room, time.Now()) + + resume := clientMsg{ + Type: relayTypeResume, SessionID: "EXPIRING_RESUME", PeerID: "G", + ReconnectToken: guestToken, ProtocolVersion: relayProtocolVersion, + } + probe := h.dial(t, "6.5.3.3") + probe.send(resume) + probe.expectError(relayErrorPeerIdUnavailable) + resume.PeerID = "NEVER_JOINED" + probe.send(resume) + probe.expectError(relayErrorPeerIdUnavailable) + resume.SessionID = "NEVER_CREATED" + probe.send(resume) + probe.expectError(relayErrorRoomNotFound) + resume.SessionID = "EXPIRING_RESUME" + resume.ProtocolVersion = legacyRelayProtocolVersion + probe.send(resume) + probe.expectError(relayErrorProtocolMismatch) + host.send(clientMsg{Type: relayTypePing}) + host.expect(relayTypePong) + + // Admission-time expiry is persisted; a restart cannot resurrect the guest. + if err := h.srv.snap.flushAndStop(2 * time.Second); err != nil { + t.Fatalf("flush reservation expiry: %v", err) + } + restarted := newRelayHarnessAt(t, t.TempDir(), copySnapshotForRestart(t, stateFile)) + afterRestart := restarted.dial(t, "6.5.3.4") + resume.PeerID = "G" + resume.ProtocolVersion = relayProtocolVersion + afterRestart.send(resume) + afterRestart.expectError(relayErrorPeerIdUnavailable) + + // An explicit new join is still allowed to claim the freed identity. + freshToken, _ := mustReconnectToken(t) + resume.Type = relayTypeJoin + resume.ReconnectToken = freshToken + probe.send(resume) + probe.expectAuthority(relayTypeJoined, "H") + host.expect(relayTypePeerJoined) +} + +func TestOfflineGuestResumesAfterHostTransfer(t *testing.T) { + h := newRelayHarness(t) + host, promoted, _, _ := createTransferRoomWithGuest(t, h, "OFFLINE_TRANSFER", "6.5.4.1", "6.5.4.2") + token, _ := mustReconnectToken(t) + offline := h.dial(t, "6.5.4.3") + admission := currentAdmission(clientMsg{ + Type: relayTypeJoin, SessionID: "OFFLINE_TRANSFER", PeerID: "OFFLINE", + ReconnectToken: token, ProtocolVersion: relayProtocolVersion, + }) + offline.send(admission) + offline.expectAuthority(relayTypeJoined, "H") + offline.expectEligibility("OFFLINE_TRANSFER", "H", "G", "OFFLINE") + host.expect(relayTypePeerJoined) + host.expectEligibility("OFFLINE_TRANSFER", "H", "G", "OFFLINE") + promoted.expect(relayTypePeerJoined) + promoted.expectEligibility("OFFLINE_TRANSFER", "H", "G", "OFFLINE") + if err := offline.conn.Close(); err != nil { + t.Fatal(err) + } + host.expect(relayTypePeerLeft) + host.expectEligibility("OFFLINE_TRANSFER", "H", "G") + promoted.expect(relayTypePeerLeft) + promoted.expectEligibility("OFFLINE_TRANSFER", "H", "G") + host.send(clientMsg{Type: relayTypeTransferHost, To: "G", ProtocolVersion: relayProtocolVersion}) + host.expect(relayTypeHostChanged) + host.expectEligibility("OFFLINE_TRANSFER", "G", "H") + promoted.expect(relayTypeHostChanged) + promoted.expectEligibility("OFFLINE_TRANSFER", "G", "H") + admission.Type = relayTypeResume + resumed := h.dial(t, "6.5.4.4") + resumed.send(admission) + resumed.expectAuthority(relayTypeResumed, "G") + resumed.expectEligibility("OFFLINE_TRANSFER", "G", "H", "OFFLINE") + host.expect(relayTypePeerJoined) + host.expectEligibility("OFFLINE_TRANSFER", "G", "H", "OFFLINE") + promoted.expect(relayTypePeerJoined) + promoted.expectEligibility("OFFLINE_TRANSFER", "G", "H", "OFFLINE") + resumed.send(clientMsg{Type: relayTypeSendTo, To: "G", Payload: json.RawMessage(`{"resumed":true}`)}) + if received := promoted.expect(relayTypeMessage); received.From != "OFFLINE" { + t.Fatalf("resumed sender=%q, want OFFLINE", received.From) + } +} + func TestDisconnectedModernGuestIdentityRejectsTheftAndAcceptsRightfulReconnect(t *testing.T) { h := newRelayHarness(t) hostToken, _ := mustReconnectToken(t) @@ -3718,7 +3927,7 @@ func TestDisconnectedModernGuestIdentityRejectsTheftAndAcceptsRightfulReconnect( thiefToken, _ := mustReconnectToken(t) thief := h.dial(t, "6.1.0.12") thief.send(clientMsg{ - Type: relayTypeJoin, + Type: relayTypeResume, SessionID: "GUEST_RECONNECT", PeerID: "G", ReconnectToken: thiefToken, @@ -3730,13 +3939,13 @@ func TestDisconnectedModernGuestIdentityRejectsTheftAndAcceptsRightfulReconnect( rightful := h.dial(t, "6.1.0.13") rightful.send(clientMsg{ - Type: relayTypeJoin, + Type: relayTypeResume, SessionID: "GUEST_RECONNECT", PeerID: "G", ReconnectToken: guestToken, ProtocolVersion: relayProtocolVersion, }) - joined := rightful.expectAuthority(relayTypeJoined, "H") + joined := rightful.expectAuthority(relayTypeResumed, "H") if joined.ReconnectToken != guestToken { t.Fatal("rightful reconnect rotated the retained guest token") } @@ -3921,7 +4130,7 @@ func TestAdmissionPrunePersistsWhenJoinRejected(t *testing.T) { wrongHostToken, _ := mustReconnectToken(t) rejected := h.dial(t, "6.2.2.3") rejected.send(clientMsg{ - Type: relayTypeJoin, + Type: relayTypeResume, SessionID: "REJECTED_AFTER_PRUNE", PeerID: "H", ReconnectToken: wrongHostToken, @@ -3986,7 +4195,7 @@ func TestGuestReservationSnapshotV4Migration(t *testing.T) { statePath := filepath.Join(t.TempDir(), "rooms.json") now := time.Now().UTC() _, hostVerifier := mustReconnectToken(t) - _, guestVerifier := mustReconnectToken(t) + guestToken, guestVerifier := mustReconnectToken(t) legacy := stateSnapshot{ Version: 3, SavedAt: now, @@ -4039,6 +4248,12 @@ func TestGuestReservationSnapshotV4Migration(t *testing.T) { if runtimeReservation.absentSince.IsZero() { t.Fatal("legacy reservation was restored as connected") } + guest := h.dial(t, "6.2.4.1") + guest.send(clientMsg{ + Type: relayTypeResume, SessionID: "V3_MIGRATION", PeerID: "G", + ReconnectToken: guestToken, ProtocolVersion: relayProtocolVersion, + }) + guest.expectAuthority(relayTypeResumed, "H") } func TestSnapshotV2LoadsAndRewritesV4(t *testing.T) { @@ -4081,6 +4296,12 @@ func TestSnapshotV2LoadsAndRewritesV4(t *testing.T) { if snapshot.Version != snapshotFormatVersion { t.Fatalf("v2 rewrite version=%d, want %d", snapshot.Version, snapshotFormatVersion) } + legacyHost := h.dial(t, "6.2.4.2") + legacyHost.send(clientMsg{ + Type: relayTypeResume, SessionID: "V2_MIGRATION", PeerID: "H", + ProtocolVersion: legacyRelayProtocolVersion, + }) + legacyHost.expectError(relayErrorProtocolMismatch) } func TestSnapshotV4RetainsGuestAbsenceAcrossRestart(t *testing.T) { @@ -4722,7 +4943,7 @@ func TestLeavePendingReservationRejectsReplacement(t *testing.T) { matching := h.dial(t, "6.3.1.3") matching.send(clientMsg{ - Type: relayTypeJoin, + Type: relayTypeResume, SessionID: "PENDING_RELEASE", PeerID: "G", ReconnectToken: guestToken, @@ -4732,7 +4953,7 @@ func TestLeavePendingReservationRejectsReplacement(t *testing.T) { wrongToken, _ := mustReconnectToken(t) wrong := h.dial(t, "6.3.1.4") wrong.send(clientMsg{ - Type: relayTypeJoin, + Type: relayTypeResume, SessionID: "PENDING_RELEASE", PeerID: "G", ReconnectToken: wrongToken, @@ -5587,9 +5808,9 @@ func TestTransferHostAdmissionDoesNotReuseConnectionCapability(t *testing.T) { typ string }{ {"host create retry", "H", relayTypeCreate}, - {"host join", "H", relayTypeJoin}, - {"target join", "G", relayTypeJoin}, - {"bystander join", "B", relayTypeJoin}, + {"host resume", "H", relayTypeResume}, + {"target resume", "G", relayTypeResume}, + {"bystander resume", "B", relayTypeResume}, } { for _, disconnected := range []bool{false, true} { t.Run(fmt.Sprintf("%s/disconnected=%v", admission.name, disconnected), func(t *testing.T) { @@ -5637,7 +5858,7 @@ func TestTransferHostAdmissionDoesNotReuseConnectionCapability(t *testing.T) { Type: admission.typ, SessionID: "XFER_REPLACE", PeerID: admission.peerID, ReconnectToken: token, ProtocolVersion: relayProtocolVersion, }) - ackType := relayTypeJoined + ackType := relayTypeResumed if admission.typ == relayTypeCreate { ackType = relayTypeCreated } @@ -5645,7 +5866,7 @@ func TestTransferHostAdmissionDoesNotReuseConnectionCapability(t *testing.T) { t.Fatalf("replacement changed reconnect identity: %+v", ack) } for _, observer := range observers { - if admission.typ == relayTypeJoin || disconnected { + if admission.typ == relayTypeResume || disconnected { if joined := observer.expect(relayTypePeerJoined); joined.PeerID != admission.peerID { t.Fatalf("wrong replacement identity: %+v", joined) } @@ -6030,7 +6251,7 @@ func TestTransferHostPreservesReconnectAuthority(t *testing.T) { guest.expect(relayTypeHostChanged) guest.expectEligibility("XFER_RECONNECT", "G", "H") - // The old host drops and rejoins with its original token as a guest. + // The old host resumes with its original token as a guest. if err := host.conn.Close(); err != nil { t.Fatalf("close old host: %v", err) } @@ -6041,13 +6262,13 @@ func TestTransferHostPreservesReconnectAuthority(t *testing.T) { guest.expectEligibility("XFER_RECONNECT", "G") oldHost := h.dial(t, "6.3.4.3") oldHost.send(currentAdmission(clientMsg{ - Type: relayTypeJoin, + Type: relayTypeResume, SessionID: "XFER_RECONNECT", PeerID: "H", ReconnectToken: hostToken, ProtocolVersion: relayProtocolVersion, })) - rejoinedGuest := oldHost.expectAuthority(relayTypeJoined, "G") + rejoinedGuest := oldHost.expectAuthority(relayTypeResumed, "G") if rejoinedGuest.ReconnectToken != hostToken { t.Fatalf("old host reconnect token changed: %+v", rejoinedGuest) } @@ -6055,7 +6276,7 @@ func TestTransferHostPreservesReconnectAuthority(t *testing.T) { guest.expect(relayTypePeerJoined) guest.expectEligibility("XFER_RECONNECT", "G", "H") - // The new host drops and rejoins with its original token as the host. + // The new host resumes with its original token as the host. if err := guest.conn.Close(); err != nil { t.Fatalf("close new host: %v", err) } @@ -6066,13 +6287,13 @@ func TestTransferHostPreservesReconnectAuthority(t *testing.T) { oldHost.expectEligibility("XFER_RECONNECT", "G") newHost := h.dial(t, "6.3.4.4") newHost.send(currentAdmission(clientMsg{ - Type: relayTypeJoin, + Type: relayTypeResume, SessionID: "XFER_RECONNECT", PeerID: "G", ReconnectToken: guestToken, ProtocolVersion: relayProtocolVersion, })) - rejoinedHost := newHost.expectAuthority(relayTypeJoined, "G") + rejoinedHost := newHost.expectAuthority(relayTypeResumed, "G") if rejoinedHost.ReconnectToken != guestToken { t.Fatalf("new host reconnect token changed: %+v", rejoinedHost) } @@ -6127,22 +6348,22 @@ func TestTransferHostSurvivesSnapshotRestart(t *testing.T) { newHost := hB.dial(t, "6.3.5.3") newHost.send(clientMsg{ - Type: relayTypeJoin, + Type: relayTypeResume, SessionID: "XFER_RESTART", PeerID: "G", ReconnectToken: guestToken, ProtocolVersion: relayProtocolVersion, }) - newHost.expectAuthority(relayTypeJoined, "G") + newHost.expectAuthority(relayTypeResumed, "G") oldHost := hB.dial(t, "6.3.5.4") oldHost.send(clientMsg{ - Type: relayTypeJoin, + Type: relayTypeResume, SessionID: "XFER_RESTART", PeerID: "H", ReconnectToken: hostToken, ProtocolVersion: relayProtocolVersion, }) - oldHost.expectAuthority(relayTypeJoined, "G") + oldHost.expectAuthority(relayTypeResumed, "G") newHost.expect(relayTypePeerJoined) // Persisted identity does not imply capability on these fresh connections. newHost.send(clientMsg{Type: relayTypeTransferHost, To: "H", ProtocolVersion: relayProtocolVersion}) @@ -8745,7 +8966,7 @@ func TestSnapshotV4RetainsModernHostAndGuestReservationsAcrossRestart(t *testing wrongHostToken, _ := mustReconnectToken(t) hostThief := hB.dial(t, "8.0.1.3") hostThief.send(clientMsg{ - Type: relayTypeJoin, + Type: relayTypeResume, SessionID: "V3_RESTART", PeerID: "H", ReconnectToken: wrongHostToken, @@ -8755,13 +8976,13 @@ func TestSnapshotV4RetainsModernHostAndGuestReservationsAcrossRestart(t *testing restartedHost := hB.dial(t, "8.0.1.4") restartedHost.send(clientMsg{ - Type: relayTypeJoin, + Type: relayTypeResume, SessionID: "V3_RESTART", PeerID: "H", ReconnectToken: hostToken, ProtocolVersion: relayProtocolVersion, }) - hostJoined := restartedHost.expectAuthority(relayTypeJoined, "H") + hostJoined := restartedHost.expectAuthority(relayTypeResumed, "H") if hostJoined.ReconnectToken != hostToken || hostJoined.ProtocolVersion != relayProtocolVersion { t.Fatalf("restored host authority changed: %+v", hostJoined) } @@ -8769,7 +8990,7 @@ func TestSnapshotV4RetainsModernHostAndGuestReservationsAcrossRestart(t *testing wrongGuestToken, _ := mustReconnectToken(t) guestThief := hB.dial(t, "8.0.1.5") guestThief.send(clientMsg{ - Type: relayTypeJoin, + Type: relayTypeResume, SessionID: "V3_RESTART", PeerID: "G", ReconnectToken: wrongGuestToken, @@ -8779,13 +9000,13 @@ func TestSnapshotV4RetainsModernHostAndGuestReservationsAcrossRestart(t *testing restartedGuest := hB.dial(t, "8.0.1.6") restartedGuest.send(clientMsg{ - Type: relayTypeJoin, + Type: relayTypeResume, SessionID: "V3_RESTART", PeerID: "G", ReconnectToken: guestToken, ProtocolVersion: relayProtocolVersion, }) - guestJoined := restartedGuest.expectAuthority(relayTypeJoined, "H") + guestJoined := restartedGuest.expectAuthority(relayTypeResumed, "H") if guestJoined.ReconnectToken != guestToken || guestJoined.ProtocolVersion != relayProtocolVersion { t.Fatalf("restored guest authority changed: %+v", guestJoined) } diff --git a/server/relay_protocol_gen.go b/server/relay_protocol_gen.go index 645fb55b7..7abbf55bc 100644 --- a/server/relay_protocol_gen.go +++ b/server/relay_protocol_gen.go @@ -8,6 +8,7 @@ const ( relayTypeCreate = "create" relayTypeJoin = "join" + relayTypeResume = "resume" relayTypeBroadcast = "broadcast" relayTypeSendTo = "sendTo" relayTypePing = "ping" @@ -16,6 +17,7 @@ const ( relayTypeTransferHost = "transferHost" relayTypeCreated = "created" relayTypeJoined = "joined" + relayTypeResumed = "resumed" relayTypePeerJoined = "peerJoined" relayTypePeerLeft = "peerLeft" relayTypeMessage = "message" @@ -38,6 +40,7 @@ const ( relayErrorPeerNotFound = "peer_not_found" relayErrorHostTransferUnavailable = "host_transfer_unavailable" relayFeatureAtomicHostTransfer = "atomicHostTransfer" + relayFeatureAuthenticatedResume = "authenticatedResume" relayCapabilityHostTransfer = "hostTransfer" maxRoomSize = 8 diff --git a/test/watch_together/watch_together_peer_service_test.dart b/test/watch_together/watch_together_peer_service_test.dart index 31b83b42f..a2aa9326c 100644 --- a/test/watch_together/watch_together_peer_service_test.dart +++ b/test/watch_together/watch_together_peer_service_test.dart @@ -5,7 +5,6 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_channel/stream_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; -import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/watch_together/services/watch_together_peer_service.dart'; import 'package:plezy/watch_together/services/watch_together_relay_endpoint.dart'; import 'package:plezy/watch_together/models/sync_message.dart'; @@ -311,9 +310,10 @@ void main() { test('guest reconnect sends its retained capability', () async { late final _RelayServer relay; relay = await relayWith((_, socket, message) { - if (message['type'] == 'join') { + if (message['type'] == 'join' || message['type'] == 'resume') { relay.send(socket, { - 'type': 'joined', + 'type': message['type'] == 'join' ? 'joined' : 'resumed', + 'features': [RelayProtocol.authenticatedResumeFeature], 'sessionId': message['sessionId'], 'hostPeerId': _relayHostId, 'reconnectToken': message['reconnectToken'], @@ -338,7 +338,7 @@ void main() { final initialToken = relay.messages[0].single['reconnectToken']; expect(relay.messages[1], [ { - 'type': 'join', + 'type': 'resume', 'sessionId': 'GUEST1', 'peerId': guestPeerId, 'reconnectToken': initialToken, @@ -357,9 +357,10 @@ void main() { // mirrors it and surfaces the change through the same onHostChanged path. late final _RelayServer relay; relay = await relayWith((connection, socket, message) { - if (message['type'] == 'join') { + if (message['type'] == 'join' || message['type'] == 'resume') { relay.send(socket, { - 'type': 'joined', + 'type': message['type'] == 'join' ? 'joined' : 'resumed', + 'features': [RelayProtocol.authenticatedResumeFeature], 'sessionId': message['sessionId'], 'hostPeerId': connection == 0 ? _relayHostId : 'new-host', 'reconnectToken': message['reconnectToken'], @@ -393,10 +394,10 @@ void main() { expect(service.hostPeerId, 'new-host'); expect(service.isHost, isFalse); expect(errors, isEmpty); - expect(relay.messages[1].map((m) => m['type']), ['join'], reason: 'no leave: the admission stands'); + expect(relay.messages[1].map((m) => m['type']), ['resume'], reason: 'authenticated continuity is required'); }); - test('host reconnect proves ownership and re-creates with the retained authority', () async { + test('host membership loss is terminal and never recreates its room', () async { late final _RelayServer relay; relay = await relayWith((connection, socket, message) { if (connection == 0 && message['type'] == 'create') { @@ -407,81 +408,44 @@ void main() { 'reconnectToken': message['reconnectToken'], 'protocolVersion': 2, }); - } else if (connection == 1 && message['type'] == 'join') { + } else if (message['type'] == 'resume') { relay.send(socket, {'type': 'error', 'code': 'room_not_found', 'message': 'Room not found'}); - } else if (connection == 1 && message['type'] == 'create') { - relay.send(socket, { - 'type': 'created', - 'sessionId': message['sessionId'], - 'hostPeerId': message['peerId'], - 'reconnectToken': message['reconnectToken'], - 'protocolVersion': 2, - }); } }); final service = serviceFor(relay); - final reconnected = Completer(); var reconnectCallbacks = 0; - service.onReconnected = () { - reconnectCallbacks++; - reconnected.complete(); - }; + service.onReconnected = () => reconnectCallbacks++; + final ended = service.onSessionEnded.first; await _withShortenedTimer( original: const Duration(seconds: 2), replacement: const Duration(milliseconds: 10), - body: () => service.createSession(sessionId: 'room2'), + body: () async { + await service.createSession(sessionId: 'room2'); + await relay.sockets.single.close(); + await ended.timeout(const Duration(seconds: 1)); + await service.releaseSession(); + }, ); - final hostPeerId = service.myPeerId; - await relay.sockets.single.close(); - await reconnected.future.timeout(const Duration(seconds: 6)); - - expect(reconnectCallbacks, 1); - expect(relay.sockets, hasLength(2)); - final initialCreate = relay.messages[0].single; - final reconnectToken = initialCreate['reconnectToken']; - expect(initialCreate, { - 'type': 'create', - 'sessionId': 'ROOM2', - 'peerId': hostPeerId, - 'reconnectToken': matches(RegExp(r'^[A-Za-z0-9_-]{43}$')), - 'protocolVersion': 2, - 'syncProtocolVersion': SyncMessage.protocolVersion, - 'capabilities': [RelayProtocol.hostTransferCapability], - }); - expect(relay.messages[1], [ - { - 'type': 'join', - 'sessionId': 'ROOM2', - 'peerId': hostPeerId, - 'reconnectToken': reconnectToken, - 'protocolVersion': 2, - 'syncProtocolVersion': SyncMessage.protocolVersion, - 'capabilities': [RelayProtocol.hostTransferCapability], - }, - { - 'type': 'create', - 'sessionId': 'ROOM2', - 'peerId': hostPeerId, - 'reconnectToken': reconnectToken, - 'protocolVersion': 2, - 'syncProtocolVersion': SyncMessage.protocolVersion, - 'capabilities': [RelayProtocol.hostTransferCapability], - }, + expect(reconnectCallbacks, 0); + expect(service.sessionId, isNull); + expect(relay.messages.map((messages) => messages.map((message) => message['type']).toList()), [ + ['create'], + ['resume'], ]); - expect(service.hostPeerId, hostPeerId); }); - test('initial create retry reuses its pre-minted identity and capability', () async { + test('lost create ACK resumes the pre-minted identity without another create', () async { late final _RelayServer relay; relay = await relayWith((connection, socket, message) async { - if (message['type'] != 'create') return; + if (message['type'] != 'create' && message['type'] != 'resume') return; if (connection == 0) { await socket.close(); return; } relay.send(socket, { - 'type': 'created', + 'type': 'resumed', + 'features': [RelayProtocol.authenticatedResumeFeature], 'sessionId': message['sessionId'], 'hostPeerId': message['peerId'], 'reconnectToken': message['reconnectToken'], @@ -500,7 +464,7 @@ void main() { final first = relay.messages[0].single; final retry = relay.messages[1].single; expect(first['reconnectToken'], matches(RegExp(r'^[A-Za-z0-9_-]{43}$'))); - expect(retry, first); + expect(retry, {...first, 'type': 'resume'}); expect(service.myPeerId, first['peerId']); expect(service.hostPeerId, first['peerId']); }); @@ -517,11 +481,7 @@ void main() { await expectLater( _withRetryBackoffShortened(() => timeoutService.createSession(sessionId: 'slow1')), - throwsA( - isA() - .having((error) => error.type, 'type', PeerErrorType.timeout) - .having((error) => error.message, 'message', t.watchTogether.errors.timedOut), - ), + throwsA(isA().having((error) => error.type, 'type', PeerErrorType.timeout)), ); late final _RelayServer errorRelay; @@ -556,8 +516,7 @@ void main() { throwsA( isA() .having((error) => error.type, 'type', PeerErrorType.serverError) - .having((error) => error.serverCode, 'serverCode', 'protocol_mismatch') - .having((error) => error.message, 'message', contains('Relay protocol version 2 is required')), + .having((error) => error.serverCode, 'serverCode', 'protocol_mismatch'), ), ); }); @@ -579,11 +538,7 @@ void main() { await expectLater( service.createSession(sessionId: 'token1'), - throwsA( - isA() - .having((error) => error.type, 'type', PeerErrorType.serverError) - .having((error) => error.message, 'message', t.watchTogether.errors.invalidRelayResponse), - ), + throwsA(isA().having((error) => error.type, 'type', PeerErrorType.serverError)), ); expect(service.hostPeerId, isNull); }); @@ -597,11 +552,7 @@ void main() { await expectLater( oldRelayService.createSession(sessionId: 'old01'), - throwsA( - isA() - .having((error) => error.type, 'type', PeerErrorType.serverError) - .having((error) => error.message, 'message', t.watchTogether.errors.invalidRelayResponse), - ), + throwsA(isA().having((error) => error.type, 'type', PeerErrorType.serverError)), ); expect(oldRelayService.hostPeerId, isNull); @@ -619,11 +570,7 @@ void main() { await expectLater( malformedService.joinSession('bad01'), - throwsA( - isA() - .having((error) => error.type, 'type', PeerErrorType.serverError) - .having((error) => error.message, 'message', t.watchTogether.errors.invalidRelayResponse), - ), + throwsA(isA().having((error) => error.type, 'type', PeerErrorType.serverError)), ); expect(malformedService.hostPeerId, isNull); }); @@ -631,9 +578,10 @@ void main() { test('exhausted create retries end a possibly committed room before clearing credentials', () async { late final _RelayServer relay; relay = await relayWith((connection, socket, message) { - if (connection >= 3 && message['type'] == 'join') { + if (connection >= 3 && message['type'] == 'resume') { relay.send(socket, { - 'type': 'joined', + 'type': 'resumed', + 'features': [RelayProtocol.authenticatedResumeFeature], 'sessionId': message['sessionId'], 'hostPeerId': message['peerId'], 'reconnectToken': message['reconnectToken'], @@ -651,18 +599,14 @@ void main() { await expectLater( _withRetryBackoffShortened(() => service.createSession(sessionId: 'lostc')), - throwsA( - isA() - .having((error) => error.type, 'type', PeerErrorType.timeout) - .having((error) => error.message, 'message', t.watchTogether.errors.timedOut), - ), + throwsA(isA().having((error) => error.type, 'type', PeerErrorType.timeout)), ); expect(relay.messages.map((messages) => messages.map((message) => message['type']).toList()).toList(), [ ['create'], - ['create'], - ['create'], - ['join', 'endSession'], + ['resume'], + ['resume'], + ['resume', 'endSession'], ]); final announcements = relay.messages.map((messages) => messages.first).toList(); expect(announcements.map((message) => message['peerId']).toSet(), hasLength(1)); @@ -674,9 +618,10 @@ void main() { test('exhausted join retries leave a possibly committed guest reservation before clearing credentials', () async { late final _RelayServer relay; relay = await relayWith((connection, socket, message) { - if (connection >= 3 && message['type'] == 'join') { + if (connection >= 3 && message['type'] == 'resume') { relay.send(socket, { - 'type': 'joined', + 'type': 'resumed', + 'features': [RelayProtocol.authenticatedResumeFeature], 'sessionId': message['sessionId'], 'hostPeerId': _relayHostId, 'reconnectToken': message['reconnectToken'], @@ -696,18 +641,14 @@ void main() { await expectLater( _withRetryBackoffShortened(() => service.joinSession('lostj')), - throwsA( - isA() - .having((error) => error.type, 'type', PeerErrorType.timeout) - .having((error) => error.message, 'message', isNotEmpty), - ), + throwsA(isA().having((error) => error.type, 'type', PeerErrorType.timeout)), ); expect(relay.messages.map((messages) => messages.map((message) => message['type']).toList()).toList(), [ ['join'], - ['join'], - ['join'], - ['join', 'leave'], + ['resume'], + ['resume'], + ['resume', 'leave'], ]); final announcements = relay.messages.map((messages) => messages.first).toList(); expect(announcements.map((message) => message['peerId']).toSet(), hasLength(1)); @@ -728,11 +669,7 @@ void main() { await expectLater( service.joinSession('ended2'), - throwsA( - isA() - .having((error) => error.type, 'type', PeerErrorType.invalidSession) - .having((error) => error.message, 'message', t.watchTogether.errors.sessionEnded), - ), + throwsA(isA().having((error) => error.type, 'type', PeerErrorType.invalidSession)), ); await ended.timeout(const Duration(seconds: 1)); @@ -744,7 +681,7 @@ void main() { test('guest reconnect treats ended as terminal and cancels further reconnects', () async { late final _RelayServer relay; relay = await relayWith((connection, socket, message) { - if (message['type'] != 'join') return; + if (message['type'] != 'join' && message['type'] != 'resume') return; if (connection == 0) { relay.send(socket, { 'type': 'joined', @@ -779,10 +716,10 @@ void main() { expect(relay.sockets, hasLength(2)); }); - test('guest reconnect converges to session ended after room-not-found retries are exhausted', () async { + test('guest membership loss is terminal without retrying initial admission', () async { late final _RelayServer relay; relay = await relayWith((connection, socket, message) { - if (message['type'] != 'join') return; + if (message['type'] != 'join' && message['type'] != 'resume') return; if (connection == 0) { relay.send(socket, { 'type': 'joined', @@ -828,10 +765,10 @@ void main() { expect(reconnectCallbacks, 0); expect(sessionEndedEvents, 1); expect(service.isConnected, isFalse); - expect(relay.sockets, hasLength(4)); + expect(relay.sockets, hasLength(2)); expect( relay.messages.skip(1).map((messages) => messages.map((message) => message['type']).toList()), - everyElement(['join']), + everyElement(['resume']), ); }); @@ -916,7 +853,7 @@ void main() { 'protocolVersion': 2, 'peers': [_relayHostId], }); - } else if (connection == 1 && message['type'] == 'join') { + } else if (connection == 1 && message['type'] == 'resume') { relay.send(socket, { 'type': 'error', 'code': 'peer_id_unavailable', @@ -931,7 +868,7 @@ void main() { expect(relay.messages, hasLength(2)); expect(relay.messages[0].map((message) => message['type']), ['join', 'leave']); - expect(relay.messages[1].map((message) => message['type']), ['join']); + expect(relay.messages[1].map((message) => message['type']), ['resume']); }); test('a guest promoted mid-teardown ends the room instead of reading the rejected leave as released', () async { @@ -944,8 +881,10 @@ void main() { relay = await relayWith((connection, socket, message) { switch (message['type']) { case 'join': + case 'resume': relay.send(socket, { - 'type': 'joined', + 'type': message['type'] == 'join' ? 'joined' : 'resumed', + 'features': [RelayProtocol.authenticatedResumeFeature], 'sessionId': message['sessionId'], 'hostPeerId': connection == 0 ? _relayHostId : message['peerId'], 'reconnectToken': message['reconnectToken'], @@ -970,7 +909,7 @@ void main() { expect(relay.messages, hasLength(2)); expect(relay.messages[0].map((message) => message['type']), ['join', 'leave']); - expect(relay.messages[1].map((message) => message['type']), ['join', 'endSession']); + expect(relay.messages[1].map((message) => message['type']), ['resume', 'endSession']); }); test('a host demoted mid-teardown leaves as the guest it became instead of failing the exit', () async { @@ -987,9 +926,10 @@ void main() { }); case 'endSession': relay.send(socket, {'type': 'error', 'code': 'peer_id_unavailable', 'message': 'Unable to end room'}); - case 'join': + case 'resume': relay.send(socket, { - 'type': 'joined', + 'type': 'resumed', + 'features': [RelayProtocol.authenticatedResumeFeature], 'sessionId': message['sessionId'], 'hostPeerId': 'new-host', 'reconnectToken': message['reconnectToken'], @@ -1016,7 +956,7 @@ void main() { expect(relay.messages, hasLength(2)); expect(relay.messages[0].map((message) => message['type']), ['create', 'endSession']); - expect(relay.messages[1].map((message) => message['type']), ['join', 'leave']); + expect(relay.messages[1].map((message) => message['type']), ['resume', 'leave']); }); test('a release the relay keeps refusing for an unchanged role stays bounded and surfaces', () async { @@ -1024,8 +964,10 @@ void main() { relay = await relayWith((connection, socket, message) { switch (message['type']) { case 'join': + case 'resume': relay.send(socket, { - 'type': 'joined', + 'type': message['type'] == 'join' ? 'joined' : 'resumed', + 'features': [RelayProtocol.authenticatedResumeFeature], 'sessionId': message['sessionId'], 'hostPeerId': _relayHostId, 'reconnectToken': message['reconnectToken'], @@ -1045,8 +987,8 @@ void main() { ); expect(relay.messages, hasLength(3)); - for (final connection in relay.messages) { - expect(connection.map((message) => message['type']), ['join', 'leave']); + for (var i = 0; i < relay.messages.length; i++) { + expect(relay.messages[i].map((message) => message['type']), [i == 0 ? 'join' : 'resume', 'leave']); } }); @@ -1089,7 +1031,7 @@ void main() { ]); }); - test('sequential guest release accepts not-in-room as idempotent success', () async { + test('a concurrent host end completes guest release without requesting membership again', () async { var leaveRequests = 0; late final _RelayServer relay; relay = await relayWith((_, socket, message) { @@ -1104,16 +1046,12 @@ void main() { }); } else if (message['type'] == 'leave') { leaveRequests++; - if (leaveRequests == 1) { - relay.send(socket, { - 'type': 'left', - 'sessionId': message['sessionId'], - 'peerId': message['peerId'], - 'protocolVersion': 2, - }); - } else { - relay.send(socket, {'type': 'error', 'code': 'not_in_room', 'message': 'Peer is not in the room'}); - } + relay.send(socket, { + 'type': 'ended', + 'sessionId': message['sessionId'], + 'peerId': message['peerId'], + 'protocolVersion': 2, + }); } }); final service = serviceFor(relay); @@ -1122,8 +1060,8 @@ void main() { await service.releaseSession(); await service.releaseSession(); - expect(leaveRequests, 2); - expect(relay.messages.single.map((message) => message['type']), ['join', 'leave', 'leave']); + expect(leaveRequests, 1); + expect(relay.messages.single.map((message) => message['type']), ['join', 'leave']); }); test('host end waits for a protocol-2 ended acknowledgement', () async { @@ -1264,10 +1202,11 @@ void main() { final service = serviceFor(relay); final pending = service.createSession(sessionId: 'cancel1'); + final cancelled = expectLater(pending, throwsStateError); await announcementSeen.future.timeout(const Duration(seconds: 1)); await service.disconnect(); - await expectLater(pending, throwsStateError); + await cancelled.timeout(const Duration(seconds: 1)); expect(service.sessionId, isNull); expect(service.connectedPeers, isEmpty); }); @@ -1285,9 +1224,10 @@ void main() { 'reconnectToken': message['reconnectToken'], 'protocolVersion': 2, }); - } else if (connection == 1 && message['type'] == 'join') { + } else if (connection == 1 && message['type'] == 'resume') { relay.send(socket, { - 'type': 'joined', + 'type': 'resumed', + 'features': [RelayProtocol.authenticatedResumeFeature], 'sessionId': message['sessionId'], 'hostPeerId': message['peerId'], 'reconnectToken': message['reconnectToken'], @@ -1460,14 +1400,17 @@ void main() { test('reconnection must reestablish enforcing-feature and roster authorization', () async { late final _RelayServer relay; relay = await relayWith((connection, socket, message) { - if (message['type'] == 'create' || message['type'] == 'join') { + if (message['type'] == 'create' || message['type'] == 'resume') { relay.send(socket, { - 'type': message['type'] == 'create' ? 'created' : 'joined', + 'type': message['type'] == 'create' ? 'created' : 'resumed', 'sessionId': message['sessionId'], 'hostPeerId': message['peerId'], 'reconnectToken': message['reconnectToken'], 'protocolVersion': 2, - if (connection != 1) 'features': [RelayProtocol.atomicHostTransferFeature], + 'features': [ + RelayProtocol.authenticatedResumeFeature, + if (connection != 1) RelayProtocol.atomicHostTransferFeature, + ], }); relay.send(socket, { 'type': 'hostTransferEligibility', @@ -1489,7 +1432,7 @@ void main() { service.onReconnected = () => reconnected.complete(); await relay.sockets.single.close(); await reconnected.future.timeout(const Duration(seconds: 5)); - expect(service.canTransferHostTo('guest-1'), isFalse, reason: 'old relay cannot inherit prior ACK'); + expect(service.canTransferHostTo('guest-1'), isFalse, reason: 'resume cannot inherit prior transfer ACK'); final restored = service.onHostTransferEligibilityChanged.firstWhere( (_) => service.canTransferHostTo('guest-1'), ); @@ -1576,9 +1519,10 @@ void main() { test('guest reconnect accepts the host identity pinned by a transfer', () async { late final _RelayServer relay; relay = await relayWith((connection, socket, message) { - if (message['type'] == 'join') { + if (message['type'] == 'join' || message['type'] == 'resume') { relay.send(socket, { - 'type': 'joined', + 'type': message['type'] == 'join' ? 'joined' : 'resumed', + 'features': [RelayProtocol.authenticatedResumeFeature], 'sessionId': message['sessionId'], 'hostPeerId': connection == 0 ? _relayHostId : 'guest-2', 'reconnectToken': message['reconnectToken'], @@ -1630,9 +1574,10 @@ void main() { 'reconnectToken': message['reconnectToken'], 'protocolVersion': 2, }); - } else if (connection >= 1 && message['type'] == 'join') { + } else if (connection >= 1 && message['type'] == 'resume') { relay.send(socket, { - 'type': 'joined', + 'type': 'resumed', + 'features': [RelayProtocol.authenticatedResumeFeature], 'sessionId': message['sessionId'], 'hostPeerId': 'new-host', 'reconnectToken': message['reconnectToken'], @@ -1672,8 +1617,10 @@ void main() { relay = await relayWith((connection, socket, message) { switch (message['type']) { case 'join': + case 'resume': relay.send(socket, { - 'type': 'joined', + 'type': message['type'] == 'join' ? 'joined' : 'resumed', + 'features': [RelayProtocol.authenticatedResumeFeature], 'sessionId': message['sessionId'], // The reconnect: the relay made this peer the host while it was // offline, so the hostChanged broadcast never reached it. @@ -1721,6 +1668,353 @@ void main() { // The role the relay declared is the one the transport acts on: a host // destroys the room instead of quietly leaving it behind. await service.releaseSession(); - expect(relay.messages[1].map((message) => message['type']), ['join', 'endSession']); + expect(relay.messages[1].map((message) => message['type']), ['resume', 'endSession']); + }); + + test('lost initial join ACK resumes a promotion before setup returns', () async { + late final _RelayServer relay; + relay = await relayWith((_, socket, message) async { + if (message['type'] == 'join') { + await socket.close(); + } else if (message['type'] == 'resume') { + relay.send(socket, { + 'type': 'resumed', + 'sessionId': message['sessionId'], + 'hostPeerId': message['peerId'], + 'reconnectToken': message['reconnectToken'], + 'protocolVersion': 2, + 'features': [RelayProtocol.authenticatedResumeFeature], + }); + } else if (message['type'] == 'endSession') { + relay.send(socket, {'type': 'ended', 'sessionId': message['sessionId'], 'protocolVersion': 2}); + } + }); + final service = serviceFor(relay); + await _withRetryBackoffShortened(() => service.joinSession('promotedSetup')); + expect(service.isHost, isTrue); + expect(service.hostPeerId, service.myPeerId); + await service.releaseSession(); + expect(relay.messages.map((messages) => messages.map((message) => message['type']).toList()), [ + ['join'], + ['resume', 'endSession'], + ]); + }); + + test('uncommitted ambiguous setup fails closed and only explicit re-entry mints membership', () async { + late final _RelayServer relay; + relay = await relayWith((connection, socket, message) async { + if (connection == 0 && message['type'] == 'create') { + await socket.close(); + } else if (message['type'] == 'resume') { + relay.send(socket, {'type': 'error', 'code': 'room_not_found', 'message': 'Room not found'}); + } else if (message['type'] == 'create') { + relay.send(socket, { + 'type': 'created', + 'sessionId': message['sessionId'], + 'hostPeerId': message['peerId'], + 'reconnectToken': message['reconnectToken'], + 'protocolVersion': 2, + }); + } + }); + final service = serviceFor(relay); + await expectLater( + _withRetryBackoffShortened(() => service.createSession(sessionId: 'ambiguous')), + throwsA(isA().having((error) => error.serverCode, 'serverCode', 'room_not_found')), + ); + expect(service.sessionId, isNull); + expect(relay.messages.map((messages) => messages.single['type']), ['create', 'resume']); + final original = relay.messages.first.single; + await service.createSession(sessionId: 'ambiguous'); + final explicitEntry = relay.messages.last.single; + expect(explicitEntry['type'], 'create'); + expect(explicitEntry['peerId'], isNot(original['peerId'])); + expect(explicitEntry['reconnectToken'], isNot(original['reconnectToken'])); + }); + + for (final invalid in ['joinedTokenEcho', 'missingFeature', 'malformedPeers', 'wrongSession']) { + test('resume rejects $invalid before publishing authority or room traffic', () async { + late final _RelayServer relay; + relay = await relayWith((_, socket, message) { + if (message['type'] == 'join') { + relay.send(socket, { + 'type': 'joined', + 'sessionId': message['sessionId'], + 'hostPeerId': _relayHostId, + 'reconnectToken': message['reconnectToken'], + 'protocolVersion': 2, + 'peers': [_relayHostId], + }); + } else if (message['type'] == 'resume') { + relay.send(socket, {'type': 'hostChanged', 'sessionId': message['sessionId'], 'hostPeerId': 'replacement'}); + relay.send(socket, { + 'type': invalid == 'joinedTokenEcho' ? 'joined' : 'resumed', + 'sessionId': invalid == 'wrongSession' ? 'OTHER_ROOM' : message['sessionId'], + 'hostPeerId': 'replacement', + 'reconnectToken': message['reconnectToken'], + 'protocolVersion': 2, + if (invalid != 'missingFeature') 'features': [RelayProtocol.authenticatedResumeFeature], + 'peers': invalid == 'malformedPeers' ? ['replacement', 7] : ['replacement'], + }); + relay.send(socket, {'type': 'peerJoined', 'peerId': 'replacement'}); + relay.send(socket, { + 'type': 'message', + 'from': 'replacement', + 'payload': SyncMessage.requestState().toJson(), + }); + } + }); + final service = serviceFor(relay); + final authorities = []; + final connected = []; + final peers = []; + final payloads = []; + var reconnectCallbacks = 0; + service.onReconnected = () => reconnectCallbacks++; + addTearDown(service.onHostChanged.listen(authorities.add).cancel); + addTearDown(service.onConnectionStateChanged.listen(connected.add).cancel); + addTearDown(service.onPeerConnected.listen(peers.add).cancel); + addTearDown(service.onMessageReceived.listen(payloads.add).cancel); + final ended = service.onSessionEnded.first; + await _withShortenedTimer( + original: const Duration(seconds: 2), + replacement: const Duration(milliseconds: 10), + body: () async { + await service.joinSession('validation'); + await relay.sockets.single.close(); + await ended.timeout(const Duration(seconds: 1)); + }, + ); + expect(authorities, isEmpty); + expect(peers, [_relayHostId]); + expect(connected.where((value) => value), [true]); + expect(payloads, isEmpty); + expect(reconnectCallbacks, 0); + expect(service.hostPeerId, isNull); + expect(service.sessionId, isNull); + expect(relay.messages.map((messages) => messages.single['type']), ['join', 'resume']); + }); + } + + for (final releaseInstead in [false, true]) { + test( + 'released relay compatibility fails closed on ${releaseInstead ? 'disconnected release' : 'resume'}', + () async { + late final _RelayServer relay; + relay = await relayWith((_, socket, message) { + if (message['type'] == 'join') { + // Released protocol-2 relays support initial admission without features. + relay.send(socket, { + 'type': 'joined', + 'sessionId': message['sessionId'], + 'hostPeerId': _relayHostId, + 'reconnectToken': message['reconnectToken'], + 'protocolVersion': 2, + 'peers': [_relayHostId], + }); + } else { + relay.send(socket, {'type': 'error', 'code': 'invalid_message', 'message': 'Unknown message type'}); + } + }); + final service = serviceFor(relay); + var reconnectCallbacks = 0; + service.onReconnected = () => reconnectCallbacks++; + await _withShortenedTimer( + original: const Duration(seconds: 2), + replacement: releaseInstead ? const Duration(seconds: 2) : const Duration(milliseconds: 10), + body: () async { + await service.joinSession('oldRelay'); + final disconnected = service.onConnectionStateChanged.firstWhere((connected) => !connected); + final ended = releaseInstead ? null : service.onSessionEnded.first; + await relay.sockets.single.close(); + await disconnected.timeout(const Duration(seconds: 1)); + if (releaseInstead) { + await expectLater( + service.releaseSession(), + throwsA(isA().having((error) => error.serverCode, 'serverCode', 'invalid_message')), + ); + } else { + await ended!.timeout(const Duration(seconds: 1)); + expect(service.sessionId, isNull); + } + }, + ); + expect(reconnectCallbacks, 0); + expect(relay.messages.map((messages) => messages.single['type']), ['join', 'resume']); + }, + ); + } + + test('lost host end ACK cannot recreate or end a replacement room during cleanup', () async { + late final _RelayServer relay; + relay = await relayWith((_, socket, message) { + if (message['type'] == 'create') { + relay.send(socket, { + 'type': 'created', + 'sessionId': message['sessionId'], + 'hostPeerId': message['peerId'], + 'reconnectToken': message['reconnectToken'], + 'protocolVersion': 2, + }); + } else if (message['type'] == 'resume') { + // End committed and this reusable code now names unrelated membership. + relay.send(socket, {'type': 'error', 'code': 'peer_id_unavailable', 'message': 'Peer ID is unavailable'}); + } + }); + final service = serviceFor(relay, debugReleaseTimeout: const Duration(milliseconds: 10)); + await service.createSession(sessionId: 'reusedCode'); + await _withRetryBackoffShortened(service.releaseSession); + await service.releaseSession(); + expect(relay.messages.map((messages) => messages.map((message) => message['type']).toList()), [ + ['create', 'endSession'], + ['resume'], + ]); + }); + + test('transport failure before sending admission may retry the initial operation', () async { + late final _RelayServer relay; + relay = await relayWith((_, socket, message) { + if (message['type'] == 'create') { + relay.send(socket, { + 'type': 'created', + 'sessionId': message['sessionId'], + 'hostPeerId': message['peerId'], + 'reconnectToken': message['reconnectToken'], + 'protocolVersion': 2, + }); + } + }); + var attempts = 0; + final service = serviceFor( + relay, + debugChannelFactory: (uri) { + if (attempts++ == 0) throw const SocketException('Connection refused before admission'); + return WebSocketChannel.connect(uri); + }, + ); + await _withRetryBackoffShortened(() => service.createSession(sessionId: 'preSend')); + expect(service.isHost, isTrue); + expect(relay.messages.single.single['type'], 'create'); + }); + + test('network failures exhaust bounded resume attempts without falling back to create', () async { + late final _RelayServer relay; + relay = await relayWith((_, socket, message) async { + if (message['type'] == 'create') { + relay.send(socket, { + 'type': 'created', + 'sessionId': message['sessionId'], + 'hostPeerId': message['peerId'], + 'reconnectToken': message['reconnectToken'], + 'protocolVersion': 2, + }); + } else if (message['type'] == 'resume') { + await socket.close(); + } + }); + final service = serviceFor(relay); + var reconnectCallbacks = 0; + service.onReconnected = () => reconnectCallbacks++; + final ended = service.onSessionEnded.first; + await _withShortenedTimer( + original: const Duration(seconds: 2), + replacement: const Duration(milliseconds: 10), + body: () => _withShortenedTimer( + original: const Duration(seconds: 4), + replacement: const Duration(milliseconds: 10), + body: () => _withShortenedTimer( + original: const Duration(seconds: 6), + replacement: const Duration(milliseconds: 10), + body: () async { + await service.createSession(sessionId: 'networkLoss'); + await relay.sockets.single.close(); + }, + ), + ), + ); + // Only reconnect backoff belongs to the shortened timer zone. + await ended.timeout(const Duration(seconds: 2)); + expect(reconnectCallbacks, 0); + expect(service.sessionId, isNull); + expect(relay.messages.map((messages) => messages.single['type']), ['create', 'resume', 'resume', 'resume']); + }); + + test('host release does not treat a guest left ACK as room destruction', () async { + late final _RelayServer relay; + relay = await relayWith((_, socket, message) { + if (message['type'] == 'create') { + relay.send(socket, { + 'type': 'created', + 'sessionId': message['sessionId'], + 'hostPeerId': message['peerId'], + 'reconnectToken': message['reconnectToken'], + 'protocolVersion': 2, + }); + } else if (message['type'] == 'endSession') { + relay.send(socket, { + 'type': 'left', + 'sessionId': message['sessionId'], + 'peerId': message['peerId'], + 'protocolVersion': 2, + }); + } + }); + final service = serviceFor(relay); + await service.createSession(sessionId: 'wrongReleaseAck'); + await expectLater( + service.releaseSession(), + throwsA(isA().having((error) => error.type, 'type', PeerErrorType.serverError)), + ); + expect(relay.messages.single.map((message) => message['type']), ['create', 'endSession']); + }); + + test('cancelling pending resume cannot publish or release a later explicit session', () async { + final resumeSeen = Completer(); + late final _RelayServer relay; + relay = await relayWith((_, socket, message) { + if (message['type'] == 'join') { + relay.send(socket, { + 'type': 'joined', + 'sessionId': message['sessionId'], + 'hostPeerId': _relayHostId, + 'reconnectToken': message['reconnectToken'], + 'protocolVersion': 2, + 'peers': [_relayHostId], + }); + } else if (message['type'] == 'resume') { + resumeSeen.complete(); + } else if (message['type'] == 'create') { + relay.send(socket, { + 'type': 'created', + 'sessionId': message['sessionId'], + 'hostPeerId': message['peerId'], + 'reconnectToken': message['reconnectToken'], + 'protocolVersion': 2, + }); + } + }); + final service = serviceFor(relay); + var reconnectCallbacks = 0; + service.onReconnected = () => reconnectCallbacks++; + await _withShortenedTimer( + original: const Duration(seconds: 2), + replacement: const Duration(milliseconds: 10), + body: () async { + await service.joinSession('cancelOld'); + await relay.sockets.single.close(); + await resumeSeen.future.timeout(const Duration(seconds: 1)); + await service.disconnect(); + await service.createSession(sessionId: 'explicitNew'); + }, + ); + expect(service.sessionId, 'EXPLICITNEW'); + expect(service.hostPeerId, service.myPeerId); + expect(service.isHost, isTrue); + expect(reconnectCallbacks, 0); + expect(relay.messages.map((messages) => messages.map((message) => message['type']).toList()), [ + ['join'], + ['resume'], + ['create'], + ]); }); }