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
+6 -9
View File
@@ -1883,16 +1883,13 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
}
continue
}
authorizedLegacyReplacement :=
len(existing.Peers) == 0 &&
!existing.closing &&
msg.ProtocolVersion == legacyRelayProtocolVersion &&
existing.ProtocolVersion == legacyRelayProtocolVersion &&
msg.PeerID == existing.HostPeerID &&
msg.ReconnectToken != "" &&
reconnectVerifierMatches(existing.hostVerifier, hostVerifier)
// A room nobody is connected to is an abandoned code, not property.
// Whoever asks for it next takes it, so a host that restarted with a
// fresh reconnect token can reuse its own code instead of waiting out
// the cleanup sweep. An occupied room still belongs to its peers.
reclaimable := len(existing.Peers) == 0 && !existing.closing
existing.mu.Unlock()
if !authorizedLegacyReplacement {
if !reclaimable {
rejection = &serverMsg{Type: relayTypeError, Code: relayErrorRoomExists, Message: "Room already exists"}
}
} else if len(s.rooms) >= maxRetainedRooms {
+71 -17
View File
@@ -2273,12 +2273,13 @@ func TestIdempotentCreateReannouncesPreviouslyAbsentHost(t *testing.T) {
}
}
func TestCreateCannotReclaimReservedEmptyRoom(t *testing.T) {
func TestCreateReclaimsAbandonedEmptyRoom(t *testing.T) {
h := newRelayHarness(t)
hostToken, hostVerifier := mustReconnectToken(t)
original := &Room{
SessionID: "STALE",
HostPeerID: "old-host",
ProtocolVersion: relayProtocolVersion,
hostVerifier: hostVerifier,
peerReservations: make(map[string]peerReservation),
Peers: map[string]*Client{},
@@ -2289,29 +2290,82 @@ func TestCreateCannotReclaimReservedEmptyRoom(t *testing.T) {
h.srv.rooms["STALE"] = original
h.srv.mu.Unlock()
creatorToken, _ := mustReconnectToken(t)
creator := h.dial(t, "1.1.1.6")
creator.send(clientMsg{Type: relayTypeCreate, SessionID: "STALE", PeerID: "new-host"})
creator.expectError(relayErrorRoomExists)
unproved := h.dial(t, "1.1.1.60")
unproved.send(clientMsg{Type: relayTypeJoin, SessionID: "STALE", PeerID: "old-host"})
unproved.expectError(relayErrorPeerIdUnavailable)
reconnected := h.dial(t, "1.1.1.61")
reconnected.send(clientMsg{
Type: relayTypeJoin,
SessionID: "STALE",
PeerID: "old-host",
ReconnectToken: hostToken,
creator.send(clientMsg{
Type: relayTypeCreate,
SessionID: "STALE",
PeerID: "new-host",
ReconnectToken: creatorToken,
ProtocolVersion: relayProtocolVersion,
})
reconnected.expectAuthority(relayTypeJoined, "old-host")
creator.expectAuthority(relayTypeCreated, "new-host")
h.srv.mu.RLock()
current := h.srv.rooms["STALE"]
h.srv.mu.RUnlock()
if current != original {
t.Fatal("reserved room identity was replaced")
if current == original {
t.Fatal("abandoned room identity survived the reclaim")
}
// The previous owner's capability died with the room it belonged to, and
// the live replacement is not reclaimable by anyone, owner included.
former := h.dial(t, "1.1.1.60")
former.send(clientMsg{
Type: relayTypeCreate,
SessionID: "STALE",
PeerID: "old-host",
ReconnectToken: hostToken,
ProtocolVersion: relayProtocolVersion,
})
former.expectError(relayErrorRoomExists)
}
// The recent-rooms flow: a host restarts its app, so it presents a fresh
// reconnect capability for a code the relay still holds. The abandoned code
// must come back as a hosted room instead of a ghost room with no host.
func TestAbandonedCodeIsRecreatableByARestartedHost(t *testing.T) {
h := newRelayHarness(t)
firstToken, _ := mustReconnectToken(t)
host := h.dial(t, "6.4.0.1")
host.send(clientMsg{
Type: relayTypeCreate,
SessionID: "REUSE",
PeerID: "H",
ReconnectToken: firstToken,
ProtocolVersion: relayProtocolVersion,
})
host.expectAuthority(relayTypeCreated, "H")
host.conn.Close()
h.waitRoomPeers(t, "REUSE", 0)
// A restarted app mints a new capability, so it cannot prove the previous
// ownership even when it reuses its own peer ID.
restartToken, _ := mustReconnectToken(t)
restarted := h.dial(t, "6.4.0.2")
restarted.send(clientMsg{
Type: relayTypeCreate,
SessionID: "REUSE",
PeerID: "H",
ReconnectToken: restartToken,
ProtocolVersion: relayProtocolVersion,
})
recreated := restarted.expectAuthority(relayTypeCreated, "H")
if recreated.ReconnectToken != restartToken {
t.Fatalf("recreated room token=%q, want the presented capability", recreated.ReconnectToken)
}
guestToken, _ := mustReconnectToken(t)
guest := h.dial(t, "6.4.0.3")
guest.send(clientMsg{
Type: relayTypeJoin,
SessionID: "REUSE",
PeerID: "G",
ReconnectToken: guestToken,
ProtocolVersion: relayProtocolVersion,
})
guest.expectAuthority(relayTypeJoined, "H")
restarted.expect(relayTypePeerJoined)
}
func TestCreateReclaimsOwnedEmptyRoomWithoutDoubleCharging(t *testing.T) {