Skip to main content

ryu_realtime/
lib.rs

1//! Room-keyed realtime primitive (Phase 1 of the multi-user collaboration epic).
2//!
3//! This module is the transport-agnostic fan-out core that chat fan-out,
4//! CRDT doc-sync (Phase 3), and presence/awareness all consume. It is a sibling
5//! to Core's `identity_verify` (the USER-identity layer) and intentionally
6//! knows nothing about WebSockets, JWTs, or access control — those live in the
7//! WS handler (stage 2/3) that drives this registry.
8//!
9//! ## Shape
10//!
11//! - A [`RoomRegistry`] maps `room_id` -> a [`RoomHandle`]. Each live room runs
12//!   as ONE tokio actor task ([`run_room`]) that owns the room's ephemeral state
13//!   (presence map + idle clock) behind a command channel, plus a
14//!   [`tokio::sync::broadcast`] sender for fan-out to every joined member.
15//! - Membership is reference-counted via an [`AtomicUsize`] shared between the
16//!   handle and the actor. [`RoomHandle::join`] returns a [`RoomMembership`]
17//!   RAII guard whose `Drop` decrements the count, evicts the member's presence,
18//!   and broadcasts a `presence_leave` delta — so a client that drops its socket
19//!   without a clean leave is still reaped.
20//! - **Hibernation** is the single biggest scaling lever: a room that has had
21//!   zero members for longer than [`RoomConfig::idle_window`] exits its actor and
22//!   is removed from the registry (rehydrated on the next join). Evictions are
23//!   logged.
24//!
25//! ## Race safety (membership vs eviction)
26//!
27//! Concurrent callers MUST join via [`RoomRegistry::join`], whose get-or-create
28//! and `fetch_add` both run while holding the registry `Mutex`. The actor's
29//! eviction recheck ([`try_evict`]) takes that same lock, so the two serialize:
30//! either `join` wins (eviction then sees members > 0 and skips) or eviction wins
31//! (removes the entry and exits; `join` transparently re-creates a fresh room).
32//! There is no window in which a caller ends up holding a handle to a room the
33//! registry has dropped.
34//!
35//! The lower-level [`RoomRegistry::get_or_create`] + [`RoomHandle::join`] two-step
36//! is NOT race-safe against eviction (the increment happens outside the lock) and
37//! exists only for single-threaded tests with controlled lifecycles.
38//!
39//! ## Channels
40//!
41//! A [`Frame`] carries a [`RealtimeChannel`] tag. `Events` and `Presence` carry
42//! `serde_json::Value` (JSON text on the wire); `DocSync` carries opaque
43//! `Vec<u8>` that passes through untouched (reserved for Phase 3 — accept and
44//! relay binary without interpreting it).
45//!
46//! Presence is NEVER persisted: it lives only in the actor's in-memory map with a
47//! heartbeat TTL, and vanishes when the room hibernates.
48//!
49//! Staging note: stage 1 builds the primitive with unit tests. Wiring into
50//! `ServerState`, the `GET /api/realtime/ws` route, and `append_message` fan-out
51//! happens in stages 2/3, so several items are intentionally unused for now.
52#![allow(dead_code)]
53
54use std::{
55    collections::HashMap,
56    sync::{
57        atomic::{AtomicU64, AtomicUsize, Ordering},
58        Arc, Mutex, Weak,
59    },
60    time::{Duration, Instant},
61};
62
63use serde_json::{json, Value};
64use tokio::sync::{broadcast, mpsc, oneshot};
65
66/// How long a room may have zero members before its actor exits and the registry
67/// entry is dropped (rehydrated on next join). The single biggest scaling lever.
68const DEFAULT_IDLE_WINDOW: Duration = Duration::from_secs(5 * 60);
69
70/// How long a presence entry survives without a heartbeat before the reaper
71/// evicts it and broadcasts a `presence_leave` delta. A client is expected to
72/// re-publish its presence well within this window.
73const DEFAULT_PRESENCE_TTL: Duration = Duration::from_secs(30);
74
75/// How often the per-room actor wakes to reap stale presence and re-evaluate
76/// hibernation. Keep well below both TTLs so reaping is timely.
77const DEFAULT_SWEEP_INTERVAL: Duration = Duration::from_secs(10);
78
79/// Bounded fan-out buffer per room. A slow consumer that overflows this gets a
80/// `RecvError::Lagged` and must resync — backpressure is a client concern.
81const BROADCAST_CAPACITY: usize = 256;
82
83// ── Frame envelope ───────────────────────────────────────────────────────────
84
85/// The logical channel a [`Frame`] travels on. `DocSync` is reserved for Phase 3
86/// CRDT sync and is relayed opaquely for now.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum RealtimeChannel {
89    /// Durable-ish app events (e.g. a new chat message). JSON payload.
90    Events,
91    /// Ephemeral awareness (cursor / typing / name / color). JSON payload, never
92    /// persisted.
93    Presence,
94    /// Opaque binary CRDT updates (Phase 3). Passed through untouched.
95    DocSync,
96}
97
98/// One fan-out frame. `Event`/`Presence` carry JSON; `DocSync` carries opaque
99/// bytes so binary CRDT updates pass through without interpretation. Clone is
100/// cheap-ish (Value/Vec share via the broadcast clone on each receiver).
101#[derive(Debug, Clone)]
102pub enum Frame {
103    Event(Value),
104    Presence(Value),
105    DocSync(Vec<u8>),
106}
107
108impl Frame {
109    /// The channel tag for this frame.
110    pub fn channel(&self) -> RealtimeChannel {
111        match self {
112            Frame::Event(_) => RealtimeChannel::Events,
113            Frame::Presence(_) => RealtimeChannel::Presence,
114            Frame::DocSync(_) => RealtimeChannel::DocSync,
115        }
116    }
117}
118
119// ── Typed named events (the Rivet-style event contract) ──────────────────────
120//
121// This layer sits *on top of* the [`Frame`] wire, not beside it: a named event is
122// encoded as a self-describing envelope carried on the ordinary `Frame::Event`
123// channel, so every existing consumer (the `frame_to_message` bridge, the DocSync
124// relay, raw `subscribe()` receivers) keeps working byte-for-byte. Callers that
125// opt into the typed contract publish/subscribe *by event name* instead of matching
126// an opaque `Frame`, mirroring Rivet actors' `broadcast(event, payload)` /
127// `conn.send(event, payload)` / `actor.on(event, …)` shape
128// (rivet.dev/docs/actors/events). Targeted `send_event` never rides the shared
129// broadcast — it takes a per-connection channel — so it is invisible to raw
130// broadcast subscribers and to other connections.
131
132/// Envelope key carrying the event name inside a `Frame::Event` value.
133const EVENT_NAME_KEY: &str = "__ryu_event";
134/// Envelope key carrying the event payload inside a `Frame::Event` value.
135const EVENT_DATA_KEY: &str = "data";
136
137/// Process-global source of [`ConnId`]s. Global (not per-room) so an id is unique
138/// for the life of the process: a room that hibernates and rehydrates can never
139/// reissue an id a stale holder still targets, so a late `send_event` can only ever
140/// address the connection it was minted for (and no-op if that connection is gone).
141static NEXT_CONN_ID: AtomicU64 = AtomicU64::new(1);
142
143/// Opaque, process-unique identity for one subscriber [`Connection`]. The address
144/// a targeted [`RoomHandle::send_event`] delivers to.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
146pub struct ConnId(u64);
147
148impl ConnId {
149    fn next() -> Self {
150        Self(NEXT_CONN_ID.fetch_add(1, Ordering::Relaxed))
151    }
152
153    /// The raw numeric id (diagnostics / stable wire identity).
154    pub fn get(self) -> u64 {
155        self.0
156    }
157}
158
159/// A decoded typed room event: a name plus its JSON payload. Produced by
160/// [`Connection::recv`] from an enveloped `Frame::Event`.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct Event {
163    /// The event name the publisher broadcast/sent under.
164    pub name: String,
165    /// The event's JSON payload (`Value::Null` if the publisher sent none).
166    pub payload: Value,
167}
168
169impl Event {
170    /// Decode a frame into a typed event, or `None` if the frame is not a named
171    /// event envelope (a presence delta, a DocSync blob, or a raw `publish_event`
172    /// value with no envelope). Non-events are skipped by the typed reader, never
173    /// surfaced as bogus zero-name events.
174    ///
175    /// Public so a consumer that subscribes to the *raw* [`Frame`] stream (because it
176    /// must also relay presence/DocSync frames the typed [`Connection`] skips) can
177    /// still recognise and unwrap typed named events off the shared broadcast — the
178    /// WS gateway's fan-out path is exactly such a consumer.
179    pub fn decode(frame: &Frame) -> Option<Event> {
180        let Frame::Event(value) = frame else {
181            return None;
182        };
183        let name = value.get(EVENT_NAME_KEY)?.as_str()?.to_string();
184        let payload = value.get(EVENT_DATA_KEY).cloned().unwrap_or(Value::Null);
185        Some(Event { name, payload })
186    }
187}
188
189/// Encode a named event as its `Frame::Event` envelope value.
190fn encode_event(name: impl Into<String>, payload: Value) -> Value {
191    let mut map = serde_json::Map::with_capacity(2);
192    map.insert(EVENT_NAME_KEY.to_string(), Value::String(name.into()));
193    map.insert(EVENT_DATA_KEY.to_string(), payload);
194    Value::Object(map)
195}
196
197// ── Config ───────────────────────────────────────────────────────────────────
198
199/// Tunables for room lifecycle. [`RoomConfig::default`] uses production values;
200/// tests construct short windows via [`RoomRegistry::with_config`].
201#[derive(Debug, Clone, Copy)]
202pub struct RoomConfig {
203    /// Zero-member duration after which a room hibernates.
204    pub idle_window: Duration,
205    /// Presence heartbeat TTL.
206    pub presence_ttl: Duration,
207    /// Actor sweep cadence (presence reaping + hibernation check).
208    pub sweep_interval: Duration,
209}
210
211impl Default for RoomConfig {
212    fn default() -> Self {
213        Self {
214            idle_window: DEFAULT_IDLE_WINDOW,
215            presence_ttl: DEFAULT_PRESENCE_TTL,
216            sweep_interval: DEFAULT_SWEEP_INTERVAL,
217        }
218    }
219}
220
221// ── Actor command protocol ───────────────────────────────────────────────────
222
223/// Messages the registry/handles send to a room's actor task. Membership counting
224/// is done via the shared atomic under the registry lock; these commands carry the
225/// *side effects* (presence mutation, idle-clock updates, test queries).
226enum RoomCommand {
227    /// A member joined — clear the idle clock.
228    Joined,
229    /// A member left — decrement already happened on the atomic; drop its presence
230    /// and broadcast a `presence_leave` delta, then arm the idle clock if empty.
231    Left { member_id: String },
232    /// Upsert a member's presence and broadcast the delta on the Presence channel.
233    Presence { member_id: String, value: Value },
234    /// Test/diagnostic: snapshot the live presence member ids.
235    PresenceMembers { reply: oneshot::Sender<Vec<String>> },
236    /// A typed [`Connection`] opened: register its private delivery channel so
237    /// [`RoomCommand::SendTo`] can address it.
238    OpenConn {
239        conn_id: ConnId,
240        tx: mpsc::UnboundedSender<Frame>,
241    },
242    /// A typed [`Connection`] dropped/closed (RAII): unregister its channel.
243    CloseConn { conn_id: ConnId },
244    /// Deliver `frame` to exactly one connection. No-op if that connection is gone;
245    /// a dead channel is pruned on the failed send.
246    SendTo { conn_id: ConnId, frame: Frame },
247    /// Test/diagnostic: number of registered typed connections.
248    ConnCount { reply: oneshot::Sender<usize> },
249}
250
251// ── Registry ─────────────────────────────────────────────────────────────────
252
253type RoomMap = HashMap<String, RoomHandle>;
254
255/// Process-shared registry of live rooms. Cheap to clone (it is an `Arc` bag) so
256/// it can live in `ServerState` and be reached from handlers and `append_message`.
257#[derive(Clone)]
258pub struct RoomRegistry {
259    inner: Arc<Mutex<RoomMap>>,
260    config: RoomConfig,
261}
262
263impl RoomRegistry {
264    /// A registry with production lifecycle tunables.
265    pub fn new() -> Self {
266        Self::with_config(RoomConfig::default())
267    }
268
269    /// A registry with custom lifecycle tunables (used by tests for short
270    /// windows).
271    pub fn with_config(config: RoomConfig) -> Self {
272        Self {
273            inner: Arc::new(Mutex::new(HashMap::new())),
274            config,
275        }
276    }
277
278    /// Get the handle for `room_id`, spawning the room's actor if it is not yet
279    /// live. Idempotent: repeated calls for the same id return clones of the same
280    /// handle (same broadcast sender + member counter) until the room hibernates.
281    pub fn get_or_create(&self, room_id: &str) -> RoomHandle {
282        let mut map = self.lock();
283        if let Some(handle) = map.get(room_id) {
284            return handle.clone();
285        }
286        let handle = self.spawn_room(room_id.to_string());
287        map.insert(room_id.to_string(), handle.clone());
288        handle
289    }
290
291    /// Join `room_id` as `member_id`, get-or-creating the room AND incrementing its
292    /// member count **atomically under the registry lock**. This is the race-safe
293    /// entry point that any concurrent caller (the WS gateway) must use instead of
294    /// `get_or_create()` followed by [`RoomHandle::join`].
295    ///
296    /// Because [`try_evict`] rechecks the member count under this same lock, the
297    /// increment can never be observed as zero in the gap between get-or-create and
298    /// join. So a join racing an eviction has exactly two outcomes: the join takes
299    /// the lock first (eviction then sees `members > 0` and aborts, keeping the
300    /// existing room), or eviction takes it first (removes the entry and exits;
301    /// this call then transparently spawns a fresh room). Neither outcome yields an
302    /// orphaned handle whose actor is dead and whose registry entry is gone.
303    pub fn join(&self, room_id: &str, member_id: impl Into<String>) -> RoomMembership {
304        let mut map = self.lock();
305        let handle = match map.get(room_id) {
306            Some(handle) => handle.clone(),
307            None => {
308                let handle = self.spawn_room(room_id.to_string());
309                map.insert(room_id.to_string(), handle.clone());
310                handle
311            }
312        };
313        // The whole point of this method: increment while the registry lock is
314        // still held, so `try_evict`'s locked recheck serializes against it.
315        handle.members.fetch_add(1, Ordering::SeqCst);
316        drop(map);
317        // Reset the actor's idle clock; done outside the lock (channel send only).
318        let _ = handle.cmd.send(RoomCommand::Joined);
319        RoomMembership {
320            handle,
321            member_id: member_id.into(),
322            left: false,
323        }
324    }
325
326    /// Publish an Events frame to `room_id`. No-op if the room is not live (no
327    /// members are subscribed, so there is nothing to deliver and no reason to
328    /// spin up an actor).
329    pub fn publish_event(&self, room_id: &str, value: Value) {
330        if let Some(handle) = self.lock().get(room_id) {
331            let _ = handle.broadcast.send(Frame::Event(value));
332        }
333    }
334
335    /// Publish a presence delta for `member_id` to `room_id`: stores it in the
336    /// room's ephemeral map (so the heartbeat TTL applies) and broadcasts on the
337    /// Presence channel. No-op if the room is not live.
338    pub fn publish_presence(&self, room_id: &str, member_id: &str, value: Value) {
339        if let Some(handle) = self.lock().get(room_id) {
340            handle.publish_presence(member_id, value);
341        }
342    }
343
344    /// Broadcast a typed named event to `room_id` (Rivet's `broadcast(event,
345    /// payload)`). No-op if the room is not live — the registry-level twin of
346    /// [`publish_event`], for callers holding only the registry.
347    ///
348    /// [`publish_event`]: RoomRegistry::publish_event
349    pub fn broadcast_event(&self, room_id: &str, name: impl Into<String>, payload: Value) {
350        if let Some(handle) = self.lock().get(room_id) {
351            handle.broadcast_event(name, payload);
352        }
353    }
354
355    /// Deliver a typed named event to one connection in `room_id` (Rivet's
356    /// `conn.send(event, payload)`). No-op if the room is not live or the connection
357    /// has closed.
358    pub fn send_event(
359        &self,
360        room_id: &str,
361        conn: ConnId,
362        name: impl Into<String>,
363        payload: Value,
364    ) {
365        if let Some(handle) = self.lock().get(room_id) {
366            handle.send_event(conn, name, payload);
367        }
368    }
369
370    /// Number of live (non-hibernated) rooms. Primarily for tests/diagnostics.
371    pub fn room_count(&self) -> usize {
372        self.lock().len()
373    }
374
375    /// Spawn a room actor and build its handle. Caller must hold the registry lock
376    /// and insert the returned handle.
377    fn spawn_room(&self, room_id: String) -> RoomHandle {
378        let (broadcast_tx, _rx) = broadcast::channel(BROADCAST_CAPACITY);
379        let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
380        let members = Arc::new(AtomicUsize::new(0));
381
382        let handle = RoomHandle {
383            room_id: room_id.clone(),
384            broadcast: broadcast_tx.clone(),
385            cmd: cmd_tx,
386            members: Arc::clone(&members),
387        };
388
389        let registry = Arc::downgrade(&self.inner);
390        let config = self.config;
391        tokio::spawn(run_room(
392            room_id,
393            members,
394            broadcast_tx,
395            cmd_rx,
396            registry,
397            config,
398        ));
399
400        handle
401    }
402
403    fn lock(&self) -> std::sync::MutexGuard<'_, RoomMap> {
404        self.inner.lock().unwrap_or_else(|e| e.into_inner())
405    }
406}
407
408impl Default for RoomRegistry {
409    fn default() -> Self {
410        Self::new()
411    }
412}
413
414// ── Handle ───────────────────────────────────────────────────────────────────
415
416/// A cloneable handle to a live room: the broadcast sender for fan-out, the
417/// command channel to the actor, and the shared member counter. Obtained from
418/// [`RoomRegistry::get_or_create`].
419#[derive(Clone)]
420pub struct RoomHandle {
421    room_id: String,
422    broadcast: broadcast::Sender<Frame>,
423    cmd: mpsc::UnboundedSender<RoomCommand>,
424    members: Arc<AtomicUsize>,
425}
426
427impl RoomHandle {
428    /// The room id this handle addresses.
429    pub fn room_id(&self) -> &str {
430        &self.room_id
431    }
432
433    /// Subscribe to this room's fan-out. Multiple receivers are allowed; each sees
434    /// every frame published after it subscribed.
435    pub fn subscribe(&self) -> broadcast::Receiver<Frame> {
436        self.broadcast.subscribe()
437    }
438
439    /// Current member count.
440    pub fn member_count(&self) -> usize {
441        self.members.load(Ordering::SeqCst)
442    }
443
444    /// Join this room as `member_id`, returning an RAII [`RoomMembership`] guard.
445    /// Dropping the guard leaves the room.
446    ///
447    /// NOT race-safe against hibernation: the increment happens outside the
448    /// registry lock, so a room that hibernated between obtaining this handle and
449    /// this call yields an orphaned membership. Concurrent callers must use
450    /// [`RoomRegistry::join`] instead; this method is for single-threaded tests.
451    pub fn join(&self, member_id: impl Into<String>) -> RoomMembership {
452        self.members.fetch_add(1, Ordering::SeqCst);
453        let _ = self.cmd.send(RoomCommand::Joined);
454        RoomMembership {
455            handle: self.clone(),
456            member_id: member_id.into(),
457            left: false,
458        }
459    }
460
461    /// Publish an Events frame to this room.
462    pub fn publish_event(&self, value: Value) {
463        let _ = self.broadcast.send(Frame::Event(value));
464    }
465
466    /// Publish a presence delta for `member_id` (upsert + TTL + broadcast).
467    pub fn publish_presence(&self, member_id: &str, value: Value) {
468        let _ = self.cmd.send(RoomCommand::Presence {
469            member_id: member_id.to_string(),
470            value,
471        });
472    }
473
474    /// Publish an opaque DocSync (binary) frame, passed through untouched. Phase 3
475    /// CRDT updates ride this channel.
476    pub fn publish_doc_sync(&self, bytes: Vec<u8>) {
477        let _ = self.broadcast.send(Frame::DocSync(bytes));
478    }
479
480    /// Broadcast a typed named event to **every** subscriber of this room (Rivet's
481    /// `broadcast(event, payload)`). Rides the ordinary `Frame::Event` channel as an
482    /// envelope, so raw `subscribe()` receivers still get it and typed
483    /// [`Connection`]s decode it into an [`Event`].
484    pub fn broadcast_event(&self, name: impl Into<String>, payload: Value) {
485        let _ = self
486            .broadcast
487            .send(Frame::Event(encode_event(name, payload)));
488    }
489
490    /// Deliver a typed named event to exactly **one** connection (Rivet's
491    /// `conn.send(event, payload)`). Unlike [`broadcast_event`], this never touches
492    /// the shared broadcast, so no other connection and no raw broadcast subscriber
493    /// observes it. No-op if `conn` has closed or the room actor has exited.
494    ///
495    /// [`broadcast_event`]: RoomHandle::broadcast_event
496    pub fn send_event(&self, conn: ConnId, name: impl Into<String>, payload: Value) {
497        let _ = self.cmd.send(RoomCommand::SendTo {
498            conn_id: conn,
499            frame: Frame::Event(encode_event(name, payload)),
500        });
501    }
502
503    /// Open a typed subscriber [`Connection`] on this room: it receives both
504    /// broadcasts and events addressed to its [`ConnId`] via [`send_event`], and
505    /// unregisters itself on `Drop` (the RAII unsubscribe handle). Distinct from the
506    /// raw [`subscribe`] receiver, which is broadcast-only and cannot be targeted.
507    ///
508    /// [`send_event`]: RoomHandle::send_event
509    /// [`subscribe`]: RoomHandle::subscribe
510    pub fn open_connection(&self) -> Connection {
511        let conn_id = ConnId::next();
512        let (tx, targeted_rx) = mpsc::unbounded_channel();
513        let _ = self.cmd.send(RoomCommand::OpenConn { conn_id, tx });
514        Connection {
515            conn_id,
516            cmd: self.cmd.clone(),
517            broadcast_rx: self.broadcast.subscribe(),
518            targeted_rx,
519            broadcast_open: true,
520            targeted_open: true,
521        }
522    }
523
524    /// Snapshot the number of registered typed connections (diagnostic / test
525    /// helper). Returns 0 if the actor has already exited.
526    pub async fn conn_count(&self) -> usize {
527        let (reply, rx) = oneshot::channel();
528        if self.cmd.send(RoomCommand::ConnCount { reply }).is_err() {
529            return 0;
530        }
531        rx.await.unwrap_or(0)
532    }
533
534    /// Snapshot the live presence member ids (diagnostic / test helper). Returns
535    /// an empty vec if the actor has already exited.
536    pub async fn presence_members(&self) -> Vec<String> {
537        let (reply, rx) = oneshot::channel();
538        if self
539            .cmd
540            .send(RoomCommand::PresenceMembers { reply })
541            .is_err()
542        {
543            return Vec::new();
544        }
545        rx.await.unwrap_or_default()
546    }
547}
548
549// ── Membership guard ─────────────────────────────────────────────────────────
550
551/// RAII guard for one member's presence in a room. Created by
552/// [`RoomHandle::join`]. On `Drop` (or explicit [`RoomMembership::leave`]) it
553/// decrements the member count, evicts this member's presence, and broadcasts a
554/// `presence_leave` delta — so an abrupt disconnect is still reaped.
555pub struct RoomMembership {
556    handle: RoomHandle,
557    member_id: String,
558    left: bool,
559}
560
561impl RoomMembership {
562    /// The member id this guard represents.
563    pub fn member_id(&self) -> &str {
564        &self.member_id
565    }
566
567    /// The room this membership is in.
568    pub fn handle(&self) -> &RoomHandle {
569        &self.handle
570    }
571
572    /// Subscribe to the room's fan-out (each call yields a fresh receiver).
573    pub fn subscribe(&self) -> broadcast::Receiver<Frame> {
574        self.handle.subscribe()
575    }
576
577    /// Publish this member's presence (cursor/typing/etc.).
578    pub fn publish_presence(&self, value: Value) {
579        self.handle.publish_presence(&self.member_id, value);
580    }
581
582    /// Open a typed subscriber [`Connection`] on this member's room (convenience for
583    /// [`RoomHandle::open_connection`]).
584    pub fn open_connection(&self) -> Connection {
585        self.handle.open_connection()
586    }
587
588    /// Explicitly leave now (idempotent; `Drop` also calls this).
589    pub fn leave(&mut self) {
590        if self.left {
591            return;
592        }
593        self.left = true;
594        self.handle.members.fetch_sub(1, Ordering::SeqCst);
595        let _ = self.handle.cmd.send(RoomCommand::Left {
596            member_id: self.member_id.clone(),
597        });
598    }
599}
600
601impl Drop for RoomMembership {
602    fn drop(&mut self) {
603        self.leave();
604    }
605}
606
607// ── Typed connection (subscribe = unsubscribe-on-drop handle) ────────────────
608
609/// A typed subscriber to one room, addressable by its [`ConnId`]. It merges the
610/// room's broadcast fan-out with events [`RoomHandle::send_event`] delivers to it
611/// privately, decoding each into a typed [`Event`]. Dropping it unregisters the
612/// targeted channel from the room actor — the RAII unsubscribe handle, the same
613/// pattern Rivet's `actor.on(...)` teardown gives you.
614///
615/// Non-event frames (presence deltas, DocSync blobs, raw non-envelope
616/// `publish_event` values) are skipped by [`recv`], never surfaced as bogus events;
617/// consumers that need the raw wire use [`RoomHandle::subscribe`] instead.
618///
619/// [`recv`]: Connection::recv
620pub struct Connection {
621    conn_id: ConnId,
622    cmd: mpsc::UnboundedSender<RoomCommand>,
623    broadcast_rx: broadcast::Receiver<Frame>,
624    targeted_rx: mpsc::UnboundedReceiver<Frame>,
625    broadcast_open: bool,
626    targeted_open: bool,
627}
628
629impl Connection {
630    /// This connection's process-unique id — the address for
631    /// [`RoomHandle::send_event`].
632    pub fn id(&self) -> ConnId {
633        self.conn_id
634    }
635
636    /// Await the next typed [`Event`] for this connection, from either the room
637    /// broadcast or a targeted `send_event`. Non-event frames are skipped. Returns
638    /// `None` once both delivery paths are permanently closed (the room actor exited
639    /// and the broadcast channel is drained), so it drives a `while let` loop.
640    pub async fn recv(&mut self) -> Option<Event> {
641        loop {
642            if !self.broadcast_open && !self.targeted_open {
643                return None;
644            }
645            let frame = tokio::select! {
646                // Prefer targeted delivery so a private send is never starved by a
647                // busy broadcast stream.
648                biased;
649                targeted = self.targeted_rx.recv(), if self.targeted_open => match targeted {
650                    Some(frame) => frame,
651                    None => {
652                        // Actor dropped the sender (room hibernated/exited): stop
653                        // polling this arm and fall back to draining the broadcast.
654                        self.targeted_open = false;
655                        continue;
656                    }
657                },
658                broadcast = self.broadcast_rx.recv(), if self.broadcast_open => match broadcast {
659                    Ok(frame) => frame,
660                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
661                    Err(broadcast::error::RecvError::Closed) => {
662                        self.broadcast_open = false;
663                        continue;
664                    }
665                },
666            };
667            if let Some(event) = Event::decode(&frame) {
668                return Some(event);
669            }
670            // Non-event frame (presence / doc-sync / raw value): skip and keep waiting.
671        }
672    }
673}
674
675impl Drop for Connection {
676    fn drop(&mut self) {
677        // RAII unsubscribe: unregister our targeted channel from the actor. Best
678        // effort — if the actor already exited the send simply fails.
679        let _ = self.cmd.send(RoomCommand::CloseConn {
680            conn_id: self.conn_id,
681        });
682    }
683}
684
685// ── Room actor ───────────────────────────────────────────────────────────────
686
687/// Per-room actor task. Owns the ephemeral presence map and the idle clock,
688/// serializes all state mutation, fans out presence deltas, reaps stale presence,
689/// and hibernates (removing itself from the registry) after the idle window with
690/// zero members.
691async fn run_room(
692    room_id: String,
693    members: Arc<AtomicUsize>,
694    broadcast_tx: broadcast::Sender<Frame>,
695    mut cmd_rx: mpsc::UnboundedReceiver<RoomCommand>,
696    registry: Weak<Mutex<RoomMap>>,
697    config: RoomConfig,
698) {
699    // Presence: member_id -> (latest value, last heartbeat). Never persisted.
700    let mut presence: HashMap<String, (Value, Instant)> = HashMap::new();
701    // Typed connections: conn_id -> its private targeted-delivery channel. Used only
702    // by `send_event`; broadcasts never touch this map. Dropped wholesale on
703    // hibernation (targeted delivery is ephemeral, exactly like presence/broadcast).
704    let mut conns: HashMap<ConnId, mpsc::UnboundedSender<Frame>> = HashMap::new();
705    // Invariant: `empty_since` is `Some` whenever members == 0. Armed at birth so a
706    // room created without any join still hibernates (no leak); cleared on join.
707    let mut empty_since: Option<Instant> = Some(Instant::now());
708
709    let mut sweep = tokio::time::interval(config.sweep_interval);
710    sweep.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
711    // The immediate first tick carries no elapsed time; skip it.
712    sweep.tick().await;
713
714    loop {
715        tokio::select! {
716            cmd = cmd_rx.recv() => {
717                match cmd {
718                    // All handles dropped: nothing can ever join again. Exit.
719                    None => {
720                        evict(&registry, &room_id, "all handles dropped");
721                        return;
722                    }
723                    Some(RoomCommand::Joined) => {
724                        empty_since = None;
725                    }
726                    Some(RoomCommand::Left { member_id }) => {
727                        if presence.remove(&member_id).is_some() {
728                            let _ = broadcast_tx.send(Frame::Presence(presence_leave(&member_id)));
729                        }
730                        if members.load(Ordering::SeqCst) == 0 {
731                            empty_since = Some(Instant::now());
732                        }
733                    }
734                    Some(RoomCommand::Presence { member_id, value }) => {
735                        presence.insert(member_id, (value.clone(), Instant::now()));
736                        let _ = broadcast_tx.send(Frame::Presence(value));
737                    }
738                    Some(RoomCommand::PresenceMembers { reply }) => {
739                        let mut ids: Vec<String> = presence.keys().cloned().collect();
740                        ids.sort();
741                        let _ = reply.send(ids);
742                    }
743                    Some(RoomCommand::OpenConn { conn_id, tx }) => {
744                        conns.insert(conn_id, tx);
745                    }
746                    Some(RoomCommand::CloseConn { conn_id }) => {
747                        conns.remove(&conn_id);
748                    }
749                    Some(RoomCommand::SendTo { conn_id, frame }) => {
750                        if let Some(tx) = conns.get(&conn_id) {
751                            // Prune on the failed send so a connection that vanished
752                            // without its CloseConn being processed is still reaped.
753                            if tx.send(frame).is_err() {
754                                conns.remove(&conn_id);
755                            }
756                        }
757                    }
758                    Some(RoomCommand::ConnCount { reply }) => {
759                        let _ = reply.send(conns.len());
760                    }
761                }
762            }
763            _ = sweep.tick() => {
764                // Reap stale presence and broadcast a leave delta for each.
765                let ttl = config.presence_ttl;
766                let stale: Vec<String> = presence
767                    .iter()
768                    .filter(|(_, (_, seen))| seen.elapsed() >= ttl)
769                    .map(|(id, _)| id.clone())
770                    .collect();
771                for id in stale {
772                    presence.remove(&id);
773                    let _ = broadcast_tx.send(Frame::Presence(presence_leave(&id)));
774                }
775
776                // Hibernate if empty for the idle window. The recheck under the
777                // registry lock serializes against `join`'s increment, so a join
778                // racing the eviction can never be lost.
779                if let Some(since) = empty_since {
780                    if since.elapsed() >= config.idle_window
781                        && try_evict(&registry, &room_id, &members)
782                    {
783                        return;
784                    }
785                }
786            }
787        }
788    }
789}
790
791/// Build a `presence_leave` delta frame body for a departed/reaped member.
792fn presence_leave(member_id: &str) -> Value {
793    json!({ "type": "presence_leave", "member_id": member_id })
794}
795
796/// Attempt eviction under the registry lock, rechecking member count so a join
797/// that incremented after the actor's last observation aborts the eviction.
798/// Returns `true` if the room was removed (actor should exit).
799fn try_evict(registry: &Weak<Mutex<RoomMap>>, room_id: &str, members: &Arc<AtomicUsize>) -> bool {
800    let Some(map) = registry.upgrade() else {
801        // Registry gone (server shutdown): nothing to remove, just exit.
802        return true;
803    };
804    let mut map = map.lock().unwrap_or_else(|e| e.into_inner());
805    if members.load(Ordering::SeqCst) != 0 {
806        // A member joined in the gap; stay alive.
807        return false;
808    }
809    map.remove(room_id);
810    tracing::info!(room_id, "realtime: hibernating idle room (0 members)");
811    true
812}
813
814/// Remove the room from the registry on a non-idle exit path (all handles
815/// dropped) and log it.
816fn evict(registry: &Weak<Mutex<RoomMap>>, room_id: &str, reason: &str) {
817    if let Some(map) = registry.upgrade() {
818        map.lock()
819            .unwrap_or_else(|e| e.into_inner())
820            .remove(room_id);
821    }
822    tracing::info!(room_id, reason, "realtime: evicting room");
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828
829    fn fast_config() -> RoomConfig {
830        RoomConfig {
831            idle_window: Duration::from_millis(80),
832            presence_ttl: Duration::from_millis(100),
833            sweep_interval: Duration::from_millis(20),
834        }
835    }
836
837    #[tokio::test]
838    async fn get_or_create_is_idempotent() {
839        let reg = RoomRegistry::new();
840        let a = reg.get_or_create("room-1");
841        let b = reg.get_or_create("room-1");
842        // Same underlying broadcast channel: a frame on one reaches a receiver of
843        // the other.
844        let mut rx = b.subscribe();
845        a.publish_event(json!({"n": 1}));
846        let frame = rx.recv().await.expect("frame");
847        match frame {
848            Frame::Event(v) => assert_eq!(v["n"], 1),
849            _ => panic!("expected event frame"),
850        }
851        assert_eq!(reg.room_count(), 1, "one logical room");
852    }
853
854    #[tokio::test]
855    async fn join_leave_member_counting() {
856        let reg = RoomRegistry::new();
857        let handle = reg.get_or_create("room-2");
858        assert_eq!(handle.member_count(), 0);
859
860        let m1 = handle.join("alice");
861        assert_eq!(handle.member_count(), 1);
862        let m2 = handle.join("bob");
863        assert_eq!(handle.member_count(), 2);
864
865        drop(m2);
866        assert_eq!(handle.member_count(), 1);
867
868        // Explicit leave is idempotent with Drop.
869        let mut m1 = m1;
870        m1.leave();
871        assert_eq!(handle.member_count(), 0);
872        drop(m1);
873        assert_eq!(handle.member_count(), 0);
874    }
875
876    #[tokio::test]
877    async fn registry_join_counts_and_recreates() {
878        let reg = RoomRegistry::new();
879        // Race-safe join get-or-creates and increments under the lock.
880        let m1 = reg.join("room-j", "alice");
881        assert_eq!(reg.room_count(), 1);
882        assert_eq!(m1.handle().member_count(), 1);
883
884        let m2 = reg.join("room-j", "bob");
885        assert_eq!(m2.handle().member_count(), 2);
886
887        drop(m1);
888        drop(m2);
889        // Members gone, but the room is still mapped until the actor hibernates;
890        // a fresh join must observe a live, zero-or-recreated room and count 1.
891        let m3 = reg.join("room-j", "carol");
892        assert_eq!(m3.handle().member_count(), 1);
893        assert_eq!(reg.room_count(), 1);
894    }
895
896    #[tokio::test]
897    async fn published_event_reaches_subscriber() {
898        let reg = RoomRegistry::new();
899        let handle = reg.get_or_create("room-3");
900        let _member = handle.join("alice");
901        let mut rx = handle.subscribe();
902
903        reg.publish_event("room-3", json!({"type": "message", "id": "m1"}));
904
905        let frame = rx.recv().await.expect("frame");
906        match frame {
907            Frame::Event(v) => {
908                assert_eq!(v["type"], "message");
909                assert_eq!(v["id"], "m1");
910            }
911            _ => panic!("expected event frame"),
912        }
913        assert_eq!(handle.subscribe().len(), 0, "fresh receiver has no backlog");
914    }
915
916    #[tokio::test]
917    async fn presence_delta_is_broadcast() {
918        let reg = RoomRegistry::new();
919        let handle = reg.get_or_create("room-4");
920        let member = handle.join("alice");
921        let mut rx = handle.subscribe();
922
923        member.publish_presence(json!({"member_id": "alice", "cursor": [1, 2]}));
924
925        let frame = rx.recv().await.expect("frame");
926        match frame {
927            Frame::Presence(v) => assert_eq!(v["cursor"][0], 1),
928            _ => panic!("expected presence frame"),
929        }
930    }
931
932    #[tokio::test]
933    async fn presence_ttl_is_reaped() {
934        let reg = RoomRegistry::with_config(fast_config());
935        let handle = reg.get_or_create("room-5");
936        // Hold membership so the room does not hibernate while we wait for the
937        // presence sweep (presence reaping is independent of membership).
938        let member = handle.join("alice");
939        let mut rx = handle.subscribe();
940
941        member.publish_presence(json!({"member_id": "alice"}));
942        // Drain the initial presence upsert.
943        let _ = rx.recv().await.expect("upsert");
944        assert_eq!(handle.presence_members().await, vec!["alice".to_string()]);
945
946        // Wait past the TTL for the reaper to fire.
947        tokio::time::sleep(Duration::from_millis(220)).await;
948
949        assert!(
950            handle.presence_members().await.is_empty(),
951            "stale presence should be reaped"
952        );
953        // And a presence_leave delta should have been broadcast.
954        let mut saw_leave = false;
955        while let Ok(frame) = rx.try_recv() {
956            if let Frame::Presence(v) = frame {
957                if v["type"] == "presence_leave" {
958                    saw_leave = true;
959                }
960            }
961        }
962        assert!(saw_leave, "expected a presence_leave delta on reap");
963
964        drop(member);
965    }
966
967    #[tokio::test]
968    async fn idle_room_hibernates() {
969        let reg = RoomRegistry::with_config(fast_config());
970        let handle = reg.get_or_create("room-6");
971        {
972            let _m = handle.join("alice");
973            assert_eq!(reg.room_count(), 1);
974        } // member leaves here -> idle clock arms
975
976        // Wait past the idle window + a sweep tick.
977        tokio::time::sleep(Duration::from_millis(220)).await;
978        assert_eq!(reg.room_count(), 0, "idle room should hibernate");
979
980        // Rehydration: a fresh get_or_create spins up a new actor.
981        let handle2 = reg.get_or_create("room-6");
982        let _m2 = handle2.join("bob");
983        assert_eq!(reg.room_count(), 1, "room rehydrates on next join");
984    }
985
986    #[tokio::test]
987    async fn publish_event_to_absent_room_is_noop() {
988        let reg = RoomRegistry::new();
989        // No panic, no room created.
990        reg.publish_event("ghost", json!({"x": 1}));
991        assert_eq!(reg.room_count(), 0);
992    }
993
994    #[test]
995    fn frame_channel_tags() {
996        assert_eq!(Frame::Event(json!({})).channel(), RealtimeChannel::Events);
997        assert_eq!(
998            Frame::Presence(json!({})).channel(),
999            RealtimeChannel::Presence
1000        );
1001        assert_eq!(
1002            Frame::DocSync(vec![1, 2, 3]).channel(),
1003            RealtimeChannel::DocSync
1004        );
1005    }
1006
1007    // ── Typed named events ────────────────────────────────────────────────────
1008
1009    #[test]
1010    fn event_decode_only_matches_the_envelope() {
1011        // A real envelope round-trips name + payload.
1012        let frame = Frame::Event(encode_event("chat.message", json!({"id": "m1"})));
1013        let ev = Event::decode(&frame).expect("named event");
1014        assert_eq!(ev.name, "chat.message");
1015        assert_eq!(ev.payload["id"], "m1");
1016
1017        // A raw (non-envelope) event value is NOT a typed event — the pre-existing
1018        // `publish_event` wire is untouched and never misread as a zero-name event.
1019        assert!(Event::decode(&Frame::Event(json!({"id": "raw"}))).is_none());
1020        // Presence + DocSync frames are never typed events.
1021        assert!(Event::decode(&Frame::Presence(json!({"cursor": [1, 2]}))).is_none());
1022        assert!(Event::decode(&Frame::DocSync(vec![1, 2, 3])).is_none());
1023    }
1024
1025    #[tokio::test]
1026    async fn broadcast_event_reaches_typed_and_raw_subscribers() {
1027        let reg = RoomRegistry::new();
1028        let handle = reg.get_or_create("evt-room");
1029        let mut conn = handle.open_connection();
1030        // A raw broadcast receiver: proves the typed layer rides the existing wire
1031        // without breaking it — the frame is still an ordinary `Frame::Event`.
1032        let mut raw = handle.subscribe();
1033
1034        reg.broadcast_event("evt-room", "counter.tick", json!({"n": 7}));
1035
1036        let ev = conn.recv().await.expect("typed event");
1037        assert_eq!(ev.name, "counter.tick");
1038        assert_eq!(ev.payload["n"], 7);
1039
1040        match raw.recv().await.expect("raw frame") {
1041            Frame::Event(v) => {
1042                // Behavior-preserving: still a Frame::Event on the Events channel.
1043                let decoded = Event::decode(&Frame::Event(v)).expect("envelope");
1044                assert_eq!(decoded.name, "counter.tick");
1045            }
1046            other => panic!("expected Frame::Event, got {other:?}"),
1047        }
1048    }
1049
1050    #[tokio::test]
1051    async fn plugin_contributions_broadcast_payload_is_self_describing() {
1052        // Pins the wire contract of the first production consumer (Core's plugin
1053        // enable/disable/grants handlers → the desktop's `system:plugins`
1054        // subscription): the WS gateway's `frame_to_message` strips the envelope
1055        // (and with it the event NAME) before the client sees it, so the payload
1056        // itself must carry the discriminant the desktop keys off.
1057        let reg = RoomRegistry::new();
1058        let handle = reg.get_or_create("system:plugins");
1059        let mut raw = handle.subscribe();
1060
1061        reg.broadcast_event(
1062            "system:plugins",
1063            "plugin.contributions.changed",
1064            json!({"type": "contributions_changed"}),
1065        );
1066
1067        match raw.recv().await.expect("raw frame") {
1068            frame @ Frame::Event(_) => {
1069                let ev = Event::decode(&frame).expect("envelope");
1070                assert_eq!(ev.name, "plugin.contributions.changed");
1071                // What survives to the client after the envelope is unwrapped.
1072                assert_eq!(ev.payload["type"], "contributions_changed");
1073            }
1074            other => panic!("expected Frame::Event, got {other:?}"),
1075        }
1076    }
1077
1078    #[tokio::test]
1079    async fn send_event_is_isolated_to_its_connection() {
1080        let reg = RoomRegistry::new();
1081        let handle = reg.get_or_create("target-room");
1082        let mut conn_a = handle.open_connection();
1083        let mut conn_b = handle.open_connection();
1084        let mut raw = handle.subscribe();
1085        let a_id = conn_a.id();
1086
1087        handle.send_event(a_id, "secret", json!({"for": "a"}));
1088        // Round-trip through the actor so the targeted delivery is guaranteed queued
1089        // before we broadcast — makes the ordering below deterministic.
1090        assert_eq!(handle.conn_count().await, 2);
1091        handle.broadcast_event("marker", json!({}));
1092
1093        // conn_a sees its private event first (biased), then the broadcast.
1094        let first = conn_a.recv().await.expect("a first");
1095        assert_eq!(first.name, "secret");
1096        assert_eq!(first.payload["for"], "a");
1097        let second = conn_a.recv().await.expect("a second");
1098        assert_eq!(second.name, "marker");
1099
1100        // conn_b NEVER sees the targeted event — its first (and only) event is the
1101        // broadcast marker. This is the core isolation guarantee.
1102        let b_first = conn_b.recv().await.expect("b first");
1103        assert_eq!(b_first.name, "marker");
1104
1105        // The raw broadcast subscriber likewise only ever saw the broadcast, not the
1106        // targeted send (targeted delivery never touches the broadcast channel).
1107        match raw.recv().await.expect("raw first") {
1108            Frame::Event(v) => {
1109                assert_eq!(v[EVENT_NAME_KEY], "marker");
1110            }
1111            other => panic!("expected marker frame, got {other:?}"),
1112        }
1113        assert!(raw.try_recv().is_err(), "raw saw exactly one frame");
1114    }
1115
1116    #[tokio::test]
1117    async fn dropping_a_connection_prunes_it_from_the_actor() {
1118        let reg = RoomRegistry::new();
1119        let handle = reg.get_or_create("drop-room");
1120        let conn_a = handle.open_connection();
1121        let conn_b = handle.open_connection();
1122        assert_eq!(handle.conn_count().await, 2);
1123
1124        let a_id = conn_a.id();
1125        drop(conn_a);
1126        // CloseConn is ordered before this ConnCount on the same command channel.
1127        assert_eq!(handle.conn_count().await, 1);
1128
1129        // A targeted send to the dropped connection is now a no-op; conn_b (still
1130        // open) receives the following broadcast, proving the room is healthy.
1131        handle.send_event(a_id, "ghost", json!({}));
1132        handle.broadcast_event("alive", json!({}));
1133        let mut conn_b = conn_b;
1134        assert_eq!(conn_b.recv().await.expect("b").name, "alive");
1135    }
1136
1137    #[tokio::test]
1138    async fn typed_reader_skips_non_event_frames() {
1139        let reg = RoomRegistry::new();
1140        let handle = reg.get_or_create("skip-room");
1141        let mut conn = handle.open_connection();
1142
1143        // A raw (non-envelope) event, then a real named event — both synchronous on
1144        // the broadcast channel, so the raw one is delivered first and must be
1145        // skipped, surfacing only the named event.
1146        handle.publish_event(json!({"legacy": true}));
1147        handle.broadcast_event("real", json!({"ok": 1}));
1148
1149        let ev = conn.recv().await.expect("named event");
1150        assert_eq!(ev.name, "real");
1151        assert_eq!(ev.payload["ok"], 1);
1152    }
1153
1154    #[test]
1155    fn conn_ids_are_process_unique_and_monotonic() {
1156        let a = ConnId::next();
1157        let b = ConnId::next();
1158        assert_ne!(a, b);
1159        assert!(b.get() > a.get());
1160    }
1161}