oficina / meet Public

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
Paul Campbell pushed by paul@keymail.dev a419e4b111432bb071568ebfa302942bc167990b parent d3d7e84
2 files changed, +36 −2
  • internal/meet/hub/admission.go +7 −2
  • internal/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)