fix(ui): leave room sticks across reloads via removed_rooms tombstone#248
Conversation
Fixes #247 (Ivvor's report 2026-05-14 17:03 — "every time i leave the private room on the second node it's coming back after a refresh a little while later"). ## Root cause `Confirm Leave` in `ui/src/components/room_list/edit_room_modal.rs:283` did: ROOMS.write().map.remove(&room_vk); // … save_rooms_to_delegate() On the next page load the UI re-fetches `rooms_data` from the current delegate (correctly without the room) AND `fire_legacy_migration_request()` probes all 17 entries in `legacy_delegates.toml`. Some legacy delegate's on-disk `rooms_data` still contained the room — that data was never purged when the delegate was superseded. `Rooms::merge` is purely additive (`ui/src/room_data.rs:881`), so the room came back. ## Fix Add a `removed_rooms: HashSet<VerifyingKey>` tombstone set to the serialised `Rooms` struct (with `#[serde(default)]` for backward compatibility). `Rooms::merge` now: 1. Takes the union of `removed_rooms` from both sides first. 2. Drops any entry from `self.map` whose key is in the union (defensive — should not happen if leave is the only mutation path, but enforced so the invariant holds). 3. Filters incoming entries from `other.map` against the union. New `Rooms::leave_room(vk)` helper does both `map.remove(vk)` AND `removed_rooms.insert(vk)` atomically. The Confirm-Leave button calls this. Invitation acceptance (`get_response.rs` after PUT) clears the tombstone with `rooms.removed_rooms.remove(&owner_vk)` so explicit rejoins work. ## Tests `leave_room_tombstone_prevents_merge_re_adding` — full scenario from the report: room in map → `leave_room` → legacy merge with same room present → must not re-add. Asserts both `map` and `removed_rooms` invariants. `merge_unions_removed_rooms_tombstones` — if either side has the tombstone, it's honoured; map entries on the receiver are dropped. 14/14 UI `room_data::tests` pass (was 12). ## Deploy footprint UI-only change. `chat_delegate.wasm` and `room_contract.wasm` are BYTE-IDENTICAL to #245. No migration entry needed. No riverctl republish. Deploy is `cargo make publish-river`. ## Recovery for currently-stuck users After this lands and users reload, the next time they leave a room the tombstone is recorded. Their previous leave attempts (without tombstone) won't survive — they'll need to leave once more after the upgrade. The "rejoining via invitation clears the tombstone" path means this isn't a one-way door — explicit rejoin still works. [AI-assisted - Claude] Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review findings on PR #248: * **Skeptical H1**: response_handler.rs collected room keys and owned- rooms-for-subscription from `loaded_rooms.map` BEFORE the merge filter ran, then subscribed/synced every key. That re-introduced tombstoned rooms via the subscribe + SYNC_INFO path. Now filtered against a union of incoming and current `removed_rooms`. * **Skeptical H2**: `current_room_key` restoration didn't check the tombstone. If the user left the room that was their current room, reload restored a CURRENT_ROOM pointing at a nonexistent map entry. Now skipped when tombstoned. * **Code-first**: members.rs:580 (room import via export blob) inserted into ROOMS without clearing the tombstone. If a user imported an export of a room they left, the room came back briefly but the next merge dropped it again. Now clears the tombstone on import, matching get_response.rs's invitation-accept path. * **Code-first / serde round-trip**: added two tests — `rooms_deserialises_pre_247_blob_with_default_removed_rooms` pins the `#[serde(default)]` backward-compat behaviour for blobs serialised before this PR; `rooms_round_trip_preserves_removed_rooms_tombstone` pins the write-side wire format. Replaces the original ad-hoc test that depended on serde_json (which isn't a dep here). * **Code-first / explicit rejoin test**: added `cleared_tombstone_allows_merge_to_re_add` to lock in the invitation-accept / import-identity rejoin semantics. * **Doc comment**: corrected the rustdoc on `removed_rooms` — it was documented as "monotonically grows" but the code clears entries on explicit rejoin. Now accurate, with notes on the multi-device divergence behaviour. 17/17 UI `room_data::tests` pass (was 14). Deferred follow-ups (not in this PR, captured in the issue tracker): * Codex P2: stale tombstone re-applied after rejoin in a divergent multi-device scenario. Requires timestamped tombstones or a rejoin-counter; out of scope for tonight's fix. * Skeptical M1: removed_rooms GC. Set is small in practice (one entry per leave); a future TTL/eviction pass can land separately. * Skeptical M2: cross-version device divergence (old build drops removed_rooms on save). Mitigated by `#[serde(default)]` and a "leave once more after the upgrade" recovery note in the PR body. * Skeptical M4: migrated_rooms not filtered. Minor. * Skeptical L1: PartialEq ignores removed_rooms. Latent footgun. * Skeptical L3: stale `.syncthing.*.tmp` files in the worktree. [AI-assisted - Claude] Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review findings addressed (force-push `b65cd0ab`)
Deferred to follow-ups (captured in commit message)
[AI-assisted - Claude] |
…ignore) Per the "don't accumulate follow-ups while fixing bugs" principle — folding the small mechanical items from #249 into this PR rather than deferring: * **#2 / Skeptical M4**: `Rooms::leave_room` now also retains `migrated_rooms` to drop any pending upgrade-pointer entry for the left room. Previously, if a room had been recorded for upgrade and the user then left, the upgrade pointer would still be sent. * **#3 / Skeptical L1**: `PartialEq for Rooms` now compares `removed_rooms` too. Two `Rooms` differing only in tombstones used to compare equal — a latent footgun if any code uses equality to short-circuit a save. * **#6 / Skeptical L3**: `.gitignore` excludes `**/.syncthing.*.tmp` so syncthing's in-flight transfer markers don't show up as untracked files. Three items remain in #249, all genuinely deferred: * **#1 Timestamped tombstones (Codex P2)**: real but requires custom serde for backward compat with the HashSet we're shipping. Scenario is rare (River doesn't have automatic multi-device sync; single delegate writer) and user-recoverable (leave again). Not paid for. * **#4 `removed_rooms` GC/TTL**: hypothetical for heavy users; defer until there's evidence. * **#5 Cross-version divergence**: mostly moots itself as users upgrade. Schema-versioning is an architectural change for later. 17/17 `room_data::tests` still pass. [AI-assisted - Claude] Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes #247.
Problem
Ivvor's report (2026-05-14 17:03):
`Confirm Leave` in `ui/src/components/room_list/edit_room_modal.rs:283` only did `ROOMS.write().map.remove(&room_vk)` and saved rooms_data to the current delegate. On reload, `fire_legacy_migration_request()` probed all 17 entries in `legacy_delegates.toml`. Some legacy delegate's on-disk `rooms_data` still contained the room — that data was never purged when the delegate was superseded. `Rooms::merge` was purely additive, so the room came back.
Fix
Add a `removed_rooms: HashSet<VerifyingKey>` tombstone set to the serialised `Rooms` struct (with `#[serde(default)]` for backward compatibility). `Rooms::merge` now unions tombstones from both sides and filters all entries against the union; `map` entries on the receiver matching a tombstone are also dropped (defensive). New `Rooms::leave_room(vk)` helper does both mutations atomically. Invitation acceptance clears the tombstone for explicit rejoin.
Tests
Deploy footprint
UI-only. `chat_delegate.wasm` and `room_contract.wasm` are byte-identical to #245. No migration entry. No riverctl republish. Just `cargo make publish-river`.
Recovery for currently-stuck users
After this lands and users reload, the next time they leave a room the tombstone is recorded. Their previous leave attempts (without tombstone) won't survive — they'll need to leave once more after the upgrade.
Test plan
[AI-assisted - Claude]