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(), Media: p.Media} 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 { // Usually a guest, but a peer the tick promoted while the // room was full waits on as a member (spec §2.4). ro.Waiting = append(ro.Waiting, RosterPeer{Peer: p.Peer, Name: p.Name, Kind: p.Kind.String()}) } } } 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() h.sendLocked(p, 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), Media: p.Media}}}}) } } if wasWaiting { // Not to the newcomer: a peer the tick promoted to member is // admitted as one, and would otherwise be told its own knock had // gone, a moment after the roster that already said so. h.broadcastLocked(r, Message{Type: TypeKnockGone, From: p.Peer}, func(q *Participation) bool { return q.Peer != p.Peer && admittedMembers(q) }) } 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)}) } } } // setKindLocked records a re-derived kind. A kind that actually changed // on an admitted peer owes every admitted peer a fresh roster, whether // the change was seen on the tick or on a request (spec §2.4), so every // re-derivation writes Kind through here and nowhere else. func (h *Hub) setKindLocked(r *room, p *Participation, kind Kind) { if p.Kind == kind { return } p.Kind = kind if p.Admitted { h.refreshRostersLocked(r) } } // standingLostLocked applies a loss of standing the way spec §2.4 asks. // A member loses the room; so does anyone not yet admitted, a guest with // the reason their grant earns. An **admitted** guest is untouched: a // participation is never ended by a grant expiring, because the // participation is the authority now (ruling 14). It reports whether the // participation ended, and announces the departure when it did. func (h *Hub) standingLostLocked(r *room, p *Participation) bool { if p.Kind != KindMember && p.Admitted { return false } reason := ReasonExpired if p.Kind == KindMember { reason = ReasonStanding } wasAdmitted, wasWaiting := h.endLocked(r, p, reason) h.announceDepartureLocked(r, p, wasAdmitted, wasWaiting) return true } // memberStandingGone re-derives standing for a participation whose kind // is `member`, which spec §2.5's paragraph — and §15.12, which corrects // the table row above it — require on **every** request that // participation makes, not only on the tick: "a member who has just been // removed is dropped by the next thing they do, not the next tick." // Relay, Reset, Resume and CanResume all go through here first. // // A guest is never touched and never costs a standing read: ruling 14 // makes the participation the authority once someone is admitted, and a // waiting guest is the tick's to expire. // // end says whether this call may write **at all**. The mutating callers // (Relay, Reset, Resume) end a member who has lost standing here and // now, with lost{standing} and the departure the room is owed, and // record a kind that has changed; the probe (CanResume) passes false and // writes nothing whatever the answer was — not the ending, and not the // kind either, because recording a member's demotion to guest would // broadcast a fresh roster to every admitted peer, which is a room-wide // event caused by a request that is defined as changing nothing (§15.3, // Codex S2-10). The next mutating request, or the tick, applies it. // // The standing read happens with the lock down, like every other one // (StandingFor is a database read), so the participation is re-found // under the lock afterwards: it may have ended while the read was in // flight. func (h *Hub) memberStandingGone(ctx context.Context, sqid, session string, peer PeerID, end bool) (bool, error) { h.mu.Lock() p, found := h.findLocked(sqid, session, peer) if !found || p.Kind != KindMember { // Not found is the caller's own lookup to answer, in its own // words; a guest is not this function's business at all. h.mu.Unlock() return false, nil } personID := p.PersonID h.mu.Unlock() kind, ok, err := h.cfg.StandingFor(ctx, sqid, personID) if err != nil { return false, err } h.mu.Lock() defer h.mu.Unlock() p, found = h.findLocked(sqid, session, peer) if !found { return false, nil } r := h.rooms[sqid] if !ok { if !end { // The kind is read again rather than remembered: the tick may // have made this an admitted guest while the read was out, and // that one keeps everything (ruling 14). return p.Kind == KindMember, nil } return h.standingLostLocked(r, p), nil } if !end { return false, nil } h.setKindLocked(r, p, kind) return false, nil } // 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 } waiting := true if h.cfg.WaitingRoom != nil { waiting, err = h.cfg.WaitingRoom(ctx, sqid) 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 { // Standing is checked before the kind is written, because what a // loss costs depends on the kind the peer held. A member is // dropped by the next thing they do, not the next tick (§2.5); // an admitted guest whose grant lapsed keeps everything. if h.standingLostLocked(r, p) { return ErrNotYours } return nil } h.setKindLocked(r, p, kind) if p.Joined && p.Admitted { return nil // idempotent } if p.Joined { // Waiting. A guest waits on; a peer the tick promoted to member // while the room was full may retry here, which is the retry // §2.4 promises them. if kind != KindMember && waiting { return nil } if !h.admitLocked(r, p) { h.sendLocked(p, Message{Type: TypeFull}) return ErrMeshFull } return nil } if kind == KindMember || !waiting { if !h.admitLocked(r, p) { h.sendLocked(p, Message{Type: TypeFull}) return ErrMeshFull } return nil } if waitingCountLocked(r) >= MaxWaiting { h.sendLocked(p, 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) // Who is already looking, gathered here because it needs the lock and // must not be re-read later: by the time the notification is sent the // room may have changed, and telling someone who *was* present is a // smaller fault than holding the mutex across a network call. if h.cfg.Knocked != nil { watching := make([]int64, 0, len(r.peers)) for _, other := range r.peers { if other.Admitted && other.Kind == KindMember { watching = append(watching, other.PersonID) } } go h.cfg.Knocked(r.sqid, watching, p.Name) } 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). // // The actor's own authority is settled completely — found, current // standing applied, admitted, member — before the target is looked at // at all. The other order (found first in an earlier revision) let any // joined participation, admitted or not, learn from the shape of the // refusal alone whether a candidate peer id existed in the room, and // spent a StandingFor call on the target even when the actor had no // business asking (review Important 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() a, ok := h.findLocked(sqid, session, actor) if !ok { h.mu.Unlock() return ErrUnknownPeer } r := h.rooms[sqid] if !actorOK { lost := h.standingLostLocked(r, a) h.mu.Unlock() if lost { return ErrNotYours } // An admitted guest whose grant lapsed keeps the room but never // gains the authority to let anyone else in. return ErrNotMember } h.setKindLocked(r, a, actorKind) if !a.Admitted { h.mu.Unlock() return ErrNotAdmitted } if a.Kind != KindMember { h.mu.Unlock() return ErrNotMember } // Only now, with the actor's authority settled, is the target looked // at — first for its bare existence in this room, still under this // same lock, before it costs a StandingFor call at all. tp, found := r.peers[target] if !found || !tp.Joined || tp.Admitted { h.mu.Unlock() return ErrNotWaiting } targetPerson := tp.PersonID h.mu.Unlock() 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 // target's standing read was in flight. a, ok = h.findLocked(sqid, session, actor) if !ok { return ErrUnknownPeer } r = h.rooms[sqid] 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 { // Through standingLostLocked, not endLocked: a loss of standing // costs what the kind held earns, and there is exactly one place // that decides which (spec §2.4). The target is always waiting // here — the guard above refuses an admitted one — but it is not // always a guest: the tick promotes a waiting guest to member // and leaves it waiting when the mesh is full, and that one is // owed reason "standing", not "expired". h.standingLostLocked(r, tp) return nil } h.setKindLocked(r, tp, targetKind) if !h.admitLocked(r, tp) { h.sendLocked(a, 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 { return ErrUnknownPeer } r := h.rooms[sqid] if !ok { if h.standingLostLocked(r, a) { return ErrNotYours } return ErrNotMember // an admitted guest, grant lapsed: still not a member } h.setKindLocked(r, a, 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 stream // racing the ending is refused (spec §2.4). A room nobody opened needs // no headstone: the handler has already read ended_at, and every later // entry reads it too. func (h *Hub) End(sqid string) { h.mu.Lock() defer h.mu.Unlock() r, ok := h.rooms[sqid] if !ok { return } for _, p := range r.peers { h.endLocked(r, p, ReasonEnded) } r.ended = true delete(h.bySession, sqid) // Nothing reaps rooms today, so a headstone lives as long as the // process. If a future round ever does reap them, it must leave an // ended room in place for at least TombstoneLife: the tombstones // written just above are answered only through their own room // (resumeRefusalLocked), and a room reaped ahead of them turns spec // §4.5's terminal 410 "ended" into a 404, which sends a browser off // to open a fresh stream on a meeting that is over. } // RefreshWaiting rechecks queued guests through the normal entry gate after // meeting settings change. Database callbacks must run outside the hub lock. func (h *Hub) RefreshWaiting(ctx context.Context, sqid string) error { type waitingPeer struct { session string peer PeerID } h.mu.Lock() var peers []waitingPeer if room := h.rooms[sqid]; room != nil { for _, p := range room.peers { if p.Joined && !p.Admitted { peers = append(peers, waitingPeer{p.Session, p.Peer}) } } } h.mu.Unlock() for _, p := range peers { err := h.Join(ctx, sqid, p.session, p.peer) if err != nil && err != ErrMeshFull && err != ErrUnknownPeer && err != ErrNotYours { return err } } return nil }