oficina / meet Public

hub: rooms, participations, peer ids, limits, tombstones

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 5922711550d085635ce231a4b7f8caadf6158410 parent d713b28
4 files changed, +608 −0
  • internal/meet/hub/hub.go +341 −0
  • internal/meet/hub/hub_test.go +122 −0
  • internal/meet/hub/messages.go +96 −0
  • internal/meet/hub/subscriber.go +49 −0
diff --git a/internal/meet/hub/hub.go b/internal/meet/hub/hub.go
new file mode 100644
index 0000000..5cc7f8b
--- /dev/null
+++ b/internal/meet/hub/hub.go
@@ -0,0 +1,341 @@
+// Package hub is the in-process authority for who is in a room: the
+// participations of spec §2.4, the peer identity and limits of §4.2 and
+// §4.4, and the resume window of §4.5. It holds no HTTP and no database;
+// the handlers wrap it and the store answers standing for it.
+//
+// The hub is per process, which is fine on one host and would not
+// survive a multi-host fleet; that is noted, not solved (spec §4.2).
+package hub
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "log/slog"
+ "sync"
+ "time"
+)
+
+const (
+ // MaxParticipations is what a room holds in any state: unjoined,
+ // waiting, admitted, or held for resume (spec §4.2).
+ MaxParticipations = 16
+ // MaxWaiting is the knock queue; a ninth waiting join is refused
+ // with full (spec §4.4).
+ MaxWaiting = 8
+ // MaxAdmitted is the mesh cap, checked at every transition into
+ // the admitted state and never on a resume (spec §4.3).
+ MaxAdmitted = 4
+ // MaxStreamsPerPerson counts concurrent streams across all rooms.
+ MaxStreamsPerPerson = 4
+
+ // UnjoinedWindow is how long a participation lives without a join.
+ UnjoinedWindow = 30 * time.Second
+ // ResumeWindow is how long a detached participation keeps its
+ // admission waiting for the browser to come back (spec §4.5).
+ ResumeWindow = 30 * time.Second
+ // TombstoneLife is how long an ended peer id is remembered so a
+ // resume can be told why it ended (spec §4.5).
+ TombstoneLife = 10 * time.Minute
+ // RingSize is the replay ring: the last targeted messages held for
+ // one participation, for the resume window only (spec §4.5).
+ RingSize = 32
+)
+
+var (
+ ErrRoomFull = errors.New("hub: room holds sixteen participations")
+ ErrSessionHasPeer = errors.New("hub: this session already has a peer in the room")
+ ErrPersonStreams = errors.New("hub: this person has four streams open")
+ ErrUnknownPeer = errors.New("hub: no such peer")
+ ErrNotYours = errors.New("hub: peer is not bound to this session")
+ ErrNotAdmitted = errors.New("hub: peer is not admitted")
+ ErrNotMember = errors.New("hub: peer is not a member")
+ ErrNotWaiting = errors.New("hub: target is not waiting")
+ ErrStaleGen = errors.New("hub: stale generation")
+ ErrTooManyWaiting = errors.New("hub: eight already waiting")
+)
+
+type Config struct {
+ Now func() time.Time
+ Logger *slog.Logger
+ // StandingFor re-derives a person's kind and whether they still hold
+ // entry authority for the room. ok=false means standing none.
+ StandingFor func(ctx context.Context, room string, personID int64) (kind Kind, ok bool, err error)
+}
+
+// Participation is the hub's record that a peer is in a room. It is the
+// authority for everything a peer does after entry (spec §2.4).
+type Participation struct {
+ Peer PeerID
+ Room string
+ Session string
+ PersonID int64
+ Name string
+ Kind Kind
+ Admitted bool
+ Joined bool // has posted join (waiting or admitted)
+ JoinedAt time.Time
+ OpenedAt time.Time
+
+ sub *Sub
+ resumeUntil time.Time // zero while a stream is attached
+ seqOut map[PeerID]uint64 // next seq per target (unused by the hub; kept by browsers)
+ lastSeen map[PeerID]uint64
+ ring []Message // targeted messages delivered to this peer, newest last, cap RingSize
+}
+
+type pairKey struct{ a, b PeerID }
+
+func keyFor(x, y PeerID) pairKey {
+ if x < y {
+ return pairKey{x, y}
+ }
+ return pairKey{y, x}
+}
+
+type room struct {
+ sqid string
+ peers map[PeerID]*Participation
+ gens map[pairKey]uint64
+ ended bool
+}
+
+type tombstone struct {
+ reason Reason
+ at time.Time
+}
+
+type Hub struct {
+ cfg Config
+ boot string
+
+ mu sync.Mutex
+ rooms map[string]*room
+ bySession map[string]map[string]PeerID // room -> session -> peer
+ byPerson map[int64]int // open streams per person
+ tombstones map[PeerID]tombstone
+}
+
+func New(cfg Config) *Hub {
+ if cfg.Now == nil {
+ cfg.Now = time.Now
+ }
+ if cfg.Logger == nil {
+ cfg.Logger = slog.Default()
+ }
+ return &Hub{
+ cfg: cfg, boot: randomID(),
+ rooms: map[string]*room{}, bySession: map[string]map[string]PeerID{},
+ byPerson: map[int64]int{}, tombstones: map[PeerID]tombstone{},
+ }
+}
+
+func randomID() string {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ panic(err)
+ }
+ return hex.EncodeToString(b)
+}
+
+// Boot is the random id chosen when the process started. Every hello
+// carries it so a browser can tell a restart from any other refusal
+// (spec §4.2).
+func (h *Hub) Boot() string { return h.boot }
+
+func (h *Hub) roomLocked(sqid string) *room {
+ r, ok := h.rooms[sqid]
+ if !ok {
+ r = &room{sqid: sqid, peers: map[PeerID]*Participation{}, gens: map[pairKey]uint64{}}
+ h.rooms[sqid] = r
+ }
+ return r
+}
+
+type OpenResult struct {
+ Peer PeerID
+ Sub *Sub
+ Existing PeerID
+}
+
+// Open is a fresh stream: a new unjoined participation, subject to the
+// room, session and person limits, all checked before allocation.
+func (h *Hub) Open(sqid, session string, personID int64, name string, kind Kind) (OpenResult, error) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ r := h.rooms[sqid] // nil until this room holds someone
+ if r != nil && r.ended {
+ return OpenResult{}, ErrUnknownPeer
+ }
+ if existing, ok := h.bySession[sqid][session]; ok {
+ return OpenResult{Existing: existing}, ErrSessionHasPeer
+ }
+ if r != nil && len(r.peers) >= MaxParticipations {
+ return OpenResult{}, ErrRoomFull
+ }
+ if h.byPerson[personID] >= MaxStreamsPerPerson {
+ return OpenResult{}, ErrPersonStreams
+ }
+ r = h.roomLocked(sqid)
+ p := &Participation{
+ Peer: PeerID(randomID()), Room: sqid, Session: session, PersonID: personID, Name: name, Kind: kind,
+ OpenedAt: h.cfg.Now(), sub: newSub(), seqOut: map[PeerID]uint64{}, lastSeen: map[PeerID]uint64{},
+ }
+ r.peers[p.Peer] = p
+ if h.bySession[sqid] == nil {
+ h.bySession[sqid] = map[string]PeerID{}
+ }
+ h.bySession[sqid][session] = p.Peer
+ h.byPerson[personID]++
+ return OpenResult{Peer: p.Peer, Sub: p.sub}, nil
+}
+
+// Find answers a peer only through its own room and session.
+func (h *Hub) Find(sqid, session string, peer PeerID) (*Participation, bool) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ p, ok := h.findLocked(sqid, session, peer)
+ return p, ok
+}
+
+func (h *Hub) findLocked(sqid, session string, peer PeerID) (*Participation, bool) {
+ r, ok := h.rooms[sqid]
+ if !ok {
+ return nil, false
+ }
+ p, ok := r.peers[peer]
+ if !ok || p.Session != session {
+ return nil, false
+ }
+ return p, true
+}
+
+// LiveForSession names the peer this session holds in the room, whether
+// attached or within its resume window (spec §4.2).
+func (h *Hub) LiveForSession(sqid, session string) (PeerID, bool) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ id, ok := h.bySession[sqid][session]
+ return id, ok
+}
+
+// Detach starts the resume window for a participation whose stream
+// closed. Its subscriber is closed; a resume attaches a new one.
+func (h *Hub) Detach(sqid string, peer PeerID) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ r, ok := h.rooms[sqid]
+ if !ok {
+ return
+ }
+ p, ok := r.peers[peer]
+ if !ok {
+ return
+ }
+ if p.sub != nil {
+ p.sub.terminate(Message{Type: TypeLost, Reason: ReasonBuffer})
+ p.sub = nil
+ }
+ p.resumeUntil = h.cfg.Now().Add(ResumeWindow)
+}
+
+// endLocked removes a participation for a terminal reason, closes its
+// stream with lost{reason}, leaves a tombstone, and returns whether it
+// was admitted or waiting so the caller can announce the departure.
+func (h *Hub) endLocked(r *room, p *Participation, reason Reason) (wasAdmitted, wasWaiting bool) {
+ wasAdmitted, wasWaiting = p.Admitted, p.Joined && !p.Admitted
+ if p.sub != nil {
+ p.sub.terminate(Message{Type: TypeLost, Reason: reason})
+ p.sub = nil
+ }
+ delete(r.peers, p.Peer)
+ if h.bySession[r.sqid][p.Session] == p.Peer {
+ delete(h.bySession[r.sqid], p.Session)
+ }
+ if h.byPerson[p.PersonID] > 0 {
+ h.byPerson[p.PersonID]--
+ }
+ h.tombstones[p.Peer] = tombstone{reason: reason, at: h.cfg.Now()}
+ for k := range r.gens {
+ if k.a == p.Peer || k.b == p.Peer {
+ delete(r.gens, k)
+ }
+ }
+ return
+}
+
+// Tick runs the fifteen-second housekeeping: unjoined and lapsed
+// participations are dropped, tombstones expire. Task 2 adds kind
+// re-derivation and promotion.
+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 {
+ switch {
+ case !p.Joined && p.sub != nil && now.Sub(p.OpenedAt) > UnjoinedWindow:
+ h.endLocked(r, p, ReasonBuffer) // dropped quietly; the browser sees a closed stream
+ delete(h.tombstones, p.Peer)
+ case p.sub == nil && !p.resumeUntil.IsZero() && now.After(p.resumeUntil):
+ wasAdmitted, wasWaiting := h.endLocked(r, p, ReasonBuffer)
+ delete(h.tombstones, p.Peer)
+ h.announceDepartureLocked(r, p, wasAdmitted, wasWaiting)
+ }
+ }
+ }
+ for id, t := range h.tombstones {
+ if now.Sub(t.at) > TombstoneLife {
+ delete(h.tombstones, id)
+ }
+ }
+}
+
+// announceDepartureLocked tells the room that a participation is gone,
+// in the shape its state earns: an admitted peer yields gone to admitted
+// peers, a waiting one yields knock-gone to admitted members, an
+// unjoined one yields nothing (spec §4.3).
+func (h *Hub) announceDepartureLocked(r *room, p *Participation, wasAdmitted, wasWaiting bool) {
+ switch {
+ case wasAdmitted:
+ h.broadcastLocked(r, Message{Type: TypeGone, From: p.Peer}, admittedPeers)
+ case wasWaiting:
+ h.broadcastLocked(r, Message{Type: TypeKnockGone, From: p.Peer}, admittedMembers)
+ }
+}
+
+type audience func(*Participation) bool
+
+func admittedPeers(p *Participation) bool { return p.Admitted }
+func admittedMembers(p *Participation) bool { return p.Admitted && p.Kind == KindMember }
+
+func (h *Hub) broadcastLocked(r *room, m Message, who audience) {
+ for _, q := range r.peers {
+ if who(q) && q.sub != nil {
+ q.sub.send(m)
+ }
+ }
+}
+
+// deliver is the one place an event reaches a subscriber; targeted
+// messages are also kept in the recipient's replay ring (Task 3).
+func (h *Hub) deliver(sqid string, to PeerID, m Message) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.deliverLocked(sqid, to, m)
+}
+
+func (h *Hub) deliverLocked(sqid string, to PeerID, m Message) {
+ r, ok := h.rooms[sqid]
+ if !ok {
+ return
+ }
+ p, ok := r.peers[to]
+ if !ok {
+ return
+ }
+ if p.sub != nil {
+ p.sub.send(m)
+ }
+}
diff --git a/internal/meet/hub/hub_test.go b/internal/meet/hub/hub_test.go
new file mode 100644
index 0000000..a9ec43b
--- /dev/null
+++ b/internal/meet/hub/hub_test.go
@@ -0,0 +1,122 @@
+package hub
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "testing"
+ "time"
+)
+
+func testHub(now *time.Time) *Hub {
+ return New(Config{
+ Now: func() time.Time { return *now },
+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ StandingFor: func(ctx context.Context, room string, personID int64) (Kind, bool, error) {
+ if personID < 100 {
+ return KindMember, true, nil
+ }
+ return KindGuest, true, nil
+ },
+ })
+}
+
+func TestOpenIssuesADistinctPeerBoundToTheSession(t *testing.T) {
+ now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
+ h := testHub(&now)
+ a, err := h.Open("r1", "sessA", 1, "Ann", KindMember)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, _ := h.Open("r1", "sessB", 2, "Bob", KindMember)
+ if a.Peer == b.Peer || len(a.Peer) != 32 {
+ t.Fatalf("peer ids %q %q", a.Peer, b.Peer)
+ }
+ if _, ok := h.Find("r1", "sessB", a.Peer); ok {
+ t.Fatal("a peer was found through another session")
+ }
+ if _, ok := h.Find("r2", "sessA", a.Peer); ok {
+ t.Fatal("a peer was found through another room")
+ }
+ if p, ok := h.Find("r1", "sessA", a.Peer); !ok || p.Admitted {
+ t.Fatal("fresh participation should exist and be unjoined")
+ }
+}
+
+func TestOneLivePeerPerSessionPerRoom(t *testing.T) {
+ now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
+ h := testHub(&now)
+ a, _ := h.Open("r1", "sessA", 1, "Ann", KindMember)
+ _, err := h.Open("r1", "sessA", 1, "Ann", KindMember)
+ if err != ErrSessionHasPeer {
+ t.Fatalf("second stream: %v", err)
+ }
+ h.Detach("r1", a.Peer)
+ if _, err := h.Open("r1", "sessA", 1, "Ann", KindMember); err != ErrSessionHasPeer {
+ t.Fatal("a detached peer inside its resume window should still block a fresh stream")
+ }
+ now = now.Add(31 * time.Second)
+ h.Tick(context.Background())
+ if _, err := h.Open("r1", "sessA", 1, "Ann", KindMember); err != nil {
+ t.Fatalf("after the window: %v", err)
+ }
+}
+
+func TestSixteenParticipationsPerRoomThenRefuse(t *testing.T) {
+ now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
+ h := testHub(&now)
+ for i := 0; i < 16; i++ {
+ if _, err := h.Open("r1", "s"+string(rune('a'+i)), int64(100+i), "g", KindGuest); err != nil {
+ t.Fatalf("open %d: %v", i, err)
+ }
+ }
+ if _, err := h.Open("r1", "sZ", 200, "g", KindGuest); err != ErrRoomFull {
+ t.Fatalf("seventeenth: %v", err)
+ }
+}
+
+func TestUnjoinedParticipationDropsAfterThirtySeconds(t *testing.T) {
+ now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
+ h := testHub(&now)
+ a, _ := h.Open("r1", "sessA", 1, "Ann", KindMember)
+ now = now.Add(31 * time.Second)
+ h.Tick(context.Background())
+ if _, ok := h.Find("r1", "sessA", a.Peer); ok {
+ t.Fatal("unjoined participation survived the window")
+ }
+ if !a.Sub.Lost() {
+ t.Fatal("its subscriber was not closed")
+ }
+}
+
+func TestFourStreamsPerPerson(t *testing.T) {
+ now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
+ h := testHub(&now)
+ for i := 0; i < 4; i++ {
+ if _, err := h.Open("r"+string(rune('1'+i)), "s"+string(rune('1'+i)), 1, "Ann", KindMember); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := h.Open("r9", "s9", 1, "Ann", KindMember); err != ErrPersonStreams {
+ t.Fatalf("fifth stream: %v", err)
+ }
+}
+
+func TestSlowSubscriberIsDeclaredLost(t *testing.T) {
+ now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
+ h := testHub(&now)
+ a, _ := h.Open("r1", "sessA", 1, "Ann", KindMember)
+ for i := 0; i < 70; i++ {
+ h.deliver("r1", a.Peer, Message{Type: "peer"})
+ }
+ if !a.Sub.Lost() {
+ t.Fatal("65th undelivered event did not declare the subscriber lost")
+ }
+ var last Message
+ for m := range a.Sub.C() {
+ last = m
+ }
+ if last.Type != "lost" || last.Reason != "buffer" {
+ t.Fatalf("last event %+v, want lost/buffer", last)
+ }
+}
diff --git a/internal/meet/hub/messages.go b/internal/meet/hub/messages.go
new file mode 100644
index 0000000..ec731f9
--- /dev/null
+++ b/internal/meet/hub/messages.go
@@ -0,0 +1,96 @@
+package hub
+
+import "encoding/json"
+
+// PeerID is the server-issued identity of one participation: sixteen
+// random bytes in hex, bound to a room and a session, shown in rosters
+// so browsers can address each other. It is not a credential (spec §4.2).
+type PeerID string
+
+// Kind is a participation's authority, re-derived from standing on
+// every tick (spec §2.4).
+type Kind int
+
+// Reason says why a stream ended. Only "buffer" is followed by a
+// resume; the rest are terminal (spec §4.5).
+type Reason string
+
+const (
+ KindGuest Kind = iota
+ KindMember
+)
+
+func (k Kind) String() string {
+ if k == KindMember {
+ return "member"
+ }
+ return "guest"
+}
+
+const (
+ ReasonBuffer Reason = "buffer"
+ ReasonReplaced Reason = "replaced"
+ ReasonStanding Reason = "standing"
+ ReasonExpired Reason = "expired"
+ ReasonRemoved Reason = "removed"
+ ReasonEnded Reason = "ended"
+)
+
+// Upstream types a browser may send.
+const (
+ TypeJoin = "join"
+ TypeAdmit = "admit"
+ TypeLeave = "leave"
+ TypeOffer = "offer"
+ TypeAnswer = "answer"
+ TypeCandidate = "candidate"
+ TypeReset = "reset"
+ TypeChat = "chat" // slice 4
+ TypeShare = "share" // slice 4
+)
+
+// Downstream types the server emits.
+const (
+ TypeHello = "hello"
+ TypeRoster = "roster"
+ TypePeer = "peer"
+ TypeGone = "gone"
+ TypeKnock = "knock"
+ TypeKnockGone = "knock-gone"
+ TypeFull = "full"
+ TypeEnded = "ended"
+ TypeLost = "lost"
+ TypePair = "pair"
+ TypeGap = "gap"
+ TypeState = "state"
+)
+
+// Message is the one envelope, built by the server. Payload is opaque.
+type Message struct {
+ Type string `json:"type"`
+ From PeerID `json:"from,omitempty"`
+ Name string `json:"name,omitempty"`
+ To PeerID `json:"to,omitempty"`
+ Seq uint64 `json:"seq,omitempty"`
+ Gen uint64 `json:"gen,omitempty"`
+ Payload json.RawMessage `json:"payload,omitempty"`
+ Reason Reason `json:"reason,omitempty"`
+ Roster *Roster `json:"roster,omitempty"`
+ Boot string `json:"boot,omitempty"`
+ Peer PeerID `json:"peer,omitempty"`
+ Waiting bool `json:"waiting,omitempty"`
+}
+
+// Roster is the authoritative snapshot a browser reconciles against
+// (spec §4.3). Waiting is filled only for a member recipient.
+type Roster struct {
+ Peers []RosterPeer `json:"peers"`
+ Waiting []RosterPeer `json:"waiting,omitempty"`
+}
+
+type RosterPeer struct {
+ Peer PeerID `json:"peer"`
+ Name string `json:"name"`
+ Kind string `json:"kind"`
+ Gen uint64 `json:"gen,omitempty"`
+}
diff --git a/internal/meet/hub/subscriber.go b/internal/meet/hub/subscriber.go
new file mode 100644
index 0000000..d2feccf
--- /dev/null
+++ b/internal/meet/hub/subscriber.go
@@ -0,0 +1,49 @@
+package hub
+
+import "sync/atomic"
+
+const subBuffer = 64
+
+// Sub is one stream's outbound channel. A subscriber that falls 64
+// events behind is declared lost and told so (spec §4.4), as in Sheets.
+type Sub struct {
+ ch chan Message
+ lost atomic.Bool
+ done chan struct{}
+}
+
+func newSub() *Sub { return &Sub{ch: make(chan Message, subBuffer), done: make(chan struct{})} }
+
+func (s *Sub) C() <-chan Message { return s.ch }
+func (s *Sub) Lost() bool { return s.lost.Load() }
+
+// send is non-blocking; a full buffer declares the subscriber lost,
+// drains room for the lost event, and closes.
+func (s *Sub) send(m Message) {
+ select {
+ case <-s.done:
+ return
+ default:
+ }
+ select {
+ case s.ch <- m:
+ default:
+ s.terminate(Message{Type: TypeLost, Reason: ReasonBuffer})
+ }
+}
+
+func (s *Sub) terminate(final Message) {
+ if s.lost.Swap(true) {
+ return
+ }
+ close(s.done)
+ // Make room for the final event, then close so the range ends.
+ select {
+ case <-s.ch:
+ default:
+ }
+ s.ch <- final
+ close(s.ch)
+}
+
+func (s *Sub) Close() { s.terminate(Message{Type: TypeLost, Reason: ReasonBuffer}) }