hub: join, admit, leave, remove, takeover, end, the cap and the tick
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
3 files changed,
+672
−3
internal/meet/hub/admission.go+304 −0internal/meet/hub/admission_test.go+294 −0internal/meet/hub/hub.go+74 −3
diff --git a/internal/meet/hub/admission.go b/internal/meet/hub/admission.go| new file mode 100644 |
| index 0000000..7f2ed0e |
| --- /dev/null |
| +++ b/internal/meet/hub/admission.go |
| @@ -0,0 +1,304 @@ |
| +package hub |
| + |
| +import "context" |
| + |
| +func admittedCountLocked(r *room) int { |
| + n := 0 |
| + for _, p := range r.peers { |
| + if p.Admitted { |
| + n++ |
| + } |
| + } |
| + return n |
| +} |
| + |
| +func waitingCountLocked(r *room) int { |
| + n := 0 |
| + for _, p := range r.peers { |
| + if p.Joined && !p.Admitted { |
| + n++ |
| + } |
| + } |
| + return n |
| +} |
| + |
| +// rosterFor is the authoritative snapshot for one viewer: admitted peers |
| +// with the viewer's pair generation, and the waiting queue only when the |
| +// viewer is a member (spec §4.3). |
| +func (h *Hub) rosterFor(r *room, viewer *Participation) *Roster { |
| + ro := &Roster{} |
| + for _, p := range r.peers { |
| + if !p.Admitted { |
| + continue |
| + } |
| + rp := RosterPeer{Peer: p.Peer, Name: p.Name, Kind: p.Kind.String()} |
| + if p.Peer != viewer.Peer { |
| + rp.Gen = h.genLocked(r, viewer.Peer, p.Peer) |
| + } |
| + ro.Peers = append(ro.Peers, rp) |
| + } |
| + if viewer.Kind == KindMember { |
| + for _, p := range r.peers { |
| + if p.Joined && !p.Admitted { |
| + ro.Waiting = append(ro.Waiting, RosterPeer{Peer: p.Peer, Name: p.Name, Kind: "guest"}) |
| + } |
| + } |
| + } |
| + return ro |
| +} |
| + |
| +// genLocked returns the pair's generation, starting it at one on first |
| +// sight (spec §4.6). |
| +func (h *Hub) genLocked(r *room, a, b PeerID) uint64 { |
| + k := keyFor(a, b) |
| + if r.gens[k] == 0 { |
| + r.gens[k] = 1 |
| + } |
| + return r.gens[k] |
| +} |
| + |
| +// admitLocked is the one transition into the admitted state: the cap |
| +// under the lock, roster to the newcomer, peer to the others, knock-gone |
| +// to members if they were waiting. Returns false when the room is full. |
| +func (h *Hub) admitLocked(r *room, p *Participation) bool { |
| + if admittedCountLocked(r) >= MaxAdmitted { |
| + return false |
| + } |
| + wasWaiting := p.Joined && !p.Admitted |
| + p.Admitted = true |
| + p.Joined = true |
| + p.JoinedAt = h.cfg.Now() |
| + if p.sub != nil { |
| + p.sub.send(Message{Type: TypeRoster, Roster: h.rosterFor(r, p)}) |
| + } |
| + for _, q := range r.peers { |
| + if q.Admitted && q.Peer != p.Peer && q.sub != nil { |
| + // The one-entry roster carries this recipient's own pair |
| + // generation with the newcomer; browsers read it from |
| + // roster.peers[0]. |
| + q.sub.send(Message{Type: TypePeer, Peer: p.Peer, Name: p.Name, |
| + Roster: &Roster{Peers: []RosterPeer{{Peer: p.Peer, Name: p.Name, Kind: p.Kind.String(), Gen: h.genLocked(r, q.Peer, p.Peer)}}}}) |
| + } |
| + } |
| + if wasWaiting { |
| + h.broadcastLocked(r, Message{Type: TypeKnockGone, From: p.Peer}, admittedMembers) |
| + } |
| + return true |
| +} |
| + |
| +// refreshRostersLocked sends the authoritative snapshot to every |
| +// admitted peer, which is what a kind change owes them (spec §2.4). |
| +func (h *Hub) refreshRostersLocked(r *room) { |
| + for _, q := range r.peers { |
| + if q.Admitted && q.sub != nil { |
| + q.sub.send(Message{Type: TypeRoster, Roster: h.rosterFor(r, q)}) |
| + } |
| + } |
| +} |
| + |
| +// Join is the entry, so standing is re-derived now (spec §2.5). |
| +func (h *Hub) Join(ctx context.Context, sqid, session string, peer PeerID) error { |
| + kind, ok, err := h.standing(ctx, sqid, session, peer) |
| + if err != nil { |
| + return err |
| + } |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + p, found := h.findLocked(sqid, session, peer) |
| + if !found { |
| + return ErrUnknownPeer // it ended while standing was being read |
| + } |
| + r := h.rooms[sqid] |
| + if !ok { |
| + h.endLocked(r, p, ReasonStanding) |
| + return ErrNotYours |
| + } |
| + p.Kind = kind |
| + if p.Joined { |
| + return nil // idempotent |
| + } |
| + if kind == KindMember { |
| + if !h.admitLocked(r, p) { |
| + if p.sub != nil { |
| + p.sub.send(Message{Type: TypeFull}) |
| + } |
| + return ErrRoomFull |
| + } |
| + return nil |
| + } |
| + if waitingCountLocked(r) >= MaxWaiting { |
| + if p.sub != nil { |
| + p.sub.send(Message{Type: TypeFull}) |
| + } |
| + return ErrTooManyWaiting |
| + } |
| + p.Joined = true |
| + p.JoinedAt = h.cfg.Now() |
| + h.broadcastLocked(r, Message{Type: TypeKnock, From: p.Peer, Name: p.Name}, admittedMembers) |
| + return nil |
| +} |
| + |
| +// standing looks the peer up to find its person, then asks the app |
| +// without holding the lock, because it is a database read. |
| +func (h *Hub) standing(ctx context.Context, sqid, session string, peer PeerID) (Kind, bool, error) { |
| + h.mu.Lock() |
| + p, found := h.findLocked(sqid, session, peer) |
| + var personID int64 |
| + if found { |
| + personID = p.PersonID |
| + } |
| + h.mu.Unlock() |
| + if !found { |
| + return 0, false, ErrUnknownPeer |
| + } |
| + return h.cfg.StandingFor(ctx, sqid, personID) |
| +} |
| + |
| +// Admit: the actor must be admitted with current member kind; the target |
| +// waiting with entry authority now; the cap under the lock (spec §4.3). |
| +func (h *Hub) Admit(ctx context.Context, sqid, session string, actor, target PeerID) error { |
| + actorKind, actorOK, err := h.standing(ctx, sqid, session, actor) |
| + if err != nil { |
| + return err |
| + } |
| + h.mu.Lock() |
| + var targetPerson int64 |
| + found := false |
| + if r, ok := h.rooms[sqid]; ok { |
| + var tp *Participation |
| + if tp, found = r.peers[target]; found { |
| + targetPerson = tp.PersonID |
| + } |
| + } |
| + h.mu.Unlock() |
| + if !found { |
| + return ErrNotWaiting |
| + } |
| + targetKind, targetOK, err := h.cfg.StandingFor(ctx, sqid, targetPerson) |
| + if err != nil { |
| + return err |
| + } |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + // Both participations are re-found: either may have ended while the |
| + // two standing reads were in flight. |
| + a, ok := h.findLocked(sqid, session, actor) |
| + if !ok { |
| + return ErrUnknownPeer |
| + } |
| + r := h.rooms[sqid] |
| + if !actorOK { |
| + h.endLocked(r, a, ReasonStanding) |
| + return ErrNotYours |
| + } |
| + a.Kind = actorKind |
| + if !a.Admitted { |
| + return ErrNotAdmitted |
| + } |
| + if a.Kind != KindMember { |
| + return ErrNotMember |
| + } |
| + tp, ok := r.peers[target] |
| + if !ok || !tp.Joined || tp.Admitted { |
| + return ErrNotWaiting |
| + } |
| + if !targetOK { |
| + wasAdmitted, wasWaiting := h.endLocked(r, tp, ReasonExpired) |
| + h.announceDepartureLocked(r, tp, wasAdmitted, wasWaiting) |
| + return nil |
| + } |
| + tp.Kind = targetKind |
| + if !h.admitLocked(r, tp) { |
| + if a.sub != nil { |
| + a.sub.send(Message{Type: TypeFull}) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// Leave ends a participation in any state at its own request. It is not |
| +// a tombstoned ending: a leave is not something to resume from. |
| +func (h *Hub) Leave(sqid, session string, peer PeerID) error { |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + r, ok := h.rooms[sqid] |
| + if !ok { |
| + return ErrUnknownPeer |
| + } |
| + p, found := h.findLocked(sqid, session, peer) |
| + if !found { |
| + return ErrUnknownPeer |
| + } |
| + wasAdmitted, wasWaiting := h.endLocked(r, p, ReasonBuffer) |
| + delete(h.tombstones, p.Peer) |
| + h.announceDepartureLocked(r, p, wasAdmitted, wasWaiting) |
| + return nil |
| +} |
| + |
| +// Remove is spec §3.5: the actor holds an admitted member participation |
| +// in this room (kind re-derived), the target is any other participation. |
| +func (h *Hub) Remove(ctx context.Context, sqid, session string, actor, target PeerID) error { |
| + kind, ok, err := h.standing(ctx, sqid, session, actor) |
| + if err != nil { |
| + return err |
| + } |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + a, found := h.findLocked(sqid, session, actor) |
| + if !found || !ok { |
| + return ErrUnknownPeer |
| + } |
| + r := h.rooms[sqid] |
| + a.Kind = kind |
| + if !a.Admitted { |
| + return ErrNotAdmitted |
| + } |
| + if a.Kind != KindMember { |
| + return ErrNotMember |
| + } |
| + tp, found := r.peers[target] |
| + if !found || tp.Peer == a.Peer { |
| + return ErrUnknownPeer |
| + } |
| + wasAdmitted, wasWaiting := h.endLocked(r, tp, ReasonRemoved) |
| + h.announceDepartureLocked(r, tp, wasAdmitted, wasWaiting) |
| + return nil |
| +} |
| + |
| +// Takeover ends the session's live participation in the room with the |
| +// state-dependent departure (spec §4.2), leaving a replaced tombstone so |
| +// the displaced stream can be told why. |
| +func (h *Hub) Takeover(sqid, session string) (PeerID, error) { |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + r, ok := h.rooms[sqid] |
| + if !ok { |
| + return "", ErrUnknownPeer |
| + } |
| + id, ok := h.bySession[sqid][session] |
| + if !ok { |
| + return "", ErrUnknownPeer |
| + } |
| + p, ok := r.peers[id] |
| + if !ok { |
| + delete(h.bySession[sqid], session) |
| + return "", ErrUnknownPeer |
| + } |
| + wasAdmitted, wasWaiting := h.endLocked(r, p, ReasonReplaced) |
| + h.announceDepartureLocked(r, p, wasAdmitted, wasWaiting) |
| + return id, nil |
| +} |
| + |
| +// End is the meeting ending: lost{ended} to every stream, every |
| +// participation ended, and the room kept only as a headstone so a later |
| +// Open on an ended meeting is refused (spec §2.4). |
| +func (h *Hub) End(sqid string) { |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + r := h.roomLocked(sqid) |
| + for _, p := range r.peers { |
| + h.endLocked(r, p, ReasonEnded) |
| + } |
| + r.ended = true |
| + delete(h.bySession, sqid) |
| +} |
diff --git a/internal/meet/hub/admission_test.go b/internal/meet/hub/admission_test.go| new file mode 100644 |
| index 0000000..4b7a6e8 |
| --- /dev/null |
| +++ b/internal/meet/hub/admission_test.go |
| @@ -0,0 +1,294 @@ |
| +package hub |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// drain returns every event currently buffered on a sub, non-blocking. |
| +func drain(s *Sub) []Message { |
| + var out []Message |
| + for { |
| + select { |
| + case m, ok := <-s.C(): |
| + if !ok { |
| + return out |
| + } |
| + out = append(out, m) |
| + default: |
| + return out |
| + } |
| + } |
| +} |
| + |
| +func types(ms []Message) []string { |
| + out := make([]string, len(ms)) |
| + for i, m := range ms { |
| + out[i] = m.Type |
| + } |
| + return out |
| +} |
| + |
| +func TestMemberJoinsAndGuestWaits(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + h := testHub(&now) |
| + ctx := context.Background() |
| + m, _ := h.Open("r1", "sm", 1, "Mia", KindMember) |
| + if err := h.Join(ctx, "r1", "sm", m.Peer); err != nil { |
| + t.Fatal(err) |
| + } |
| + ev := drain(m.Sub) |
| + if len(ev) != 1 || ev[0].Type != TypeRoster || len(ev[0].Roster.Peers) != 1 { |
| + t.Fatalf("member join events %v", types(ev)) |
| + } |
| + g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest) |
| + if err := h.Join(ctx, "r1", "sg", g.Peer); err != nil { |
| + t.Fatal(err) |
| + } |
| + if ev := drain(g.Sub); len(ev) != 0 { |
| + t.Fatalf("waiting guest received %v", types(ev)) |
| + } |
| + ev = drain(m.Sub) |
| + if len(ev) != 1 || ev[0].Type != TypeKnock || ev[0].From != g.Peer || ev[0].Name != "Gus" { |
| + t.Fatalf("member did not get the knock: %v", types(ev)) |
| + } |
| +} |
| + |
| +func TestAdmitSendsRosterToGuestPeerToOthersAndKnockGone(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + h := testHub(&now) |
| + ctx := context.Background() |
| + m, _ := h.Open("r1", "sm", 1, "Mia", KindMember) |
| + h.Join(ctx, "r1", "sm", m.Peer) |
| + g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest) |
| + h.Join(ctx, "r1", "sg", g.Peer) |
| + drain(m.Sub) |
| + if err := h.Admit(ctx, "r1", "sm", m.Peer, g.Peer); err != nil { |
| + t.Fatal(err) |
| + } |
| + gev := drain(g.Sub) |
| + if len(gev) != 1 || gev[0].Type != TypeRoster || len(gev[0].Roster.Peers) != 2 || gev[0].Roster.Waiting != nil { |
| + t.Fatalf("guest got %v (waiting queue must not reach a guest)", types(gev)) |
| + } |
| + mev := types(drain(m.Sub)) |
| + if len(mev) != 2 || mev[0] != TypePeer || mev[1] != TypeKnockGone { |
| + t.Fatalf("member got %v", mev) |
| + } |
| +} |
| + |
| +func TestOnlyAnAdmittedMemberMayAdmit(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + h := testHub(&now) |
| + ctx := context.Background() |
| + m, _ := h.Open("r1", "sm", 1, "Mia", KindMember) |
| + h.Join(ctx, "r1", "sm", m.Peer) |
| + g1, _ := h.Open("r1", "s1", 100, "G1", KindGuest) |
| + h.Join(ctx, "r1", "s1", g1.Peer) |
| + g2, _ := h.Open("r1", "s2", 101, "G2", KindGuest) |
| + h.Join(ctx, "r1", "s2", g2.Peer) |
| + h.Admit(ctx, "r1", "sm", m.Peer, g1.Peer) |
| + if err := h.Admit(ctx, "r1", "s1", g1.Peer, g2.Peer); err != ErrNotMember { |
| + t.Fatalf("admitted guest admitting: %v", err) |
| + } |
| + if err := h.Admit(ctx, "r1", "s2", g2.Peer, g2.Peer); err != ErrNotAdmitted { |
| + t.Fatalf("waiting guest admitting: %v", err) |
| + } |
| +} |
| + |
| +func TestCapAtJoinAndAtAdmit(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + h := testHub(&now) |
| + ctx := context.Background() |
| + var members []OpenResult |
| + for i := 0; i < 5; i++ { |
| + o, _ := h.Open("r1", "s"+string(rune('a'+i)), int64(1+i), "M", KindMember) |
| + members = append(members, o) |
| + h.Join(ctx, "r1", "s"+string(rune('a'+i)), o.Peer) |
| + } |
| + fifth := drain(members[4].Sub) |
| + if len(fifth) != 1 || fifth[0].Type != TypeFull { |
| + t.Fatalf("fifth member got %v, want full", types(fifth)) |
| + } |
| + // One leaves; four queued guests and the freed slot yield one admission. |
| + h.Leave("r1", "sd", members[3].Peer) |
| + var guests []OpenResult |
| + for i := 0; i < 4; i++ { |
| + o, _ := h.Open("r1", "g"+string(rune('a'+i)), int64(100+i), "G", KindGuest) |
| + guests = append(guests, o) |
| + h.Join(ctx, "r1", "g"+string(rune('a'+i)), o.Peer) |
| + } |
| + drain(members[0].Sub) |
| + if err := h.Admit(ctx, "r1", "sa", members[0].Peer, guests[0].Peer); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := h.Admit(ctx, "r1", "sa", members[0].Peer, guests[1].Peer); err != nil { |
| + t.Fatal(err) // Admit itself returns nil; the outcome is the `full` event |
| + } |
| + ev := types(drain(members[0].Sub)) |
| + if ev[len(ev)-1] != TypeFull { |
| + t.Fatalf("second admit over the cap: %v, want full last", ev) |
| + } |
| + if p, _ := h.Find("r1", "gb", guests[1].Peer); p.Admitted { |
| + t.Fatal("guest admitted over the cap") |
| + } |
| +} |
| + |
| +func TestEightWaitingThenFull(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + h := testHub(&now) |
| + ctx := context.Background() |
| + for i := 0; i < 8; i++ { |
| + o, _ := h.Open("r1", "g"+string(rune('a'+i)), int64(100+i), "G", KindGuest) |
| + h.Join(ctx, "r1", "g"+string(rune('a'+i)), o.Peer) |
| + } |
| + o, _ := h.Open("r1", "gz", 200, "G", KindGuest) |
| + if err := h.Join(ctx, "r1", "gz", o.Peer); err != ErrTooManyWaiting { |
| + t.Fatalf("ninth waiting: %v", err) |
| + } |
| + if ev := drain(o.Sub); len(ev) != 1 || ev[0].Type != TypeFull { |
| + t.Fatalf("ninth got %v", types(ev)) |
| + } |
| +} |
| + |
| +func TestGuestFirstArrivalIsInTheMembersRoster(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + h := testHub(&now) |
| + ctx := context.Background() |
| + g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest) |
| + h.Join(ctx, "r1", "sg", g.Peer) |
| + m, _ := h.Open("r1", "sm", 1, "Mia", KindMember) |
| + h.Join(ctx, "r1", "sm", m.Peer) |
| + ev := drain(m.Sub) |
| + if len(ev) != 1 || ev[0].Type != TypeRoster || len(ev[0].Roster.Waiting) != 1 || ev[0].Roster.Waiting[0].Peer != g.Peer { |
| + t.Fatalf("member roster %+v", ev) |
| + } |
| +} |
| + |
| +func TestAdmitRefusesAnExpiredGrantBeforeTheTick(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + authority := map[int64]bool{100: true} |
| + h := New(Config{Now: func() time.Time { return now }, StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) { |
| + if id < 100 { |
| + return KindMember, true, nil |
| + } |
| + return KindGuest, authority[id], nil |
| + }}) |
| + ctx := context.Background() |
| + m, _ := h.Open("r1", "sm", 1, "Mia", KindMember) |
| + h.Join(ctx, "r1", "sm", m.Peer) |
| + g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest) |
| + h.Join(ctx, "r1", "sg", g.Peer) |
| + authority[100] = false // grant expired, no tick yet |
| + drain(m.Sub) |
| + if err := h.Admit(ctx, "r1", "sm", m.Peer, g.Peer); err != nil { |
| + t.Fatal(err) |
| + } |
| + if p, ok := h.Find("r1", "sg", g.Peer); ok && p.Admitted { |
| + t.Fatal("expired guest was admitted") |
| + } |
| + gev := drain(g.Sub) |
| + if len(gev) == 0 || gev[len(gev)-1].Type != TypeLost || gev[len(gev)-1].Reason != ReasonExpired { |
| + t.Fatalf("expired guest got %v", types(gev)) |
| + } |
| + if ev := types(drain(m.Sub)); len(ev) != 1 || ev[0] != TypeKnockGone { |
| + t.Fatalf("member got %v", ev) |
| + } |
| +} |
| + |
| +func TestTickDropsAMemberWhoLostStandingAndPromotesAWaitingGuest(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + kinds := map[int64]Kind{1: KindMember, 100: KindGuest} |
| + ok := map[int64]bool{1: true, 100: true} |
| + h := New(Config{Now: func() time.Time { return now }, StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) { |
| + return kinds[id], ok[id], nil |
| + }}) |
| + ctx := context.Background() |
| + m, _ := h.Open("r1", "sm", 1, "Mia", KindMember) |
| + h.Join(ctx, "r1", "sm", m.Peer) |
| + g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest) |
| + h.Join(ctx, "r1", "sg", g.Peer) |
| + drain(m.Sub) |
| + kinds[100] = KindMember // promoted while waiting |
| + h.Tick(ctx) |
| + if p, _ := h.Find("r1", "sg", g.Peer); !p.Admitted || p.Kind != KindMember { |
| + t.Fatal("waiting guest promoted to member was not admitted on the tick") |
| + } |
| + ok[1] = false // member removed from the allowlist |
| + drain(g.Sub) |
| + h.Tick(ctx) |
| + if _, found := h.Find("r1", "sm", m.Peer); found { |
| + t.Fatal("removed member still in the room") |
| + } |
| + mev := drain(m.Sub) |
| + if len(mev) == 0 || mev[len(mev)-1].Reason != ReasonStanding { |
| + t.Fatalf("removed member got %v", types(mev)) |
| + } |
| + if ev := types(drain(g.Sub)); len(ev) == 0 || ev[len(ev)-1] != TypeGone { |
| + t.Fatalf("remaining peer got %v, want gone last", ev) |
| + } |
| +} |
| + |
| +func TestTakeoverDepartureDependsOnState(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + h := testHub(&now) |
| + ctx := context.Background() |
| + m, _ := h.Open("r1", "sm", 1, "Mia", KindMember) |
| + h.Join(ctx, "r1", "sm", m.Peer) |
| + ag, _ := h.Open("r1", "sag", 100, "Adm", KindGuest) |
| + h.Join(ctx, "r1", "sag", ag.Peer) |
| + h.Admit(ctx, "r1", "sm", m.Peer, ag.Peer) |
| + w, _ := h.Open("r1", "sw", 101, "Wait", KindGuest) |
| + h.Join(ctx, "r1", "sw", w.Peer) |
| + u, _ := h.Open("r1", "su", 102, "Unj", KindGuest) |
| + drain(m.Sub) |
| + drain(ag.Sub) |
| + // waiting takeover: knock-gone to members only |
| + if _, err := h.Takeover("r1", "sw"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if ev := types(drain(ag.Sub)); len(ev) != 0 { |
| + t.Fatalf("admitted guest saw a waiting departure: %v", ev) |
| + } |
| + if ev := types(drain(m.Sub)); len(ev) != 1 || ev[0] != TypeKnockGone { |
| + t.Fatalf("member got %v", ev) |
| + } |
| + // unjoined takeover: nothing |
| + h.Takeover("r1", "su") |
| + _ = u |
| + if ev := types(drain(m.Sub)); len(ev) != 0 { |
| + t.Fatalf("unjoined takeover announced %v", ev) |
| + } |
| + // admitted takeover: gone to admitted peers, old stream lost/replaced |
| + h.Takeover("r1", "sag") |
| + agev := drain(ag.Sub) |
| + if len(agev) == 0 || agev[len(agev)-1].Reason != ReasonReplaced { |
| + t.Fatalf("replaced peer got %v", types(agev)) |
| + } |
| + if ev := types(drain(m.Sub)); len(ev) != 1 || ev[0] != TypeGone { |
| + t.Fatalf("member got %v", ev) |
| + } |
| + if _, err := h.Open("r1", "sag", 100, "Adm", KindGuest); err != nil { |
| + t.Fatalf("fresh stream after takeover: %v", err) |
| + } |
| +} |
| + |
| +func TestEndSendsEndedToEveryoneAndDropsTheRoom(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + h := testHub(&now) |
| + ctx := context.Background() |
| + m, _ := h.Open("r1", "sm", 1, "Mia", KindMember) |
| + h.Join(ctx, "r1", "sm", m.Peer) |
| + g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest) |
| + h.Join(ctx, "r1", "sg", g.Peer) |
| + h.End("r1") |
| + for _, s := range []*Sub{m.Sub, g.Sub} { |
| + ev := drain(s) |
| + if len(ev) == 0 || ev[len(ev)-1].Type != TypeLost || ev[len(ev)-1].Reason != ReasonEnded { |
| + t.Fatalf("got %v, want lost/ended last", types(ev)) |
| + } |
| + } |
| + if _, err := h.Open("r1", "sx", 5, "X", KindMember); err == nil { |
| + t.Fatal("ended room accepted a stream") |
| + } |
| +} |
diff --git a/internal/meet/hub/hub.go b/internal/meet/hub/hub.go| index 5cc7f8b..5a7328d 100644 |
| --- a/internal/meet/hub/hub.go |
| +++ b/internal/meet/hub/hub.go |
| @@ -265,12 +265,22 @@ func (h *Hub) endLocked(r *room, p *Participation, reason Reason) (wasAdmitted, |
| return |
| } |
| +// standingCheck is one joined participation whose standing the tick must |
| +// re-read, and the answer it came back with. |
| +type standingCheck struct { |
| + room string |
| + peer PeerID |
| + personID int64 |
| + kind Kind |
| + ok bool |
| +} |
| + |
| // Tick runs the fifteen-second housekeeping: unjoined and lapsed |
| -// participations are dropped, tombstones expire. Task 2 adds kind |
| -// re-derivation and promotion. |
| +// participations are dropped, tombstones expire, and every joined |
| +// participation's kind is re-derived from standing, applying the |
| +// transition table of spec §2.4. |
| func (h *Hub) Tick(ctx context.Context) { |
| h.mu.Lock() |
| - defer h.mu.Unlock() |
| now := h.cfg.Now() |
| for _, r := range h.rooms { |
| for _, p := range r.peers { |
| @@ -290,6 +300,67 @@ func (h *Hub) Tick(ctx context.Context) { |
| delete(h.tombstones, id) |
| } |
| } |
| + var checks []standingCheck |
| + for _, r := range h.rooms { |
| + for _, p := range r.peers { |
| + if p.Joined { |
| + checks = append(checks, standingCheck{room: r.sqid, peer: p.Peer, personID: p.PersonID}) |
| + } |
| + } |
| + } |
| + h.mu.Unlock() |
| + |
| + // Standing is a database read, so it happens with the lock down. |
| + answered := make([]standingCheck, 0, len(checks)) |
| + for _, c := range checks { |
| + kind, ok, err := h.cfg.StandingFor(ctx, c.room, c.personID) |
| + if err != nil { |
| + h.cfg.Logger.Warn("hub: standing lookup failed on tick", |
| + "room", c.room, "peer", c.peer, "error", err) |
| + continue // this participation waits for the next tick |
| + } |
| + c.kind, c.ok = kind, ok |
| + answered = append(answered, c) |
| + } |
| + |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + for _, c := range answered { |
| + r, found := h.rooms[c.room] |
| + if !found { |
| + continue |
| + } |
| + p, found := r.peers[c.peer] |
| + if !found || !p.Joined { |
| + continue // it ended while standing was being read |
| + } |
| + switch { |
| + case !c.ok && p.Kind == KindMember: |
| + // A member who lost standing loses the room, admitted or not. |
| + wasAdmitted, wasWaiting := h.endLocked(r, p, ReasonStanding) |
| + h.announceDepartureLocked(r, p, wasAdmitted, wasWaiting) |
| + case !c.ok && !p.Admitted: |
| + // A waiting guest's grant lapsed before anyone admitted them. |
| + wasAdmitted, wasWaiting := h.endLocked(r, p, ReasonExpired) |
| + h.announceDepartureLocked(r, p, wasAdmitted, wasWaiting) |
| + case !c.ok: |
| + // An admitted guest whose grant expired: nothing. The |
| + // participation is the authority now (ruling 14). |
| + case p.Admitted: |
| + if p.Kind != c.kind { |
| + p.Kind = c.kind |
| + h.refreshRostersLocked(r) |
| + } |
| + default: |
| + // Waiting. A promotion to member is admission under the cap, |
| + // exactly as an admit would be; a refusal leaves them waiting |
| + // as a member, free to retry with join. |
| + p.Kind = c.kind |
| + if c.kind == KindMember && !h.admitLocked(r, p) && p.sub != nil { |
| + p.sub.send(Message{Type: TypeFull}) |
| + } |
| + } |
| + } |
| } |
| // announceDepartureLocked tells the room that a participation is gone, |