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 "time"
7)
8
9func isTargeted(t string) bool { return t == TypeOffer || t == TypeAnswer || t == TypeCandidate }
10
11// Relay delivers one targeted message. The envelope is rebuilt from the
12// binding; the payload is never read.
13//
14// A member's standing is re-derived first (§15.12): the participation
15// authorizes everything after entry (§2.4), but a member who has lost
16// the team is dropped by the next thing they do, and this is that thing.
17// A member with no standing is ended here and the request answers
18// ErrNotYours, which the handler renders as the same not-found body an
19// unknown peer gets.
20func (h *Hub) Relay(ctx context.Context, sqid, session string, from PeerID, m Message) error {
21 if !isTargeted(m.Type) {
22 return fmt.Errorf("%w: %q", ErrNotTargeted, m.Type)
23 }
24 gone, err := h.memberStandingGone(ctx, sqid, session, from, true)
25 if err != nil {
26 return err
27 }
28 if gone {
29 return ErrNotYours
30 }
31 h.mu.Lock()
32 defer h.mu.Unlock()
33 r, ok := h.rooms[sqid]
34 if !ok {
35 return ErrUnknownPeer
36 }
37 p, found := h.findLocked(sqid, session, from)
38 if !found {
39 return ErrUnknownPeer
40 }
41 if !p.Admitted {
42 return ErrNotAdmitted
43 }
44 if m.To == p.Peer {
45 return ErrUnknownPeer // a peer is not its own target
46 }
47 tp, ok := r.peers[m.To]
48 if !ok || !tp.Admitted {
49 return ErrUnknownPeer
50 }
51 if m.Gen != h.genLocked(r, p.Peer, tp.Peer) {
52 return ErrStaleGen
53 }
54 out := Message{Type: m.Type, From: p.Peer, Name: p.Name, To: tp.Peer, Seq: m.Seq, Gen: m.Gen, Payload: m.Payload}
55 h.ringLocked(tp, out)
56 h.sendLocked(tp, out)
57 return nil
58}
59
60// ringLocked keeps a copy for the resume window, newest last. What falls
61// out of the ring is remembered as a high-water mark per sender, which
62// is the only way a resume can tell that a sender's messages were
63// evicted wholesale by another sender's burst.
64func (h *Hub) ringLocked(p *Participation, m Message) {
65 p.ring = append(p.ring, m)
66 if len(p.ring) <= RingSize {
67 return
68 }
69 for _, old := range p.ring[:len(p.ring)-RingSize] {
70 if !isTargeted(old.Type) {
71 continue
72 }
73 if p.evicted == nil {
74 p.evicted = map[PeerID]uint64{}
75 }
76 if old.Seq > p.evicted[old.From] {
77 p.evicted[old.From] = old.Seq
78 }
79 }
80 p.ring = p.ring[len(p.ring)-RingSize:]
81}
82
83// Reset bumps the pair's generation and tells both sides. A member's
84// standing is re-derived first, exactly as Relay does (§15.12).
85func (h *Hub) Reset(ctx context.Context, sqid, session string, from, to PeerID) error {
86 gone, err := h.memberStandingGone(ctx, sqid, session, from, true)
87 if err != nil {
88 return err
89 }
90 if gone {
91 return ErrNotYours
92 }
93 h.mu.Lock()
94 defer h.mu.Unlock()
95 r, ok := h.rooms[sqid]
96 if !ok {
97 return ErrUnknownPeer
98 }
99 p, found := h.findLocked(sqid, session, from)
100 if !found {
101 return ErrUnknownPeer
102 }
103 if !p.Admitted {
104 return ErrNotAdmitted
105 }
106 if to == p.Peer {
107 return ErrUnknownPeer // a peer is not its own pair
108 }
109 tp, ok := r.peers[to]
110 if !ok || !tp.Admitted {
111 return ErrUnknownPeer
112 }
113 k := keyFor(p.Peer, tp.Peer)
114 h.genLocked(r, p.Peer, tp.Peer) // starts the pair at one if it is new
115 r.gens[k]++
116 gen := r.gens[k]
117 for _, pair := range [][2]*Participation{{p, tp}, {tp, p}} {
118 m := Message{Type: TypePair, Peer: pair[1].Peer, Gen: gen, To: pair[0].Peer}
119 h.ringLocked(pair[0], m)
120 h.sendLocked(pair[0], m)
121 }
122 return nil
123}
124
125// ResumeResult is what the stream handler owes a browser that came back
126// inside its window: a fresh subscriber, and, when the participation is
127// admitted, the roster, the replay and the senders it must reset with.
128type ResumeResult struct {
129 Sub *Sub
130 Admitted bool
131 Roster *Roster
132 Replay []Message
133 Gap []PeerID
134 // ReportToken is the participation's, for the resumed hello (§15.26).
135 ReportToken string
136}
137
138// ResumeError is a refused resume in the shape spec §4.5's table asks
139// for: a status, a reason on the terminal 410s, and the boot id on the
140// 404s so a browser can tell a restart from a lapsed window.
141type ResumeError struct {
142 Status int
143 Reason Reason
144 Boot string
145}
146
147func (e *ResumeError) Error() string {
148 return fmt.Sprintf("hub: resume refused %d %s", e.Status, e.Reason)
149}
150
151// resumeRefusalLocked is spec §4.5's refusal ladder, and the only
152// place it is written: Resume applies it before attaching, and
153// CanResume applies it and stops there.
154func (h *Hub) resumeRefusalLocked(sqid, session string, peer PeerID) *ResumeError {
155 if _, ok := h.rooms[sqid]; !ok {
156 return &ResumeError{Status: 404, Boot: h.boot}
157 }
158 // A tombstone is answered only through the room and session it was
159 // bound to. Why a participation ended is account state, and a peer
160 // id is published in every roster, so an ended peer's room-mate must
161 // not be able to read the reason off it (spec §4.2's binding rule).
162 if t, ok := h.tombstones[peer]; ok && t.room == sqid && t.session == session {
163 return &ResumeError{Status: 410, Reason: t.reason}
164 }
165 p, found := h.findLocked(sqid, session, peer)
166 if !found {
167 // Unknown here, unknown in another session, unknown in another
168 // room: one answer, so nothing leaks (spec §4.2).
169 return &ResumeError{Status: 404, Boot: h.boot}
170 }
171 // The window is thirty seconds, not thirty plus however long it is
172 // until the next tick: a resume that arrives after resume_until is
173 // refused now. Removing the participation and announcing the
174 // departure is still the tick's, so a refusal never has a room's
175 // events as a side effect.
176 if p.sub == nil && !p.resumeUntil.IsZero() && h.cfg.Now().After(p.resumeUntil) {
177 return &ResumeError{Status: 404, Boot: h.boot}
178 }
179 return nil
180}
181
182// CanResume answers what a resume would answer, and does nothing else:
183// it does not attach a subscriber, does not clear the resume window and
184// does not read or trim the ring. It is the probe behind
185// GET /m/{sqid}/stream?peer=&probe=1, which exists because EventSource
186// hides the status code of a refused stream from the page.
187// It re-derives a member's standing (§15.12) and answers 404 when it has
188// gone — the same body an unknown peer gets — but it does not end the
189// participation: that is the next mutating request's, or the tick's. A
190// probe changes nothing, and a browser that reads 404 here goes on to
191// open a fresh stream, which is what a dropped member should get.
192func (h *Hub) CanResume(ctx context.Context, sqid, session string, peer PeerID) *ResumeError {
193 gone, err := h.memberStandingGone(ctx, sqid, session, peer, false)
194 if err != nil {
195 // A standing read that failed is not evidence of anything. The
196 // participation answers for itself, as it did before §15.12, and
197 // the tick will re-read standing in fifteen seconds.
198 h.cfg.Logger.Warn("hub: standing lookup failed on a resume probe", "room", sqid, "peer", peer, "error", err)
199 }
200 h.mu.Lock()
201 defer h.mu.Unlock()
202 if gone {
203 return &ResumeError{Status: 404, Boot: h.boot}
204 }
205 return h.resumeRefusalLocked(sqid, session, peer)
206}
207
208// Resume reattaches a stream to a participation in its window. It is
209// never a transition into the admitted state: a resume does not consume
210// a cap slot and cannot admit anyone (spec §4.3).
211//
212// A member's standing is re-derived before anything is attached
213// (§15.12). The refusal ladder runs first, so a participation that
214// ended for a reason worth naming still answers 410 with that reason
215// rather than spending a database read; a member whose standing has
216// gone since is ended here and answered 404, which is the answer an
217// unknown peer gets, byte for byte.
218func (h *Hub) Resume(ctx context.Context, sqid, session string, peer PeerID, last map[PeerID]uint64) (ResumeResult, error) {
219 h.mu.Lock()
220 if re := h.resumeRefusalLocked(sqid, session, peer); re != nil {
221 h.mu.Unlock()
222 return ResumeResult{}, re
223 }
224 h.mu.Unlock()
225
226 gone, err := h.memberStandingGone(ctx, sqid, session, peer, true)
227 if err != nil {
228 return ResumeResult{}, err
229 }
230 if gone {
231 return ResumeResult{}, &ResumeError{Status: 404, Boot: h.boot}
232 }
233
234 h.mu.Lock()
235 defer h.mu.Unlock()
236 // The lock was down for the standing read, so the ladder is walked
237 // again: the window may have passed, or the meeting ended, while it
238 // was out.
239 if re := h.resumeRefusalLocked(sqid, session, peer); re != nil {
240 return ResumeResult{}, re
241 }
242 p, _ := h.findLocked(sqid, session, peer)
243 r := h.rooms[sqid]
244 if p.sub != nil {
245 // A second stream for the same peer replaces the attachment only:
246 // no tombstone, no departure, the participation carries on.
247 p.sub.terminate(Message{Type: TypeLost, Reason: ReasonReplaced})
248 }
249 p.sub = newSub()
250 p.resumeUntil = time.Time{}
251 res := ResumeResult{Sub: p.sub, Admitted: p.Admitted, ReportToken: p.reportToken}
252 if !p.Admitted {
253 // A waiting guest learns nothing by resuming (ruling 9).
254 return res, nil
255 }
256 res.Roster = h.rosterFor(r, p)
257 // Replay is per sender: everything above last, unless the ring has
258 // dropped something above last, in which case that sender gets a gap
259 // and no partial replay (spec §4.5).
260 //
261 // Everything here is filtered against the roster that has just been
262 // built. The ring and the eviction marks outlive the peers that
263 // filled them, and the roster is the authority a browser reconciles
264 // against (§4.5): it has already closed its connection to a peer
265 // that is gone, so it must not be told to reset with it, replay its
266 // signalling, or rebuild its pair.
267 gapped := map[PeerID]bool{}
268 for from, high := range p.evicted {
269 if _, present := rosterGen(res.Roster, from); !present {
270 continue
271 }
272 if high > last[from] {
273 gapped[from] = true
274 res.Gap = append(res.Gap, from)
275 }
276 }
277 for _, m := range p.ring {
278 switch {
279 case isTargeted(m.Type):
280 if _, present := rosterGen(res.Roster, m.From); !present {
281 continue
282 }
283 if !gapped[m.From] && m.Seq > last[m.From] {
284 res.Replay = append(res.Replay, m)
285 }
286 case m.Type == TypePair:
287 // The roster already carries the current generation for every
288 // admitted peer, so this never fires for a current roster;
289 // the branch is kept because the ring is the authority for
290 // what was sent, and the browser reconciles from both (§4.6).
291 gen, present := rosterGen(res.Roster, m.Peer)
292 if present && m.Gen > gen {
293 res.Replay = append(res.Replay, m)
294 }
295 }
296 }
297 return res, nil
298}
299
300// rosterGen is the recipient's generation with one peer, and whether
301// that peer is in the roster at all.
302func rosterGen(ro *Roster, peer PeerID) (uint64, bool) {
303 for _, rp := range ro.Peers {
304 if rp.Peer == peer {
305 return rp.Gen, true
306 }
307 }
308 return 0, false
309}
310