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 "fmt"
6 "sync"
7 "testing"
8 "time"
9)
10
11// drain returns every event currently buffered on a sub, non-blocking.
12func drain(s *Sub) []Message {
13 var out []Message
14 for {
15 select {
16 case m, ok := <-s.C():
17 if !ok {
18 return out
19 }
20 out = append(out, m)
21 default:
22 return out
23 }
24 }
25}
26
27func types(ms []Message) []string {
28 out := make([]string, len(ms))
29 for i, m := range ms {
30 out[i] = m.Type
31 }
32 return out
33}
34
35func TestMemberJoinsAndGuestWaits(t *testing.T) {
36 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
37 h := testHub(&now)
38 ctx := context.Background()
39 m, _ := h.Open("r1", "sm", 1, "Mia", KindMember)
40 if err := h.Join(ctx, "r1", "sm", m.Peer); err != nil {
41 t.Fatal(err)
42 }
43 ev := drain(m.Sub)
44 if len(ev) != 1 || ev[0].Type != TypeRoster || len(ev[0].Roster.Peers) != 1 {
45 t.Fatalf("member join events %v", types(ev))
46 }
47 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
48 if err := h.Join(ctx, "r1", "sg", g.Peer); err != nil {
49 t.Fatal(err)
50 }
51 if ev := drain(g.Sub); len(ev) != 0 {
52 t.Fatalf("waiting guest received %v", types(ev))
53 }
54 ev = drain(m.Sub)
55 if len(ev) != 1 || ev[0].Type != TypeKnock || ev[0].From != g.Peer || ev[0].Name != "Gus" {
56 t.Fatalf("member did not get the knock: %v", types(ev))
57 }
58}
59
60func TestAdmitSendsRosterToGuestPeerToOthersAndKnockGone(t *testing.T) {
61 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
62 h := testHub(&now)
63 ctx := context.Background()
64 m, _ := h.Open("r1", "sm", 1, "Mia", KindMember)
65 h.Join(ctx, "r1", "sm", m.Peer)
66 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
67 h.Join(ctx, "r1", "sg", g.Peer)
68 drain(m.Sub)
69 if err := h.Admit(ctx, "r1", "sm", m.Peer, g.Peer); err != nil {
70 t.Fatal(err)
71 }
72 gev := drain(g.Sub)
73 if len(gev) != 1 || gev[0].Type != TypeRoster || len(gev[0].Roster.Peers) != 2 || gev[0].Roster.Waiting != nil {
74 t.Fatalf("guest got %v (waiting queue must not reach a guest)", types(gev))
75 }
76 mev := types(drain(m.Sub))
77 if len(mev) != 2 || mev[0] != TypePeer || mev[1] != TypeKnockGone {
78 t.Fatalf("member got %v", mev)
79 }
80}
81
82func TestOnlyAnAdmittedMemberMayAdmit(t *testing.T) {
83 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
84 h := testHub(&now)
85 ctx := context.Background()
86 m, _ := h.Open("r1", "sm", 1, "Mia", KindMember)
87 h.Join(ctx, "r1", "sm", m.Peer)
88 g1, _ := h.Open("r1", "s1", 100, "G1", KindGuest)
89 h.Join(ctx, "r1", "s1", g1.Peer)
90 g2, _ := h.Open("r1", "s2", 101, "G2", KindGuest)
91 h.Join(ctx, "r1", "s2", g2.Peer)
92 h.Admit(ctx, "r1", "sm", m.Peer, g1.Peer)
93 if err := h.Admit(ctx, "r1", "s1", g1.Peer, g2.Peer); err != ErrNotMember {
94 t.Fatalf("admitted guest admitting: %v", err)
95 }
96 if err := h.Admit(ctx, "r1", "s2", g2.Peer, g2.Peer); err != ErrNotAdmitted {
97 t.Fatalf("waiting guest admitting: %v", err)
98 }
99}
100
101// Task 5 review Important 3: Admit must settle the actor's own
102// authority completely before it ever looks at the target, so a
103// participation with no admitting authority cannot use the shape of the
104// refusal to learn whether a candidate peer id exists in the room, and
105// the target's StandingFor is never spent on an actor that had no
106// business asking.
107func TestAdmitByAWaitingGuestNeverLooksAtTheTargetOrItsStanding(t *testing.T) {
108 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
109 var calls int
110 h := New(Config{Now: func() time.Time { return now }, StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) {
111 calls++
112 if id < 100 {
113 return KindMember, true, nil
114 }
115 return KindGuest, true, nil
116 }})
117 ctx := context.Background()
118 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
119 h.Join(ctx, "r1", "sg", g.Peer) // waiting, never admitted
120
121 calls = 0
122 errAbsent := h.Admit(ctx, "r1", "sg", g.Peer, "0123456789abcdef0123456789abcdef")
123 callsForAbsent := calls
124
125 m, _ := h.Open("r1", "sm", 1, "Mia", KindMember)
126 h.Join(ctx, "r1", "sm", m.Peer) // present, and admitted immediately as a member
127
128 calls = 0
129 errPresent := h.Admit(ctx, "r1", "sg", g.Peer, m.Peer)
130 callsForPresent := calls
131
132 if errAbsent != ErrNotAdmitted || errPresent != ErrNotAdmitted {
133 t.Fatalf("waiting guest's admit: absent target %v, present target %v, want ErrNotAdmitted both times", errAbsent, errPresent)
134 }
135 if callsForAbsent != 1 || callsForPresent != 1 {
136 t.Fatalf("StandingFor calls: absent target %d, present target %d, want 1 both times (the actor's own — the target is never reached)", callsForAbsent, callsForPresent)
137 }
138
139 // The same short-circuit, one step later: an *admitted* guest is
140 // past the !a.Admitted check but still fails a.Kind != KindMember,
141 // so the target is still never reached (review Task 5 fix round 2
142 // item 6).
143 if err := h.Admit(ctx, "r1", "sm", m.Peer, g.Peer); err != nil {
144 t.Fatalf("member admitting the waiting guest: %v", err)
145 }
146
147 calls = 0
148 errAbsentAdmitted := h.Admit(ctx, "r1", "sg", g.Peer, "0123456789abcdef0123456789abcdef")
149 callsForAbsentAdmitted := calls
150
151 calls = 0
152 errPresentAdmitted := h.Admit(ctx, "r1", "sg", g.Peer, m.Peer)
153 callsForPresentAdmitted := calls
154
155 if errAbsentAdmitted != ErrNotMember || errPresentAdmitted != ErrNotMember {
156 t.Fatalf("admitted guest's admit: absent target %v, present target %v, want ErrNotMember both times", errAbsentAdmitted, errPresentAdmitted)
157 }
158 if callsForAbsentAdmitted != 1 || callsForPresentAdmitted != 1 {
159 t.Fatalf("StandingFor calls (admitted guest actor): absent target %d, present target %d, want 1 both times (the actor's own — the target is never reached)", callsForAbsentAdmitted, callsForPresentAdmitted)
160 }
161}
162
163func TestCapAtJoinAndAtAdmit(t *testing.T) {
164 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
165 h := testHub(&now)
166 ctx := context.Background()
167 var members []OpenResult
168 for i := 0; i < 5; i++ {
169 o, _ := h.Open("r1", "s"+string(rune('a'+i)), int64(1+i), "M", KindMember)
170 members = append(members, o)
171 h.Join(ctx, "r1", "s"+string(rune('a'+i)), o.Peer)
172 }
173 fifth := drain(members[4].Sub)
174 if len(fifth) != 1 || fifth[0].Type != TypeFull {
175 t.Fatalf("fifth member got %v, want full", types(fifth))
176 }
177 // One leaves; four queued guests and the freed slot yield one admission.
178 h.Leave("r1", "sd", members[3].Peer)
179 var guests []OpenResult
180 for i := 0; i < 4; i++ {
181 o, _ := h.Open("r1", "g"+string(rune('a'+i)), int64(100+i), "G", KindGuest)
182 guests = append(guests, o)
183 h.Join(ctx, "r1", "g"+string(rune('a'+i)), o.Peer)
184 }
185 drain(members[0].Sub)
186 if err := h.Admit(ctx, "r1", "sa", members[0].Peer, guests[0].Peer); err != nil {
187 t.Fatal(err)
188 }
189 if err := h.Admit(ctx, "r1", "sa", members[0].Peer, guests[1].Peer); err != nil {
190 t.Fatal(err) // Admit itself returns nil; the outcome is the `full` event
191 }
192 ev := types(drain(members[0].Sub))
193 if ev[len(ev)-1] != TypeFull {
194 t.Fatalf("second admit over the cap: %v, want full last", ev)
195 }
196 if p, _ := h.Find("r1", "gb", guests[1].Peer); p.Admitted {
197 t.Fatal("guest admitted over the cap")
198 }
199}
200
201func TestEightWaitingThenFull(t *testing.T) {
202 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
203 h := testHub(&now)
204 ctx := context.Background()
205 for i := 0; i < 8; i++ {
206 o, _ := h.Open("r1", "g"+string(rune('a'+i)), int64(100+i), "G", KindGuest)
207 h.Join(ctx, "r1", "g"+string(rune('a'+i)), o.Peer)
208 }
209 o, _ := h.Open("r1", "gz", 200, "G", KindGuest)
210 if err := h.Join(ctx, "r1", "gz", o.Peer); err != ErrTooManyWaiting {
211 t.Fatalf("ninth waiting: %v", err)
212 }
213 if ev := drain(o.Sub); len(ev) != 1 || ev[0].Type != TypeFull {
214 t.Fatalf("ninth got %v", types(ev))
215 }
216}
217
218func TestGuestFirstArrivalIsInTheMembersRoster(t *testing.T) {
219 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
220 h := testHub(&now)
221 ctx := context.Background()
222 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
223 h.Join(ctx, "r1", "sg", g.Peer)
224 m, _ := h.Open("r1", "sm", 1, "Mia", KindMember)
225 h.Join(ctx, "r1", "sm", m.Peer)
226 ev := drain(m.Sub)
227 if len(ev) != 1 || ev[0].Type != TypeRoster || len(ev[0].Roster.Waiting) != 1 || ev[0].Roster.Waiting[0].Peer != g.Peer {
228 t.Fatalf("member roster %+v", ev)
229 }
230}
231
232func TestAdmitRefusesAnExpiredGrantBeforeTheTick(t *testing.T) {
233 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
234 authority := map[int64]bool{100: true}
235 h := New(Config{Now: func() time.Time { return now }, StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) {
236 if id < 100 {
237 return KindMember, true, nil
238 }
239 return KindGuest, authority[id], nil
240 }})
241 ctx := context.Background()
242 m, _ := h.Open("r1", "sm", 1, "Mia", KindMember)
243 h.Join(ctx, "r1", "sm", m.Peer)
244 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
245 h.Join(ctx, "r1", "sg", g.Peer)
246 authority[100] = false // grant expired, no tick yet
247 drain(m.Sub)
248 if err := h.Admit(ctx, "r1", "sm", m.Peer, g.Peer); err != nil {
249 t.Fatal(err)
250 }
251 if p, ok := h.Find("r1", "sg", g.Peer); ok && p.Admitted {
252 t.Fatal("expired guest was admitted")
253 }
254 gev := drain(g.Sub)
255 if len(gev) == 0 || gev[len(gev)-1].Type != TypeLost || gev[len(gev)-1].Reason != ReasonExpired {
256 t.Fatalf("expired guest got %v", types(gev))
257 }
258 if ev := types(drain(m.Sub)); len(ev) != 1 || ev[0] != TypeKnockGone {
259 t.Fatalf("member got %v", ev)
260 }
261}
262
263func TestTickDropsAMemberWhoLostStandingAndPromotesAWaitingGuest(t *testing.T) {
264 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
265 kinds := map[int64]Kind{1: KindMember, 100: KindGuest}
266 ok := map[int64]bool{1: true, 100: true}
267 h := New(Config{Now: func() time.Time { return now }, StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) {
268 return kinds[id], ok[id], nil
269 }})
270 ctx := context.Background()
271 m, _ := h.Open("r1", "sm", 1, "Mia", KindMember)
272 h.Join(ctx, "r1", "sm", m.Peer)
273 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
274 h.Join(ctx, "r1", "sg", g.Peer)
275 drain(m.Sub)
276 kinds[100] = KindMember // promoted while waiting
277 h.Tick(ctx)
278 if p, _ := h.Find("r1", "sg", g.Peer); !p.Admitted || p.Kind != KindMember {
279 t.Fatal("waiting guest promoted to member was not admitted on the tick")
280 }
281 ok[1] = false // member removed from the allowlist
282 drain(g.Sub)
283 h.Tick(ctx)
284 if _, found := h.Find("r1", "sm", m.Peer); found {
285 t.Fatal("removed member still in the room")
286 }
287 mev := drain(m.Sub)
288 if len(mev) == 0 || mev[len(mev)-1].Reason != ReasonStanding {
289 t.Fatalf("removed member got %v", types(mev))
290 }
291 if ev := types(drain(g.Sub)); len(ev) == 0 || ev[len(ev)-1] != TypeGone {
292 t.Fatalf("remaining peer got %v, want gone last", ev)
293 }
294}
295
296// An admitted guest is never ended by their grant expiring: the
297// participation is the authority now (spec §2.4, ruling 14).
298func TestJoinKeepsAnAdmittedGuestWhoseGrantExpired(t *testing.T) {
299 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
300 authority := map[int64]bool{100: true}
301 h := New(Config{Now: func() time.Time { return now }, StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) {
302 if id < 100 {
303 return KindMember, true, nil
304 }
305 return KindGuest, authority[id], nil
306 }})
307 ctx := context.Background()
308 m, _ := h.Open("r1", "sm", 1, "Mia", KindMember)
309 h.Join(ctx, "r1", "sm", m.Peer)
310 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
311 h.Join(ctx, "r1", "sg", g.Peer)
312 h.Admit(ctx, "r1", "sm", m.Peer, g.Peer)
313 drain(m.Sub)
314 drain(g.Sub)
315 now = now.Add(2 * time.Hour)
316 authority[100] = false // the two-hour grant lapsed mid-call
317 if err := h.Join(ctx, "r1", "sg", g.Peer); err != nil {
318 t.Fatalf("admitted guest re-posting join after expiry: %v", err)
319 }
320 if p, found := h.Find("r1", "sg", g.Peer); !found || !p.Admitted {
321 t.Fatal("an admitted guest was ended by their grant expiring")
322 }
323 if ev := types(drain(m.Sub)); len(ev) != 0 {
324 t.Fatalf("the room was told of a departure that did not happen: %v", ev)
325 }
326 if ev := types(drain(g.Sub)); len(ev) != 0 {
327 t.Fatalf("the admitted guest got %v", ev)
328 }
329 h.Tick(ctx) // and the tick agrees with the request path
330 if p, found := h.Find("r1", "sg", g.Peer); !found || !p.Admitted {
331 t.Fatal("the tick ended an admitted guest whose grant expired")
332 }
333}
334
335// A guest the tick promoted to member while the room was full waits on
336// as a member and may retry with join (spec §2.4).
337func TestAWaitingMemberMayRetryJoin(t *testing.T) {
338 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
339 kinds := map[int64]Kind{100: KindGuest}
340 h := New(Config{Now: func() time.Time { return now }, StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) {
341 if id < 100 {
342 return KindMember, true, nil
343 }
344 return kinds[id], true, nil
345 }})
346 ctx := context.Background()
347 var members []OpenResult
348 for i := 0; i < MaxAdmitted; i++ {
349 o, _ := h.Open("r1", "m"+string(rune('a'+i)), int64(1+i), "M", KindMember)
350 members = append(members, o)
351 h.Join(ctx, "r1", "m"+string(rune('a'+i)), o.Peer)
352 }
353 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
354 h.Join(ctx, "r1", "sg", g.Peer)
355 kinds[100] = KindMember // promoted while the room is full
356 h.Tick(ctx)
357 p, _ := h.Find("r1", "sg", g.Peer)
358 if p.Admitted || p.Kind != KindMember {
359 t.Fatalf("promoted guest: admitted=%v kind=%v, want waiting as a member", p.Admitted, p.Kind)
360 }
361 if ev := types(drain(g.Sub)); len(ev) != 1 || ev[0] != TypeFull {
362 t.Fatalf("promotion into a full room got %v, want full", ev)
363 }
364 // Retrying while the cap is still held earns another full, not silence.
365 if err := h.Join(ctx, "r1", "sg", g.Peer); err != ErrMeshFull {
366 t.Fatalf("retry into a full room: %v", err)
367 }
368 if ev := types(drain(g.Sub)); len(ev) != 1 || ev[0] != TypeFull {
369 t.Fatalf("retry got %v, want full", ev)
370 }
371 // A slot frees and the same retry admits.
372 h.Leave("r1", "ma", members[0].Peer)
373 if err := h.Join(ctx, "r1", "sg", g.Peer); err != nil {
374 t.Fatal(err)
375 }
376 if p, _ := h.Find("r1", "sg", g.Peer); !p.Admitted {
377 t.Fatal("the retry did not admit the waiting member")
378 }
379 if ev := types(drain(g.Sub)); len(ev) != 1 || ev[0] != TypeRoster {
380 t.Fatalf("admitted retry got %v, want roster", ev)
381 }
382}
383
384// A kind change is announced whichever path notices it: the request one
385// re-derives standing too, and the tick will never see this change again
386// (spec §2.4, §2.5).
387func TestAKindChangeOnTheRequestPathRefreshesRosters(t *testing.T) {
388 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
389 kinds := map[int64]Kind{1: KindMember, 2: KindMember}
390 h := New(Config{Now: func() time.Time { return now }, StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) {
391 return kinds[id], true, nil
392 }})
393 ctx := context.Background()
394 a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
395 h.Join(ctx, "r1", "sa", a.Peer)
396 b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
397 h.Join(ctx, "r1", "sb", b.Peer)
398 drain(a.Sub)
399 drain(b.Sub)
400 kinds[2] = KindGuest // removed from the allowlist, live grant, no tick yet
401 if err := h.Join(ctx, "r1", "sb", b.Peer); err != nil {
402 t.Fatal(err)
403 }
404 ev := drain(a.Sub)
405 if len(ev) != 1 || ev[0].Type != TypeRoster {
406 t.Fatalf("the other admitted peer got %v, want one roster", types(ev))
407 }
408 got := ""
409 for _, rp := range ev[0].Roster.Peers {
410 if rp.Peer == b.Peer {
411 got = rp.Kind
412 }
413 }
414 if got != "guest" {
415 t.Fatalf("Ann's roster shows Bob as %q, want guest", got)
416 }
417 // The demoted peer keeps the room but loses the authority to admit.
418 if err := h.Admit(ctx, "r1", "sb", b.Peer, a.Peer); err != ErrNotMember {
419 t.Fatalf("demoted member admitting: %v", err)
420 }
421}
422
423func TestJoinDropsAndAnnouncesAMemberWhoLostStanding(t *testing.T) {
424 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
425 standing := map[int64]bool{1: true, 2: true}
426 h := New(Config{Now: func() time.Time { return now }, StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) {
427 return KindMember, standing[id], nil
428 }})
429 ctx := context.Background()
430 a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
431 h.Join(ctx, "r1", "sa", a.Peer)
432 b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
433 h.Join(ctx, "r1", "sb", b.Peer)
434 drain(a.Sub)
435 drain(b.Sub)
436 standing[2] = false // removed from the allowlist between joins
437 if err := h.Join(ctx, "r1", "sb", b.Peer); err != ErrNotYours {
438 t.Fatalf("re-join without standing: %v", err)
439 }
440 if _, found := h.Find("r1", "sb", b.Peer); found {
441 t.Fatal("the removed member is still in the room")
442 }
443 bev := drain(b.Sub)
444 if len(bev) == 0 || bev[len(bev)-1].Type != TypeLost || bev[len(bev)-1].Reason != ReasonStanding {
445 t.Fatalf("removed member got %v, want lost/standing last", types(bev))
446 }
447 if ev := types(drain(a.Sub)); len(ev) != 1 || ev[0] != TypeGone {
448 t.Fatalf("the peer left behind got %v, want gone", ev)
449 }
450}
451
452func TestTakeoverDepartureDependsOnState(t *testing.T) {
453 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
454 h := testHub(&now)
455 ctx := context.Background()
456 m, _ := h.Open("r1", "sm", 1, "Mia", KindMember)
457 h.Join(ctx, "r1", "sm", m.Peer)
458 ag, _ := h.Open("r1", "sag", 100, "Adm", KindGuest)
459 h.Join(ctx, "r1", "sag", ag.Peer)
460 h.Admit(ctx, "r1", "sm", m.Peer, ag.Peer)
461 w, _ := h.Open("r1", "sw", 101, "Wait", KindGuest)
462 h.Join(ctx, "r1", "sw", w.Peer)
463 u, _ := h.Open("r1", "su", 102, "Unj", KindGuest)
464 drain(m.Sub)
465 drain(ag.Sub)
466 // waiting takeover: knock-gone to members only
467 if _, err := h.Takeover("r1", "sw"); err != nil {
468 t.Fatal(err)
469 }
470 if ev := types(drain(ag.Sub)); len(ev) != 0 {
471 t.Fatalf("admitted guest saw a waiting departure: %v", ev)
472 }
473 if ev := types(drain(m.Sub)); len(ev) != 1 || ev[0] != TypeKnockGone {
474 t.Fatalf("member got %v", ev)
475 }
476 // unjoined takeover: nothing
477 h.Takeover("r1", "su")
478 _ = u
479 if ev := types(drain(m.Sub)); len(ev) != 0 {
480 t.Fatalf("unjoined takeover announced %v", ev)
481 }
482 // admitted takeover: gone to admitted peers, old stream lost/replaced
483 h.Takeover("r1", "sag")
484 agev := drain(ag.Sub)
485 if len(agev) == 0 || agev[len(agev)-1].Reason != ReasonReplaced {
486 t.Fatalf("replaced peer got %v", types(agev))
487 }
488 if ev := types(drain(m.Sub)); len(ev) != 1 || ev[0] != TypeGone {
489 t.Fatalf("member got %v", ev)
490 }
491 if _, err := h.Open("r1", "sag", 100, "Adm", KindGuest); err != nil {
492 t.Fatalf("fresh stream after takeover: %v", err)
493 }
494}
495
496func TestEndSendsEndedToEveryoneAndDropsTheRoom(t *testing.T) {
497 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
498 h := testHub(&now)
499 ctx := context.Background()
500 m, _ := h.Open("r1", "sm", 1, "Mia", KindMember)
501 h.Join(ctx, "r1", "sm", m.Peer)
502 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
503 h.Join(ctx, "r1", "sg", g.Peer)
504 h.End("r1")
505 for _, s := range []*Sub{m.Sub, g.Sub} {
506 ev := drain(s)
507 if len(ev) == 0 || ev[len(ev)-1].Type != TypeLost || ev[len(ev)-1].Reason != ReasonEnded {
508 t.Fatalf("got %v, want lost/ended last", types(ev))
509 }
510 }
511 if _, err := h.Open("r1", "sx", 5, "X", KindMember); err == nil {
512 t.Fatal("ended room accepted a stream")
513 }
514}
515
516// Spec §13's "the cap at join and at admit including four queued guests
517// and concurrent admissions": the mesh cap is checked under the hub's
518// own lock, inside admitLocked, so five members racing to fill the one
519// remaining slot cannot between them put five people in a room of four.
520//
521// Every call here is a legitimate request that the hub either performed
522// or refused for the cap, so none of them is an error: the loser learns
523// it lost from the `full` event on its own stream (§4.3), which is why
524// this asserts on the room's state rather than on a returned error.
525func TestConcurrentAdmissionsFillTheLastSlotExactlyOnce(t *testing.T) {
526 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
527 h := testHub(&now)
528 ctx := context.Background()
529
530 // Five member participations open, of which the first three join and
531 // are admitted: three of the four mesh slots taken, one free. Only
532 // those three may admit, so the five goroutines below use them
533 // round-robin — five real callers through three sessions, rather
534 // than one caller five times.
535 actors := make([]struct {
536 session string
537 peer PeerID
538 }, 5)
539 for i := range actors {
540 session := fmt.Sprintf("sm%d", i)
541 m, err := h.Open("r1", session, int64(1+i), "Mia", KindMember)
542 if err != nil {
543 t.Fatalf("open member %d: %v", i, err)
544 }
545 actors[i].session, actors[i].peer = session, m.Peer
546 if i < 3 {
547 if err := h.Join(ctx, "r1", session, m.Peer); err != nil {
548 t.Fatalf("join member %d: %v", i, err)
549 }
550 }
551 }
552 if got := admittedNow(t, h, "r1"); got != 3 {
553 t.Fatalf("setup left %d admitted, want 3", got)
554 }
555 // The two members that did not join are still members with a live
556 // participation, but they are not admitted, so they may not admit:
557 // only the three that joined are used as actors below.
558
559 guests := make([]struct {
560 session string
561 peer PeerID
562 }, 5)
563 for i := range guests {
564 session := fmt.Sprintf("sg%d", i)
565 g, err := h.Open("r1", session, int64(100+i), "Gus", KindGuest)
566 if err != nil {
567 t.Fatalf("open guest %d: %v", i, err)
568 }
569 if err := h.Join(ctx, "r1", session, g.Peer); err != nil {
570 t.Fatalf("guest %d knock: %v", i, err)
571 }
572 guests[i].session, guests[i].peer = session, g.Peer
573 }
574
575 var wg sync.WaitGroup
576 errs := make([]error, len(guests))
577 start := make(chan struct{})
578 for i := range guests {
579 wg.Add(1)
580 go func(i int) {
581 defer wg.Done()
582 a := actors[i%3] // the three admitted members, round-robin
583 <-start
584 errs[i] = h.Admit(ctx, "r1", a.session, a.peer, guests[i].peer)
585 }(i)
586 }
587 close(start)
588 wg.Wait()
589
590 for i, err := range errs {
591 if err != nil {
592 t.Fatalf("admit %d: %v", i, err)
593 }
594 }
595 admitted := 0
596 for _, g := range guests {
597 p, ok := h.Find("r1", g.session, g.peer)
598 if !ok {
599 t.Fatalf("a guest vanished: %s", g.peer)
600 }
601 if p.Admitted {
602 admitted++
603 }
604 }
605 if admitted != 1 {
606 t.Fatalf("%d of five guests were let into one free slot", admitted)
607 }
608 if got := admittedNow(t, h, "r1"); got != MaxAdmitted {
609 t.Fatalf("the room holds %d admitted, want %d", got, MaxAdmitted)
610 }
611}
612
613// admittedNow counts the room's admitted participations through the
614// hub's own lock, without reaching past its API for a field. Since
615// slice 5 that is exactly AdmittedCount, the drive surface's own
616// reader (count.go), so this is its name in the tests that predate it
617// rather than a second copy of the same walk.
618func admittedNow(t *testing.T, h *Hub, sqid string) int {
619 t.Helper()
620 return h.AdmittedCount(sqid)
621}
622
623// Spec §2.4 and ruling 14: a loss of standing costs what the kind held
624// earns, and standingLostLocked is the one place that decides which.
625// Admit's expired-target path used to call endLocked(ReasonExpired)
626// directly, which is right for a guest and wrong for the one target
627// that is waiting and a member — the tick promotes a waiting guest to
628// member and leaves it waiting when the mesh is full (§2.4's retry), and
629// that participation is owed reason "standing".
630func TestAdmitOfAWaitingMemberWhoLostStandingEndsItAsAMember(t *testing.T) {
631 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
632 kinds := map[int64]Kind{100: KindGuest}
633 authority := map[int64]bool{100: true}
634 h := New(Config{Now: func() time.Time { return now }, StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) {
635 if id < 100 {
636 return KindMember, true, nil
637 }
638 return kinds[id], authority[id], nil
639 }})
640 ctx := context.Background()
641
642 // Four admitted members: the mesh is full, so the promotion below
643 // cannot admit and the guest waits on as a member.
644 var actor OpenResult
645 for i := 0; i < MaxAdmitted; i++ {
646 session := fmt.Sprintf("sm%d", i)
647 m, _ := h.Open("r1", session, int64(1+i), "Mia", KindMember)
648 if err := h.Join(ctx, "r1", session, m.Peer); err != nil {
649 t.Fatalf("join member %d: %v", i, err)
650 }
651 if i == 0 {
652 actor = m
653 }
654 }
655 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
656 if err := h.Join(ctx, "r1", "sg", g.Peer); err != nil {
657 t.Fatal(err)
658 }
659
660 kinds[100] = KindMember // promoted while waiting
661 h.Tick(ctx)
662 p, ok := h.Find("r1", "sg", g.Peer)
663 if !ok || p.Admitted || p.Kind != KindMember {
664 t.Fatalf("setup: waiting member is %+v (admitted=%v)", p, ok && p.Admitted)
665 }
666 drain(g.Sub)
667
668 // Standing goes before anyone gets round to admitting them.
669 authority[100] = false
670 if err := h.Admit(ctx, "r1", "sm0", actor.Peer, g.Peer); err != nil {
671 t.Fatalf("admit: %v", err)
672 }
673 if _, ok := h.Find("r1", "sg", g.Peer); ok {
674 t.Fatal("a target with no standing was left in the room")
675 }
676 ev := drain(g.Sub)
677 if len(ev) == 0 || ev[len(ev)-1].Type != TypeLost {
678 t.Fatalf("the dropped target got %v", types(ev))
679 }
680 if got := ev[len(ev)-1].Reason; got != ReasonStanding {
681 t.Fatalf("a waiting member who lost standing was told %q, want %q", got, ReasonStanding)
682 }
683}
684