hub: announce the departure when join or admit drops a peer for standing
A member who lost standing is dropped by the next thing they do (spec 2.5), but the room was not being told: the participation ended with no gone to admitted peers, so every other browser kept a stale roster entry that no later tick would clear. Both paths now announce the departure in the shape the peer's state earns, as leave, remove, takeover and the tick already do.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
2 files changed,
+36
−2
internal/meet/hub/admission.go+7 −2internal/meet/hub/admission_test.go+29 −0
diff --git a/internal/meet/hub/admission.go b/internal/meet/hub/admission.go| index 7f2ed0e..644db6b 100644 |
| --- a/internal/meet/hub/admission.go |
| +++ b/internal/meet/hub/admission.go |
| @@ -110,7 +110,11 @@ func (h *Hub) Join(ctx context.Context, sqid, session string, peer PeerID) error |
| } |
| r := h.rooms[sqid] |
| if !ok { |
| - h.endLocked(r, p, ReasonStanding) |
| + // A member who lost standing is dropped by the next thing they |
| + // do, not the next tick (spec §2.5), and the room is told in the |
| + // shape their state earns. |
| + wasAdmitted, wasWaiting := h.endLocked(r, p, ReasonStanding) |
| + h.announceDepartureLocked(r, p, wasAdmitted, wasWaiting) |
| return ErrNotYours |
| } |
| p.Kind = kind |
| @@ -188,7 +192,8 @@ func (h *Hub) Admit(ctx context.Context, sqid, session string, actor, target Pee |
| } |
| r := h.rooms[sqid] |
| if !actorOK { |
| - h.endLocked(r, a, ReasonStanding) |
| + wasAdmitted, wasWaiting := h.endLocked(r, a, ReasonStanding) |
| + h.announceDepartureLocked(r, a, wasAdmitted, wasWaiting) |
| return ErrNotYours |
| } |
| a.Kind = actorKind |
diff --git a/internal/meet/hub/admission_test.go b/internal/meet/hub/admission_test.go| index 4b7a6e8..f52ff75 100644 |
| --- a/internal/meet/hub/admission_test.go |
| +++ b/internal/meet/hub/admission_test.go |
| @@ -229,6 +229,35 @@ func TestTickDropsAMemberWhoLostStandingAndPromotesAWaitingGuest(t *testing.T) { |
| } |
| } |
| +func TestJoinDropsAndAnnouncesAMemberWhoLostStanding(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + standing := map[int64]bool{1: true, 2: true} |
| + h := New(Config{Now: func() time.Time { return now }, StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) { |
| + return KindMember, standing[id], nil |
| + }}) |
| + ctx := context.Background() |
| + a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| + h.Join(ctx, "r1", "sa", a.Peer) |
| + b, _ := h.Open("r1", "sb", 2, "Bob", KindMember) |
| + h.Join(ctx, "r1", "sb", b.Peer) |
| + drain(a.Sub) |
| + drain(b.Sub) |
| + standing[2] = false // removed from the allowlist between joins |
| + if err := h.Join(ctx, "r1", "sb", b.Peer); err != ErrNotYours { |
| + t.Fatalf("re-join without standing: %v", err) |
| + } |
| + if _, found := h.Find("r1", "sb", b.Peer); found { |
| + t.Fatal("the removed member is still in the room") |
| + } |
| + bev := drain(b.Sub) |
| + if len(bev) == 0 || bev[len(bev)-1].Type != TypeLost || bev[len(bev)-1].Reason != ReasonStanding { |
| + t.Fatalf("removed member got %v, want lost/standing last", types(bev)) |
| + } |
| + if ev := types(drain(a.Sub)); len(ev) != 1 || ev[0] != TypeGone { |
| + t.Fatalf("the peer left behind got %v, want gone", ev) |
| + } |
| +} |
| + |
| func TestTakeoverDepartureDependsOnState(t *testing.T) { |
| now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| h := testHub(&now) |