oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hub
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "io"
8 "log/slog"
9 "sync"
10 "testing"
11 "time"
12)
13
14// rosterMedia finds one peer's media in a roster, and whether the peer
15// is in it at all.
16func rosterMedia(ro *Roster, peer PeerID) (*MediaState, bool) {
17 if ro == nil {
18 return nil, false
19 }
20 for _, rp := range ro.Peers {
21 if rp.Peer == peer {
22 return rp.Media, true
23 }
24 }
25 return nil, false
26}
27
28// TestSetMediaFansOutAChangeAndNothingElse is §15.19's delivery: every
29// other admitted peer hears a change, in an envelope the hub built, and
30// nobody else hears anything — not the sender, not a waiting guest, and
31// nothing in any ring. A repeat of the same state is accepted and silent.
32func TestSetMediaFansOutAChangeAndNothingElse(t *testing.T) {
33 now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
34 h := testHub(&now)
35 ctx := context.Background()
36 a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
37 h.Join(ctx, "r1", "sa", a.Peer)
38 b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
39 h.Join(ctx, "r1", "sb", b.Peer)
40 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
41 h.Join(ctx, "r1", "sg", g.Peer) // waiting
42 drain(a.Sub)
43 drain(b.Sub)
44 drain(g.Sub)
45
46 if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Muted: true}); err != nil {
47 t.Fatal(err)
48 }
49 got := drain(b.Sub)
50 if len(got) != 1 {
51 t.Fatalf("Bob got %d events, want 1: %v", len(got), types(got))
52 }
53 m := got[0]
54 if m.Type != TypeMedia || m.From != a.Peer || m.Name != "Ann" {
55 t.Fatalf("Bob got %+v, want media from Ann", m)
56 }
57 if string(m.Payload) != `{"muted":true,"cameraOff":false}` {
58 t.Fatalf("payload %s, want the canonical two booleans", m.Payload)
59 }
60 if m.To != "" || m.Seq != 0 || m.Gen != 0 || m.Roster != nil {
61 t.Fatalf("Bob got %+v, want nothing but type, from, name and payload", m)
62 }
63 if n := len(drain(a.Sub)); n != 0 {
64 t.Fatalf("the sender must not hear its own report, got %d", n)
65 }
66 if n := len(drain(g.Sub)); n != 0 {
67 t.Fatalf("a waiting guest must hear nothing, got %d", n)
68 }
69 if n := ringLen(h, "r1", "sb", b.Peer); n != 0 {
70 t.Fatalf("media is state, never ringed; ring has %d", n)
71 }
72
73 // The same state again: accepted, and the room hears nothing.
74 if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Muted: true}); err != nil {
75 t.Fatal(err)
76 }
77 if n := len(drain(b.Sub)); n != 0 {
78 t.Fatalf("a report that changes nothing must send nothing, got %d", n)
79 }
80 // A different state is a change again.
81 if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Muted: true, CameraOff: true}); err != nil {
82 t.Fatal(err)
83 }
84 got = drain(b.Sub)
85 if len(got) != 1 || string(got[0].Payload) != `{"muted":true,"cameraOff":true}` {
86 t.Fatalf("camera off should be one more media event, got %v", got)
87 }
88}
89
90// TestRostersCarryMediaAndUnknownIsAbsent is the "state, not event"
91// half: a peer admitted after the report learns it from its roster, the
92// room learns a newcomer's from the peer event once it has one, and a
93// peer that has never reported — every client older than §15.19 — has
94// no media at all rather than a guessed false.
95func TestRostersCarryMediaAndUnknownIsAbsent(t *testing.T) {
96 now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
97 h := testHub(&now)
98 ctx := context.Background()
99 a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
100 h.Join(ctx, "r1", "sa", a.Peer)
101 b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
102 h.Join(ctx, "r1", "sb", b.Peer) // never reports: an old client
103 if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Muted: true}); err != nil {
104 t.Fatal(err)
105 }
106 drain(a.Sub)
107 drain(b.Sub)
108
109 c, _ := h.Open("r1", "sc", 3, "Cy", KindMember)
110 h.Join(ctx, "r1", "sc", c.Peer)
111 cev := drain(c.Sub)
112 var ro *Roster
113 for _, m := range cev {
114 if m.Type == TypeRoster {
115 ro = m.Roster
116 }
117 }
118 am, ok := rosterMedia(ro, a.Peer)
119 if !ok || am == nil || !am.Muted || am.CameraOff {
120 t.Fatalf("Cy's roster should say Ann is muted, got %+v (present %v)", am, ok)
121 }
122 bm, ok := rosterMedia(ro, b.Peer)
123 if !ok || bm != nil {
124 t.Fatalf("Bob never reported, so his roster entry must carry no media, got %+v (present %v)", bm, ok)
125 }
126 // And on the wire, absent rather than a zero object: that is what
127 // an old client ignores and a new one reads as unknown.
128 raw, _ := json.Marshal(RosterPeer{Peer: b.Peer, Name: "Bob", Kind: "member"})
129 if string(raw) != `{"peer":"`+string(b.Peer)+`","name":"Bob","kind":"member"}` {
130 t.Fatalf("an unreported peer marshals as %s, want no media key", raw)
131 }
132
133 // The newcomer's own first report reaches everyone already there,
134 // the old client included: it ignores the type, and it is not the
135 // hub's business to know that.
136 drain(a.Sub)
137 drain(b.Sub)
138 if err := h.SetMedia(ctx, "r1", "sc", c.Peer, MediaState{CameraOff: true}); err != nil {
139 t.Fatal(err)
140 }
141 for _, s := range []*Sub{a.Sub, b.Sub} {
142 ev := drain(s)
143 if len(ev) != 1 || ev[0].Type != TypeMedia || ev[0].From != c.Peer {
144 t.Fatalf("Cy's report should reach both others once, got %v", types(ev))
145 }
146 }
147}
148
149// TestPeerEventCarriesMediaFromTheParticipation: the `peer` event is
150// built from the participation, like the roster, so whatever media the
151// participation holds at admission goes with it. Today a report needs
152// admission first, so this is nil in practice — the test pins the
153// contract so that loosening that rule later cannot forget the event.
154func TestPeerEventCarriesMediaFromTheParticipation(t *testing.T) {
155 now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
156 h := testHub(&now)
157 ctx := context.Background()
158 a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
159 h.Join(ctx, "r1", "sa", a.Peer)
160 drain(a.Sub)
161 b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
162 // Bob's media is set directly under the lock, as if he had reported
163 // before this admission; the hub's own paths only allow a report once
164 // admitted, so this is the peer event's contract, not a flow.
165 h.mu.Lock()
166 p, _ := h.findLocked("r1", "sb", b.Peer)
167 p.Media = &MediaState{Muted: true}
168 h.mu.Unlock()
169 h.Join(ctx, "r1", "sb", b.Peer)
170 ev := drain(a.Sub)
171 if len(ev) != 1 || ev[0].Type != TypePeer {
172 t.Fatalf("Ann should get one peer event, got %v", types(ev))
173 }
174 bm, ok := rosterMedia(ev[0].Roster, b.Peer)
175 if !ok || bm == nil || !bm.Muted {
176 t.Fatalf("the peer event should carry Bob's media, got %+v", bm)
177 }
178}
179
180// TestResumeRosterCarriesMediaMissedWhileDetached: a change made while a
181// peer's stream is down is not ringed and not replayed, and it does not
182// need to be — the roster the resume is answered with says it.
183func TestResumeRosterCarriesMediaMissedWhileDetached(t *testing.T) {
184 now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
185 h := testHub(&now)
186 ctx := context.Background()
187 a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
188 h.Join(ctx, "r1", "sa", a.Peer)
189 b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
190 h.Join(ctx, "r1", "sb", b.Peer)
191 if err := h.SetMedia(ctx, "r1", "sb", b.Peer, MediaState{Muted: true}); err != nil {
192 t.Fatal(err)
193 }
194 drain(a.Sub)
195 drain(b.Sub)
196
197 h.Detach("r1", b.Peer, b.Sub)
198 if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Muted: true}); err != nil {
199 t.Fatal(err)
200 }
201 if n := ringLen(h, "r1", "sb", b.Peer); n != 0 {
202 t.Fatalf("a media report to a detached peer must not be ringed, ring has %d", n)
203 }
204 res, err := h.Resume(ctx, "r1", "sb", b.Peer, map[PeerID]uint64{})
205 if err != nil {
206 t.Fatal(err)
207 }
208 if len(res.Replay) != 0 || len(res.Gap) != 0 {
209 t.Fatalf("nothing to replay and no gap, got %d replayed and %d gaps", len(res.Replay), len(res.Gap))
210 }
211 am, _ := rosterMedia(res.Roster, a.Peer)
212 if am == nil || !am.Muted {
213 t.Fatalf("the resume roster should say Ann is muted, got %+v", am)
214 }
215 // Bob's own state survived his detach: the participation holds it.
216 bm, _ := rosterMedia(res.Roster, b.Peer)
217 if bm == nil || !bm.Muted {
218 t.Fatalf("Bob's own report should survive his resume, got %+v", bm)
219 }
220
221 // A leave ends the participation, and with it the state: a fresh
222 // join starts unknown.
223 if err := h.Leave("r1", "sb", b.Peer); err != nil {
224 t.Fatal(err)
225 }
226 b2, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
227 h.Join(ctx, "r1", "sb", b2.Peer)
228 ev := drain(a.Sub)
229 var fresh *Roster
230 for _, m := range ev {
231 if m.Type == TypePeer && m.Peer == b2.Peer {
232 fresh = m.Roster
233 }
234 }
235 if bm, ok := rosterMedia(fresh, b2.Peer); !ok || bm != nil {
236 t.Fatalf("a rejoin must start with no media, got %+v (present %v)", bm, ok)
237 }
238}
239
240// TestSetMediaAuthority is Fanout's authority, reused: bound to this
241// room and session, admitted, and a member's standing re-derived.
242func TestSetMediaAuthority(t *testing.T) {
243 now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
244 standing := map[int64]bool{1: true, 2: true, 100: true}
245 h := New(Config{
246 Now: func() time.Time { return now },
247 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
248 StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) {
249 if id >= 100 {
250 return KindGuest, standing[id], nil
251 }
252 return KindMember, standing[id], nil
253 },
254 })
255 ctx := context.Background()
256 m := MediaState{Muted: true}
257 a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
258 if err := h.SetMedia(ctx, "r1", "sa", a.Peer, m); !errors.Is(err, ErrNotAdmitted) {
259 t.Fatalf("unjoined: %v", err)
260 }
261 h.Join(ctx, "r1", "sa", a.Peer)
262 if err := h.SetMedia(ctx, "r1", "sb", a.Peer, m); !errors.Is(err, ErrUnknownPeer) {
263 t.Fatalf("another session: %v", err)
264 }
265 if err := h.SetMedia(ctx, "r2", "sa", a.Peer, m); !errors.Is(err, ErrUnknownPeer) {
266 t.Fatalf("another room: %v", err)
267 }
268 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
269 h.Join(ctx, "r1", "sg", g.Peer)
270 if err := h.SetMedia(ctx, "r1", "sg", g.Peer, m); !errors.Is(err, ErrNotAdmitted) {
271 t.Fatalf("waiting guest: %v", err)
272 }
273 b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
274 h.Join(ctx, "r1", "sb", b.Peer)
275 drain(a.Sub)
276 drain(b.Sub)
277
278 standing[1] = false
279 if err := h.SetMedia(ctx, "r1", "sa", a.Peer, m); !errors.Is(err, ErrNotYours) {
280 t.Fatalf("member whose standing is gone: %v", err)
281 }
282 ev := drain(b.Sub)
283 if len(ev) != 1 || ev[0].Type != TypeGone {
284 t.Fatalf("Bob should see Ann gone and no media, got %v", types(ev))
285 }
286}
287
288// TestSetMediaUnderConcurrency is for `make race`: reports, resumes,
289// rosters and a tick all at once, over the same participations. Media
290// is a pointer handed to rosters that are serialised on other
291// goroutines, so a write through it anywhere would show up here.
292func TestSetMediaUnderConcurrency(t *testing.T) {
293 now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
294 var clock sync.Mutex
295 h := New(Config{
296 Now: func() time.Time {
297 clock.Lock()
298 defer clock.Unlock()
299 return now
300 },
301 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
302 StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) {
303 return KindMember, true, nil
304 },
305 })
306 ctx := context.Background()
307 a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
308 h.Join(ctx, "r1", "sa", a.Peer)
309 b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
310 h.Join(ctx, "r1", "sb", b.Peer)
311
312 var wg sync.WaitGroup
313 stop := make(chan struct{})
314 // Readers: marshal whatever arrives, the way the stream handler does.
315 for _, s := range []*Sub{a.Sub, b.Sub} {
316 wg.Add(1)
317 go func(s *Sub) {
318 defer wg.Done()
319 for {
320 select {
321 case <-stop:
322 return
323 case m, ok := <-s.C():
324 if !ok {
325 return
326 }
327 _, _ = json.Marshal(m)
328 }
329 }
330 }(s)
331 }
332 var writers sync.WaitGroup
333 for i, who := range []struct {
334 session string
335 peer PeerID
336 }{{"sa", a.Peer}, {"sb", b.Peer}} {
337 writers.Add(1)
338 go func(i int, session string, peer PeerID) {
339 defer writers.Done()
340 for n := 0; n < 200; n++ {
341 _ = h.SetMedia(ctx, "r1", session, peer, MediaState{Muted: n%2 == 0, CameraOff: (n+i)%3 == 0})
342 }
343 }(i, who.session, who.peer)
344 }
345 writers.Add(1)
346 go func() {
347 defer writers.Done()
348 for n := 0; n < 50; n++ {
349 h.Tick(ctx)
350 c, err := h.Open("r1", "sc", 3, "Cy", KindMember)
351 if err == nil {
352 _ = h.Join(ctx, "r1", "sc", c.Peer)
353 _, _ = json.Marshal(drain(c.Sub))
354 _ = h.Leave("r1", "sc", c.Peer)
355 }
356 }
357 }()
358 writers.Wait()
359 close(stop)
360 wg.Wait()
361}
362
363// TestSetMediaCarriesTheReceivingHalf is §15.20 in the hub: the report's
364// optional members are part of the state — a change to either is fanned
365// out, the same list again is silent, and the hub keeps its own copy of
366// the list so a caller that reuses its slice cannot rewrite a roster
367// already sent.
368func TestSetMediaCarriesTheReceivingHalf(t *testing.T) {
369 now := time.Date(2026, 9, 25, 9, 0, 0, 0, time.UTC)
370 h := testHub(&now)
371 ctx := context.Background()
372 a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
373 h.Join(ctx, "r1", "sa", a.Peer)
374 b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
375 h.Join(ctx, "r1", "sb", b.Peer)
376 drain(a.Sub)
377 drain(b.Sub)
378
379 list := []PeerID{b.Peer}
380 if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{AudioBlocked: true, NoSoundFrom: list}); err != nil {
381 t.Fatal(err)
382 }
383 got := drain(b.Sub)
384 if len(got) != 1 {
385 t.Fatalf("Bob got %d events, want 1", len(got))
386 }
387 want := `{"muted":false,"cameraOff":false,"audioBlocked":true,"noSoundFrom":["` + string(b.Peer) + `"]}`
388 if string(got[0].Payload) != want {
389 t.Fatalf("payload %s, want %s", got[0].Payload, want)
390 }
391 list[0] = "rewritten"
392 c, _ := h.Open("r1", "sc", 3, "Cy", KindMember)
393 h.Join(ctx, "r1", "sc", c.Peer)
394 var ro *Roster
395 for _, ev := range drain(c.Sub) {
396 if ev.Type == TypeRoster {
397 ro = ev.Roster
398 }
399 }
400 drain(b.Sub)
401 m, _ := rosterMedia(ro, a.Peer)
402 if m == nil || len(m.NoSoundFrom) != 1 || m.NoSoundFrom[0] != b.Peer {
403 t.Fatalf("roster media %+v, want the hub's own copy of the list", m)
404 }
405
406 if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{AudioBlocked: true, NoSoundFrom: []PeerID{b.Peer}}); err != nil {
407 t.Fatal(err)
408 }
409 if got := drain(b.Sub); len(got) != 0 {
410 t.Fatalf("the same report again reached Bob: %v", types(got))
411 }
412 if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{}); err != nil {
413 t.Fatal(err)
414 }
415 got = drain(b.Sub)
416 if len(got) != 1 || string(got[0].Payload) != `{"muted":false,"cameraOff":false}` {
417 t.Fatalf("clearing the bad news sent %v", got)
418 }
419}
420
421// §15.24: hearing is kept with its emptiness — [] is "nobody yet", nil is
422// a client that does not say — carried after the other lists, copied so
423// nothing the caller holds is shared with a roster, and a change between
424// nil and [] is a change.
425func TestSetMediaCarriesHearing(t *testing.T) {
426 now := time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)
427 h := testHub(&now)
428 ctx := context.Background()
429 a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
430 h.Join(ctx, "r1", "sa", a.Peer)
431 b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
432 h.Join(ctx, "r1", "sb", b.Peer)
433 drain(a.Sub)
434 drain(b.Sub)
435
436 if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Hearing: []PeerID{}}); err != nil {
437 t.Fatal(err)
438 }
439 got := drain(b.Sub)
440 if len(got) != 1 || string(got[0].Payload) != `{"muted":false,"cameraOff":false,"hearing":[]}` {
441 t.Fatalf("empty hearing sent %v", got)
442 }
443 list := []PeerID{b.Peer}
444 if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{ScreenStalled: list, Hearing: list}); err != nil {
445 t.Fatal(err)
446 }
447 got = drain(b.Sub)
448 want := `{"muted":false,"cameraOff":false,"screenStalled":["` + string(b.Peer) + `"],"hearing":["` + string(b.Peer) + `"]}`
449 if len(got) != 1 || string(got[0].Payload) != want {
450 t.Fatalf("payload %v, want %s", got, want)
451 }
452 list[0] = "rewritten"
453 c, _ := h.Open("r1", "sc", 3, "Cy", KindMember)
454 h.Join(ctx, "r1", "sc", c.Peer)
455 var ro *Roster
456 for _, ev := range drain(c.Sub) {
457 if ev.Type == TypeRoster {
458 ro = ev.Roster
459 }
460 }
461 drain(b.Sub)
462 m, _ := rosterMedia(ro, a.Peer)
463 if m == nil || len(m.Hearing) != 1 || m.Hearing[0] != b.Peer {
464 t.Fatalf("roster media %+v, want the hub's own copy of the list", m)
465 }
466 // Back to []: a change. Then nil: a change again, and the two-key report.
467 if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Hearing: []PeerID{}}); err != nil {
468 t.Fatal(err)
469 }
470 if got := drain(b.Sub); len(got) != 1 {
471 t.Fatalf("back to empty sent %d", len(got))
472 }
473 if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{}); err != nil {
474 t.Fatal(err)
475 }
476 got = drain(b.Sub)
477 if len(got) != 1 || string(got[0].Payload) != `{"muted":false,"cameraOff":false}` {
478 t.Fatalf("nil after [] sent %v", got)
479 }
480 if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{}); err != nil {
481 t.Fatal(err)
482 }
483 if got := drain(b.Sub); len(got) != 0 {
484 t.Fatalf("the same report again reached Bob: %v", types(got))
485 }
486 var round MediaState
487 if err := json.Unmarshal([]byte(`{"muted":false,"cameraOff":false,"hearing":[]}`), &round); err != nil || round.Hearing == nil {
488 t.Fatalf("[] must decode as an empty, non-nil list: %+v %v", round, err)
489 }
490}
491