Skip to content

fix(ui): leave room sticks across reloads via removed_rooms tombstone#248

Merged
sanity merged 3 commits into
mainfrom
worktree-fix-247-leave-room-sticks
May 15, 2026
Merged

fix(ui): leave room sticks across reloads via removed_rooms tombstone#248
sanity merged 3 commits into
mainfrom
worktree-fix-247-leave-room-sticks

Conversation

@sanity
Copy link
Copy Markdown
Contributor

@sanity sanity commented May 14, 2026

Closes #247.

Problem

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

`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

  • `leave_room_tombstone_prevents_merge_re_adding` — full scenario from the report.
  • `merge_unions_removed_rooms_tombstones` — if either side has the tombstone, it's honoured.
  • 14/14 `room_data::tests` pass (was 12).

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

  • CI green
  • Multi-perspective reviews
  • Merge + `cargo make publish-river`

[AI-assisted - Claude]

sanity and others added 2 commits May 14, 2026 18:32
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>
@sanity
Copy link
Copy Markdown
Contributor Author

sanity commented May 14, 2026

Review findings addressed (force-push `b65cd0ab`)

  • Skeptical H1 (subscribe loop bypassed tombstone): `response_handler.rs` now builds `tombstoned` from union of incoming `loaded_rooms.removed_rooms` AND current in-memory `ROOMS.removed_rooms`, then filters `room_keys`, `signing_keys`, and `owned_rooms_for_subscription` against it before any subscribe/sync action.

  • Skeptical H2 (CURRENT_ROOM restored to tombstoned): skipped now when the saved key is tombstoned.

  • Code-first members.rs: import-identity flow at `members.rs:580` now clears the tombstone before inserting, matching the `get_response.rs` invitation-accept pattern.

  • Tests (3 new): `rooms_deserialises_pre_247_blob_with_default_removed_rooms` (backward-compat via ad-hoc `LegacyRooms` shape — `serde_json` isn't in deps), `rooms_round_trip_preserves_removed_rooms_tombstone` (write-side wire format), `cleared_tombstone_allows_merge_to_re_add` (rejoin path). 17/17 `room_data::tests` pass (was 14).

  • Doc comment on `removed_rooms` corrected — no longer claims "monotonically grows", documents the rejoin-clear and multi-device convergence semantics.

Deferred to follow-ups (captured in commit message)

  • Codex P2: timestamped tombstones / rejoin counter to defend against stale-snapshot divergence. Requires CRDT redesign — out of scope for tonight.
  • Skeptical M1: `removed_rooms` GC.
  • Skeptical M2: cross-version divergence (old build drops the field on save). Mitigated by `#[serde(default)]` + the "leave once more after upgrade" note.
  • Skeptical M4: `migrated_rooms` not filtered.
  • Skeptical L1: `PartialEq` ignores `removed_rooms`.
  • Skeptical L3: stale `.syncthing.*.tmp` files (housekeeping).

[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>
@sanity sanity merged commit 7a636e9 into main May 15, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Leave room doesn't stick: legacy-delegate migration re-adds the room on refresh

1 participant