Reaped phantom m.call.member never releases the Discord puppet #54

Closed
opened 2026-07-05 01:43:24 +00:00 by robocub · 1 comment
Collaborator

Symptom

A Matrix user closes their client without leaving the call (unclean disconnect). The #25 reaper correctly empties their phantom m.call.member after the 45s grace — but their Discord puppet stays in the voice channel indefinitely (until a bridge restart, or until the user rejoins and leaves cleanly).

Live occurrence (staging, 2026-07-05, Purple Voice):

01:30:43 INFO nvb_matrix_rtc::matrix: reaped phantom m.call.member (no live LiveKit participant)
         sender=@dark:nether.im state_key=_@dark:nether.im_ihjNlBVX90_m.call
   … no "membership gone — starting leave grace timer" ever follows …
01:35:59 INFO nvb_discord::puppet: puppet joined voice and assigned idx=0 bridge=Purple Voice
         display_name="Dark Collective"        ← 5 min later, still assigned; voice-recovery
                                                  even re-seats it as the capture anchor

Root cause

The reaper's retraction is a state event sent by the bridge bot on the phantom's state_key (send_state_event_for_key, permitted because the modern _@user_DEVICE key is not owner-restricted). When that event echoes back through sync, the membership handler keys the participant on orig.sender — which is now the bot, not the phantom's owner:

  • crates/matrix-rtc/src/matrix.rs sync handler: let user_id = orig.sender.clone();
  • apply_member_event then returns at the bot echo-exclusion (if &user_id == bot_user_id { return; }) before touching the participant set.

So the (@user, state_key) entry is never removed, the 12s LEAVE_GRACE release path never starts, and router.release() is never called. Room state and Discord-side presence silently diverge.

Note the ParticipantSet doc comment already states the intent: state_key is "the unique handle … This lets us remove exactly the right entry when an Empty event arrives for a state_key, without needing to parse the state_key format" — the code just never uses that for foreign-sender retractions.

Fix sketch

In apply_member_event:

  • For a retraction (is_active == false), resolve the affected user by looking up the matching state_key in the participant set and using that entry's owner instead of the sender; active events keep sender keying.
  • Run the bot/ghost echo-exclusions against the resolved user. All existing flows are unchanged (bot's own retract_stale_self_membership and ghost teardowns have no set entry → resolve to sender → still excluded; a normal user leave has owner == sender). Bonus: any third-party retraction (e.g. a moderator emptying someone's membership) now also releases the puppet.

Workaround

Restart the bridge, or have the affected user rejoin the call and leave cleanly (their own Empty event releases the puppet normally).

Related: #25 (reaper), constraint #5 echo-exclusion (which is exactly what eats the retraction event).

## Symptom A Matrix user closes their client without leaving the call (unclean disconnect). The #25 reaper correctly empties their phantom `m.call.member` after the 45s grace — but **their Discord puppet stays in the voice channel indefinitely** (until a bridge restart, or until the user rejoins and leaves cleanly). Live occurrence (staging, 2026-07-05, Purple Voice): ``` 01:30:43 INFO nvb_matrix_rtc::matrix: reaped phantom m.call.member (no live LiveKit participant) sender=@dark:nether.im state_key=_@dark:nether.im_ihjNlBVX90_m.call … no "membership gone — starting leave grace timer" ever follows … 01:35:59 INFO nvb_discord::puppet: puppet joined voice and assigned idx=0 bridge=Purple Voice display_name="Dark Collective" ← 5 min later, still assigned; voice-recovery even re-seats it as the capture anchor ``` ## Root cause The reaper's retraction is a state event **sent by the bridge bot** on the phantom's state_key (`send_state_event_for_key`, permitted because the modern `_@user_DEVICE` key is not owner-restricted). When that event echoes back through sync, the membership handler keys the participant on **`orig.sender`** — which is now the *bot*, not the phantom's owner: - `crates/matrix-rtc/src/matrix.rs` sync handler: `let user_id = orig.sender.clone();` - `apply_member_event` then returns at the bot echo-exclusion (`if &user_id == bot_user_id { return; }`) before touching the participant set. So the `(@user, state_key)` entry is never removed, the 12s `LEAVE_GRACE` release path never starts, and `router.release()` is never called. Room state and Discord-side presence silently diverge. Note the `ParticipantSet` doc comment already states the intent: state_key is "the unique handle … This lets us remove exactly the right entry when an Empty event arrives for a state_key, without needing to parse the state_key format" — the code just never uses that for foreign-sender retractions. ## Fix sketch In `apply_member_event`: - For a **retraction** (`is_active == false`), resolve the affected user by looking up the matching `state_key` in the participant set and using **that entry's owner** instead of the sender; active events keep sender keying. - Run the bot/ghost echo-exclusions against the *resolved* user. All existing flows are unchanged (bot's own `retract_stale_self_membership` and ghost teardowns have no set entry → resolve to sender → still excluded; a normal user leave has owner == sender). Bonus: any third-party retraction (e.g. a moderator emptying someone's membership) now also releases the puppet. ## Workaround Restart the bridge, or have the affected user rejoin the call and leave cleanly (their own Empty event releases the puppet normally). Related: #25 (reaper), constraint #5 echo-exclusion (which is exactly what eats the retraction event).
robocub referenced this issue from a commit 2026-07-05 02:14:18 +00:00
Author
Collaborator

Fixed in 515db4b (released in v0.3.0-alpha.6): retractions are now attributed to the membership's owner via a state_key lookup in the participant set (resolve_affected_user), so the reaper's bot-sent retraction flows through the normal 12s leave grace and releases the puppet.

Live-verified 2026-07-05 02:10 UTC on staging: force-killed Element mid-call →

  • 02:08:57 LiveKit participant disconnected
  • 02:10:00 reaped phantom m.call.member (45s reaper grace)
  • 02:10:00 membership gone — starting leave grace timer ← the fix (never fired before)
  • 02:10:12 leave grace elapsed — releasing puppet + puppet left the Discord channel

~75s from kill to release. Unit tests cover owner resolution (foreign retraction → owner, active event → sender, unknown state_key → sender fallback).

Fixed in `515db4b` (released in v0.3.0-alpha.6): retractions are now attributed to the membership's owner via a state_key lookup in the participant set (`resolve_affected_user`), so the reaper's bot-sent retraction flows through the normal 12s leave grace and releases the puppet. **Live-verified 2026-07-05 02:10 UTC on staging:** force-killed Element mid-call → - 02:08:57 LiveKit participant disconnected - 02:10:00 `reaped phantom m.call.member` (45s reaper grace) - 02:10:00 `membership gone — starting leave grace timer` ← the fix (never fired before) - 02:10:12 `leave grace elapsed — releasing puppet` + puppet left the Discord channel ~75s from kill to release. Unit tests cover owner resolution (foreign retraction → owner, active event → sender, unknown state_key → sender fallback).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
dark/nether-voicebridge#54
No description provided.