oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1// Package hub is the in-process authority for who is in a room: the
2// participations of spec §2.4, the peer identity and limits of §4.2 and
3// §4.4, and the resume window of §4.5. It holds no HTTP and no database;
4// the handlers wrap it and the store answers standing for it.
5//
6// The hub is per process, which is fine on one host and would not
7// survive a multi-host fleet; that is noted, not solved (spec §4.2).
8package hub
9
10import (
11 "context"
12 "crypto/rand"
13 "encoding/hex"
14 "errors"
15 "log/slog"
16 "sync"
17 "time"
18)
19
20const (
21 // MaxParticipations is what a room holds in any state: unjoined,
22 // waiting, admitted, or held for resume (spec §4.2).
23 MaxParticipations = 16
24 // MaxWaiting is the knock queue; a ninth waiting join is refused
25 // with full (spec §4.4).
26 MaxWaiting = 8
27 // MaxAdmitted is the mesh cap, checked at every transition into
28 // the admitted state and never on a resume (spec §4.3).
29 MaxAdmitted = 4
30 // MaxStreamsPerPerson counts concurrent streams across all rooms.
31 MaxStreamsPerPerson = 4
32
33 // UnjoinedWindow is how long a participation lives without a join.
34 UnjoinedWindow = 30 * time.Second
35 // ResumeWindow is how long a detached participation keeps its
36 // admission waiting for the browser to come back (spec §4.5).
37 ResumeWindow = 30 * time.Second
38 // TombstoneLife is how long an ended peer id is remembered so a
39 // resume can be told why it ended (spec §4.5).
40 TombstoneLife = 10 * time.Minute
41 // RingSize is the replay ring: the last targeted messages held for
42 // one participation, for the resume window only (spec §4.5).
43 RingSize = 32
44)
45
46var (
47 // ErrRoomFull is the sixteen-participation cap of spec §4.2, which a
48 // handler answers with 503 before anything is allocated.
49 ErrRoomFull = errors.New("hub: room holds sixteen participations")
50 // ErrMeshFull is the mesh cap of four (spec §4.3), which is not a
51 // failed request: the peer has been sent full and may retry with
52 // join, so a handler answers 2xx and lets the event carry the news.
53 ErrMeshFull = errors.New("hub: four already admitted")
54 ErrSessionHasPeer = errors.New("hub: this session already has a peer in the room")
55 ErrPersonStreams = errors.New("hub: this person has four streams open")
56 ErrUnknownPeer = errors.New("hub: no such peer")
57 ErrNotYours = errors.New("hub: peer is not bound to this session")
58 ErrNotAdmitted = errors.New("hub: peer is not admitted")
59 ErrNotMember = errors.New("hub: peer is not a member")
60 ErrNotWaiting = errors.New("hub: target is not waiting")
61 ErrNotTargeted = errors.New("hub: not a targeted message type")
62 ErrStaleGen = errors.New("hub: stale generation")
63 ErrTooManyWaiting = errors.New("hub: eight already waiting")
64)
65
66type Config struct {
67 // WaitingRoom is read at join time, outside the lock. Nil preserves the
68 // hub’s original behavior: guests wait for admission.
69 WaitingRoom func(context.Context, string) (bool, error)
70 Now func() time.Time
71 Logger *slog.Logger
72 // StandingFor re-derives a person's kind and whether they still hold
73 // entry authority for the room. ok=false means standing none.
74 StandingFor func(ctx context.Context, room string, personID int64) (kind Kind, ok bool, err error)
75 // Knocked says that someone has begun waiting. It exists because the
76 // admission queue lives only here, in memory, and reaches members
77 // over their own streams — so a member who is not in the room has
78 // never had any way to learn that somebody is at the door.
79 //
80 // Called on a goroutine of its own, never under the lock: the app's
81 // implementation reads the database and talks to Apple, and the hub's
82 // mutex is held across every peer in every room. Nil is no
83 // notification, which is every test and every deployment without a
84 // push key.
85 Knocked func(room string, personIDs []int64, name string)
86}
87
88// Participation is the hub's record that a peer is in a room. It is the
89// authority for everything a peer does after entry (spec §2.4).
90type Participation struct {
91 Peer PeerID
92 Room string
93 Session string
94 PersonID int64
95 Name string
96 Kind Kind
97 Admitted bool
98 Joined bool // has posted join (waiting or admitted)
99 JoinedAt time.Time
100 OpenedAt time.Time
101 // Media is the peer's last report of its own microphone and camera
102 // (§15.19), nil until it makes one. It lives and dies with the
103 // participation, so a resume keeps it and a fresh join starts unknown.
104 Media *MediaState
105
106 sub *Sub
107 resumeUntil time.Time // zero while a stream is attached
108 seqOut map[PeerID]uint64 // next seq per target (unused by the hub; kept by browsers)
109 lastSeen map[PeerID]uint64
110 ring []Message // targeted messages delivered to this peer, newest last, cap RingSize
111 evicted map[PeerID]uint64 // highest seq per sender the ring has dropped
112 // peerTag and reportToken are spec §15.26's: the random tag this
113 // participation's reports are logged under, and the token its client
114 // reports with (report.go).
115 peerTag string
116 reportToken string
117}
118
119type pairKey struct{ a, b PeerID }
120
121func keyFor(x, y PeerID) pairKey {
122 if x < y {
123 return pairKey{x, y}
124 }
125 return pairKey{y, x}
126}
127
128type room struct {
129 sqid string
130 peers map[PeerID]*Participation
131 gens map[pairKey]uint64
132 ended bool
133 // call is the call instance now running here (report.go): replaced
134 // when a participation opens in a room that held nobody.
135 call *callInstance
136}
137
138// tombstone remembers why a participation ended, for ten minutes, so a
139// stream that never received its lost event learns the reason on resume
140// (spec §4.5). It carries the room and session it was bound to because
141// the reason is account state — removed, standing, expired — and a peer
142// id is published in rosters and is not a credential (spec §4.2).
143type tombstone struct {
144 room string
145 session string
146 reason Reason
147 at time.Time
148}
149
150type Hub struct {
151 cfg Config
152 boot string
153
154 mu sync.Mutex
155 rooms map[string]*room
156 bySession map[string]map[string]PeerID // room -> session -> peer
157 byPerson map[int64]int // open streams per person
158 tombstones map[PeerID]tombstone
159 reports map[string]*reportGrant // report token -> grant (report.go)
160}
161
162func New(cfg Config) *Hub {
163 if cfg.Now == nil {
164 cfg.Now = time.Now
165 }
166 if cfg.Logger == nil {
167 cfg.Logger = slog.Default()
168 }
169 return &Hub{
170 cfg: cfg, boot: randomID(),
171 rooms: map[string]*room{}, bySession: map[string]map[string]PeerID{},
172 byPerson: map[int64]int{}, tombstones: map[PeerID]tombstone{},
173 reports: map[string]*reportGrant{},
174 }
175}
176
177func randomID() string {
178 b := make([]byte, 16)
179 if _, err := rand.Read(b); err != nil {
180 panic(err)
181 }
182 return hex.EncodeToString(b)
183}
184
185// Boot is the random id chosen when the process started. Every hello
186// carries it so a browser can tell a restart from any other refusal
187// (spec §4.2).
188func (h *Hub) Boot() string { return h.boot }
189
190func (h *Hub) roomLocked(sqid string) *room {
191 r, ok := h.rooms[sqid]
192 if !ok {
193 r = &room{sqid: sqid, peers: map[PeerID]*Participation{}, gens: map[pairKey]uint64{}}
194 h.rooms[sqid] = r
195 }
196 return r
197}
198
199type OpenResult struct {
200 Peer PeerID
201 Sub *Sub
202 Existing PeerID
203 // ReportToken is the client's authority to send quality and problem
204 // reports (spec §15.26), carried in its hello.
205 ReportToken string
206}
207
208// Open is a fresh stream: a new unjoined participation, subject to the
209// room, session and person limits, all checked before allocation.
210func (h *Hub) Open(sqid, session string, personID int64, name string, kind Kind) (OpenResult, error) {
211 h.mu.Lock()
212 defer h.mu.Unlock()
213 r := h.rooms[sqid] // nil until this room holds someone
214 if r != nil && r.ended {
215 return OpenResult{}, ErrUnknownPeer
216 }
217 if existing, ok := h.bySession[sqid][session]; ok {
218 // A participation whose resume window has passed is not a
219 // conflict: §15.7 refuses its resume the moment the window
220 // passes, so answering 409 "open in another tab" until the tick
221 // caught up told a browser that had just been refused a resume
222 // that it was competing with itself (Codex S2-2). It is ended
223 // here, on the same terms the tick would have ended it on —
224 // nothing can be listening for the lost event, and the room is
225 // owed the departure.
226 if p := h.lapsedLocked(r, existing); p != nil {
227 wasAdmitted, wasWaiting := h.endLocked(r, p, ReasonBuffer)
228 delete(h.tombstones, p.Peer)
229 h.announceDepartureLocked(r, p, wasAdmitted, wasWaiting)
230 } else {
231 return OpenResult{Existing: existing}, ErrSessionHasPeer
232 }
233 }
234 if r != nil && len(r.peers) >= MaxParticipations {
235 return OpenResult{}, ErrRoomFull
236 }
237 if h.byPerson[personID] >= MaxStreamsPerPerson {
238 return OpenResult{}, ErrPersonStreams
239 }
240 r = h.roomLocked(sqid)
241 p := &Participation{
242 Peer: PeerID(randomID()), Room: sqid, Session: session, PersonID: personID, Name: name, Kind: kind,
243 OpenedAt: h.cfg.Now(), sub: newSub(), seqOut: map[PeerID]uint64{}, lastSeen: map[PeerID]uint64{},
244 }
245 h.enrolReportLocked(r, p) // before p joins r.peers: an empty room starts a new call
246
247 r.peers[p.Peer] = p
248 if h.bySession[sqid] == nil {
249 h.bySession[sqid] = map[string]PeerID{}
250 }
251 h.bySession[sqid][session] = p.Peer
252 h.byPerson[personID]++
253 return OpenResult{Peer: p.Peer, Sub: p.sub, ReportToken: p.reportToken}, nil
254}
255
256// Find answers a peer only through its own room and session.
257func (h *Hub) Find(sqid, session string, peer PeerID) (*Participation, bool) {
258 h.mu.Lock()
259 defer h.mu.Unlock()
260 p, ok := h.findLocked(sqid, session, peer)
261 return p, ok
262}
263
264func (h *Hub) findLocked(sqid, session string, peer PeerID) (*Participation, bool) {
265 r, ok := h.rooms[sqid]
266 if !ok {
267 return nil, false
268 }
269 p, ok := r.peers[peer]
270 if !ok || p.Session != session {
271 return nil, false
272 }
273 return p, true
274}
275
276// lapsedLocked is the participation this room holds under peer, when it
277// is detached and past its resume window — the state §15.7 has already
278// refused a resume in, and which only the tick used to clear. It is nil
279// for a live one, and for a peer this room does not hold.
280func (h *Hub) lapsedLocked(r *room, peer PeerID) *Participation {
281 if r == nil {
282 return nil
283 }
284 p, ok := r.peers[peer]
285 if !ok {
286 return nil
287 }
288 if p.sub != nil || p.resumeUntil.IsZero() || !h.cfg.Now().After(p.resumeUntil) {
289 return nil
290 }
291 return p
292}
293
294// LiveForSession names the peer this session holds in the room, whether
295// attached or within its resume window (spec §4.2).
296//
297// Past that window it holds nothing: the participation is still in the
298// hub until the tick, but a resume of it is already refused (§15.7) and
299// a fresh stream now ends it and takes its place (Open), so nothing that
300// asks "does this session hold a peer here" may still be told yes.
301func (h *Hub) LiveForSession(sqid, session string) (PeerID, bool) {
302 h.mu.Lock()
303 defer h.mu.Unlock()
304 id, ok := h.bySession[sqid][session]
305 if !ok || h.lapsedLocked(h.rooms[sqid], id) != nil {
306 return "", false
307 }
308 return id, true
309}
310
311// LiveKindForSession is LiveForSession plus the kind that participation
312// currently carries — the kind the last tick or the last request wrote,
313// never a fresh standing read, which is the app's to do.
314//
315// It exists for the page authority of spec §2.5: a live participation
316// stands in for standing only where §2.4 says the participation is now
317// the authority, and that is an admitted **guest** whose grant lapsed
318// (ruling 14). A member who lost the room is dropped by the next thing
319// they do, so their participation must not let them keep the page.
320func (h *Hub) LiveKindForSession(sqid, session string) (PeerID, Kind, bool) {
321 h.mu.Lock()
322 defer h.mu.Unlock()
323 id, ok := h.bySession[sqid][session]
324 if !ok || h.lapsedLocked(h.rooms[sqid], id) != nil {
325 return "", 0, false
326 }
327 p, ok := h.findLocked(sqid, session, id)
328 if !ok {
329 return "", 0, false
330 }
331 return id, p.Kind, true
332}
333
334// PeerNameFor answers target's display name exactly when the caller
335// (this session, in this room) could go on to remove it: an admitted
336// member participation, and target present in the same room and not
337// the caller's own. This is Remove's own authority (admission.go),
338// read here without mutating anything, so a confirm page can never
339// name — or offer to remove — a peer the caller could not actually
340// remove.
341func (h *Hub) PeerNameFor(sqid, session string, target PeerID) (string, bool) {
342 h.mu.Lock()
343 defer h.mu.Unlock()
344 actor, ok := h.bySession[sqid][session]
345 if !ok {
346 return "", false
347 }
348 a, ok := h.findLocked(sqid, session, actor)
349 if !ok || !a.Admitted || a.Kind != KindMember {
350 return "", false
351 }
352 r := h.rooms[sqid]
353 tp, ok := r.peers[target]
354 if !ok || tp.Peer == a.Peer {
355 return "", false
356 }
357 return tp.Name, true
358}
359
360// Detach starts the resume window for a participation whose stream
361// closed. Its subscriber is closed; a resume attaches a new one.
362//
363// sub is the subscriber the caller was reading, and the detach happens
364// only while that is still the participation's attachment. A stream
365// handler calls this on every exit, including the exit a Resume from a
366// second stream caused (lost{replaced}), and that displaced handler
367// must not detach the replacement that took its place. A nil sub
368// detaches whatever is attached, for a caller that holds no subscriber.
369func (h *Hub) Detach(sqid string, peer PeerID, sub *Sub) {
370 h.mu.Lock()
371 defer h.mu.Unlock()
372 r, ok := h.rooms[sqid]
373 if !ok {
374 return
375 }
376 p, ok := r.peers[peer]
377 if !ok {
378 return
379 }
380 if sub != nil && p.sub != sub {
381 return
382 }
383 if p.sub != nil {
384 p.sub.terminate(Message{Type: TypeLost, Reason: ReasonBuffer})
385 p.sub = nil
386 }
387 p.resumeUntil = h.cfg.Now().Add(ResumeWindow)
388}
389
390// endLocked removes a participation for a terminal reason, closes its
391// stream with lost{reason}, leaves a tombstone, and returns whether it
392// was admitted or waiting so the caller can announce the departure.
393func (h *Hub) endLocked(r *room, p *Participation, reason Reason) (wasAdmitted, wasWaiting bool) {
394 wasAdmitted, wasWaiting = p.Admitted, p.Joined && !p.Admitted
395 if p.sub != nil {
396 p.sub.terminate(Message{Type: TypeLost, Reason: reason})
397 p.sub = nil
398 }
399 delete(r.peers, p.Peer)
400 h.endReportLocked(p)
401 if h.bySession[r.sqid][p.Session] == p.Peer {
402 delete(h.bySession[r.sqid], p.Session)
403 }
404 if h.byPerson[p.PersonID] > 0 {
405 h.byPerson[p.PersonID]--
406 }
407 h.tombstones[p.Peer] = tombstone{room: r.sqid, session: p.Session, reason: reason, at: h.cfg.Now()}
408 for k := range r.gens {
409 if k.a == p.Peer || k.b == p.Peer {
410 delete(r.gens, k)
411 }
412 }
413 return
414}
415
416// standingCheck is one joined participation whose standing the tick must
417// re-read, and the answer it came back with.
418type standingCheck struct {
419 room string
420 peer PeerID
421 personID int64
422 kind Kind
423 ok bool
424}
425
426// Tick runs the fifteen-second housekeeping: unjoined and lapsed
427// participations are dropped, tombstones expire, and every joined
428// participation's kind is re-derived from standing, applying the
429// transition table of spec §2.4.
430func (h *Hub) Tick(ctx context.Context) {
431 h.mu.Lock()
432 now := h.cfg.Now()
433 for _, r := range h.rooms {
434 for _, p := range r.peers {
435 switch {
436 case !p.Joined && p.sub != nil && now.Sub(p.OpenedAt) > UnjoinedWindow:
437 h.endLocked(r, p, ReasonBuffer) // dropped quietly; the browser sees a closed stream
438 delete(h.tombstones, p.Peer)
439 case p.sub == nil && !p.resumeUntil.IsZero() && now.After(p.resumeUntil):
440 wasAdmitted, wasWaiting := h.endLocked(r, p, ReasonBuffer)
441 delete(h.tombstones, p.Peer)
442 h.announceDepartureLocked(r, p, wasAdmitted, wasWaiting)
443 }
444 }
445 }
446 for id, t := range h.tombstones {
447 if now.Sub(t.at) > TombstoneLife {
448 delete(h.tombstones, id)
449 }
450 }
451 h.sweepReportsLocked(now)
452 var checks []standingCheck
453 for _, r := range h.rooms {
454 for _, p := range r.peers {
455 if p.Joined {
456 checks = append(checks, standingCheck{room: r.sqid, peer: p.Peer, personID: p.PersonID})
457 }
458 }
459 }
460 h.mu.Unlock()
461
462 // Standing is a database read, so it happens with the lock down.
463 answered := make([]standingCheck, 0, len(checks))
464 for _, c := range checks {
465 kind, ok, err := h.cfg.StandingFor(ctx, c.room, c.personID)
466 if err != nil {
467 h.cfg.Logger.Warn("hub: standing lookup failed on tick",
468 "room", c.room, "peer", c.peer, "error", err)
469 continue // this participation waits for the next tick
470 }
471 c.kind, c.ok = kind, ok
472 answered = append(answered, c)
473 }
474
475 h.mu.Lock()
476 defer h.mu.Unlock()
477 for _, c := range answered {
478 r, found := h.rooms[c.room]
479 if !found {
480 continue
481 }
482 p, found := r.peers[c.peer]
483 if !found || !p.Joined {
484 continue // it ended while standing was being read
485 }
486 switch {
487 case !c.ok:
488 // A member loses the room, and so does a waiting guest whose
489 // grant lapsed; an admitted guest is untouched (ruling 14).
490 h.standingLostLocked(r, p)
491 case p.Admitted:
492 h.setKindLocked(r, p, c.kind) // a change refreshes the rosters
493 default:
494 // Waiting. A promotion to member is admission under the cap,
495 // exactly as an admit would be; a refusal leaves them waiting
496 // as a member, free to retry with join.
497 h.setKindLocked(r, p, c.kind)
498 if c.kind == KindMember && !h.admitLocked(r, p) {
499 h.sendLocked(p, Message{Type: TypeFull})
500 }
501 }
502 }
503}
504
505// announceDepartureLocked tells the room that a participation is gone,
506// in the shape its state earns: an admitted peer yields gone to admitted
507// peers, a waiting one yields knock-gone to admitted members, an
508// unjoined one yields nothing (spec §4.3).
509func (h *Hub) announceDepartureLocked(r *room, p *Participation, wasAdmitted, wasWaiting bool) {
510 switch {
511 case wasAdmitted:
512 h.broadcastLocked(r, Message{Type: TypeGone, From: p.Peer}, admittedPeers)
513 case wasWaiting:
514 h.broadcastLocked(r, Message{Type: TypeKnockGone, From: p.Peer}, admittedMembers)
515 }
516}
517
518type audience func(*Participation) bool
519
520func admittedPeers(p *Participation) bool { return p.Admitted }
521func admittedMembers(p *Participation) bool { return p.Admitted && p.Kind == KindMember }
522
523// sendLocked is one event to one participation, which is a no-op while
524// its stream is detached and waiting to resume.
525func (h *Hub) sendLocked(p *Participation, m Message) {
526 if p.sub != nil {
527 p.sub.send(m)
528 }
529}
530
531func (h *Hub) broadcastLocked(r *room, m Message, who audience) {
532 for _, q := range r.peers {
533 if who(q) && q.sub != nil {
534 q.sub.send(m)
535 }
536 }
537}
538
539// deliver is one event to one peer found by room and id, for the events
540// the hub raises about the room. A targeted message never comes this
541// way: Relay holds the recipient's participation already, because it
542// must also ring the message (see relay.go).
543func (h *Hub) deliver(sqid string, to PeerID, m Message) {
544 h.mu.Lock()
545 defer h.mu.Unlock()
546 h.deliverLocked(sqid, to, m)
547}
548
549func (h *Hub) deliverLocked(sqid string, to PeerID, m Message) {
550 r, ok := h.rooms[sqid]
551 if !ok {
552 return
553 }
554 p, ok := r.peers[to]
555 if !ok {
556 return
557 }
558 if p.sub != nil {
559 p.sub.send(m)
560 }
561}
562