fix(watch-together): re-host an abandoned room code instead of joining it

A room whose peers have all left is a code nobody is using, but the relay
kept it bound to the creator's reconnect capability and rejected every
other create with room_exists. The app compounded it: enterRoom only
promoted to host on room_not_found, so tapping a recent code landed the
user in the retained room as a guest of a host that was never coming
back, until the cleanup sweep finally dropped the room.

Create now replaces a room with no connected peers, and enterRoom hosts
the code when its probe join finds an empty room. An occupied room still
rejects create, including from its previous owner, and a host that is
merely disconnected still reclaims its peer ID through join with the
matching token.
This commit is contained in:
edde746
2026-07-28 03:30:32 +02:00
parent 314fec5383
commit 1be982f43d
4 changed files with 144 additions and 32 deletions
@@ -698,7 +698,7 @@ void main() {
});
group('WatchTogetherProvider — relay authority', () {
test('an empty successful probe joins the reserved room without creating', () async {
test('an occupied room is joined as a guest without creating', () async {
late final _ProviderRelay relay;
relay = await _ProviderRelay.start((socket, message) {
if (message['type'] == 'join') {
@@ -708,6 +708,7 @@ void main() {
'hostPeerId': _providerHostId,
'reconnectToken': message['reconnectToken'],
'protocolVersion': 2,
'peers': [_providerHostId],
});
} else if (message['type'] == 'leave') {
relay.send(socket, {
@@ -726,7 +727,7 @@ void main() {
provider.dispose();
});
final becameHost = await provider.enterRoom('empty1', relayEndpoint: endpoint, displayName: 'Guest');
final becameHost = await provider.enterRoom('busy01', relayEndpoint: endpoint, displayName: 'Guest');
expect(becameHost, isFalse);
expect(provider.isHost, isFalse);
@@ -746,6 +747,60 @@ void main() {
expect(relay.messages.where((message) => message['type'] == 'create'), isEmpty);
});
test('an abandoned room with no peers is hosted instead of joined', () async {
late final _ProviderRelay relay;
relay = await _ProviderRelay.start((socket, message) {
switch (message['type']) {
case 'join':
relay.send(socket, {
'type': 'joined',
'sessionId': message['sessionId'],
'hostPeerId': _providerHostId,
'reconnectToken': message['reconnectToken'],
'protocolVersion': 2,
});
case 'leave':
relay.send(socket, {
'type': 'left',
'sessionId': message['sessionId'],
'peerId': message['peerId'],
'protocolVersion': 2,
});
case 'create':
relay.send(socket, {
'type': 'created',
'sessionId': message['sessionId'],
'hostPeerId': message['peerId'],
'reconnectToken': message['reconnectToken'],
'protocolVersion': 2,
});
case 'endSession':
relay.send(socket, {'type': 'ended', 'sessionId': message['sessionId'], 'protocolVersion': 2});
}
});
addTearDown(relay.close);
final endpoint = WatchTogetherRelayEndpoint.resolve(relay.baseUrl);
final provider = WatchTogetherProvider();
addTearDown(() async {
await provider.leaveSession();
provider.dispose();
});
final becameHost = await provider.enterRoom('empty1', relayEndpoint: endpoint, displayName: 'Host');
expect(becameHost, isTrue);
expect(provider.isHost, isTrue);
// The probe identity is released before the code is taken over, so the
// relay sees an empty room when the create lands.
expect(relay.messages.map((message) => message['type']).take(3), ['join', 'leave', 'create']);
final create = relay.messages.singleWhere((message) => message['type'] == 'create');
expect(create['sessionId'], 'EMPTY1');
expect(provider.session?.hostPeerId, create['peerId']);
final probeJoin = relay.messages.firstWhere((message) => message['type'] == 'join');
expect(create['peerId'], isNot(probeJoin['peerId']));
expect(provider.session?.hostPeerId, isNot(_providerHostId));
});
test('a room-not-found probe creates with relay-declared host authority', () async {
late final _ProviderRelay relay;
relay = await _ProviderRelay.start((socket, message) {