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(&self, room_id: &str, conn: ConnId, name: impl Into<String>, payload: Value) {
359        if let Some(handle) = self.lock().get(room_id) {
360            handle.send_event(conn, name, payload);
361        }
362    }
363
364    /// Number of live (non-hibernated) rooms. Primarily for tests/diagnostics.
365    pub fn room_count(&self) -> usize {
366        self.lock().len()
367    }
368
369    /// Spawn a room actor and build its handle. Caller must hold the registry lock
370    /// and insert the returned handle.
371    fn spawn_room(&self, room_id: String) -> RoomHandle {
372        let (broadcast_tx, _rx) = broadcast::channel(BROADCAST_CAPACITY);
373        let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
374        let members = Arc::new(AtomicUsize::new(0));
375
376        let handle = RoomHandle {
377            room_id: room_id.clone(),
378            broadcast: broadcast_tx.clone(),
379            cmd: cmd_tx,
380            members: Arc::clone(&members),
381        };
382
383        let registry = Arc::downgrade(&self.inner);
384        let config = self.config;
385        tokio::spawn(run_room(
386            room_id,
387            members,
388            broadcast_tx,
389            cmd_rx,
390            registry,
391            config,
392        ));
393
394        handle
395    }
396
397    fn lock(&self) -> std::sync::MutexGuard<'_, RoomMap> {
398        self.inner.lock().unwrap_or_else(|e| e.into_inner())
399    }
400}
401
402impl Default for RoomRegistry {
403    fn default() -> Self {
404        Self::new()
405    }
406}
407
408// ── Handle ───────────────────────────────────────────────────────────────────
409
410/// A cloneable handle to a live room: the broadcast sender for fan-out, the
411/// command channel to the actor, and the shared member counter. Obtained from
412/// [`RoomRegistry::get_or_create`].
413#[derive(Clone)]
414pub struct RoomHandle {
415    room_id: String,
416    broadcast: broadcast::Sender<Frame>,
417    cmd: mpsc::UnboundedSender<RoomCommand>,
418    members: Arc<AtomicUsize>,
419}
420
421impl RoomHandle {
422    /// The room id this handle addresses.
423    pub fn room_id(&self) -> &str {
424        &self.room_id
425    }
426
427    /// Subscribe to this room's fan-out. Multiple receivers are allowed; each sees
428    /// every frame published after it subscribed.
429    pub fn subscribe(&self) -> broadcast::Receiver<Frame> {
430        self.broadcast.subscribe()
431    }
432
433    /// Current member count.
434    pub fn member_count(&self) -> usize {
435        self.members.load(Ordering::SeqCst)
436    }
437
438    /// Join this room as `member_id`, returning an RAII [`RoomMembership`] guard.
439    /// Dropping the guard leaves the room.
440    ///
441    /// NOT race-safe against hibernation: the increment happens outside the
442    /// registry lock, so a room that hibernated between obtaining this handle and
443    /// this call yields an orphaned membership. Concurrent callers must use
444    /// [`RoomRegistry::join`] instead; this method is for single-threaded tests.
445    pub fn join(&self, member_id: impl Into<String>) -> RoomMembership {
446        self.members.fetch_add(1, Ordering::SeqCst);
447        let _ = self.cmd.send(RoomCommand::Joined);
448        RoomMembership {
449            handle: self.clone(),
450            member_id: member_id.into(),
451            left: false,
452        }
453    }
454
455    /// Publish an Events frame to this room.
456    pub fn publish_event(&self, value: Value) {
457        let _ = self.broadcast.send(Frame::Event(value));
458    }
459
460    /// Publish a presence delta for `member_id` (upsert + TTL + broadcast).
461    pub fn publish_presence(&self, member_id: &str, value: Value) {
462        let _ = self.cmd.send(RoomCommand::Presence {
463            member_id: member_id.to_string(),
464            value,
465        });
466    }
467
468    /// Publish an opaque DocSync (binary) frame, passed through untouched. Phase 3
469    /// CRDT updates ride this channel.
470    pub fn publish_doc_sync(&self, bytes: Vec<u8>) {
471        let _ = self.broadcast.send(Frame::DocSync(bytes));
472    }
473
474    /// Broadcast a typed named event to **every** subscriber of this room (Rivet's
475    /// `broadcast(event, payload)`). Rides the ordinary `Frame::Event` channel as an
476    /// envelope, so raw `subscribe()` receivers still get it and typed
477    /// [`Connection`]s decode it into an [`Event`].
478    pub fn broadcast_event(&self, name: impl Into<String>, payload: Value) {
479        let _ = self
480            .broadcast
481            .send(Frame::Event(encode_event(name, payload)));
482    }
483
484    /// Deliver a typed named event to exactly **one** connection (Rivet's
485    /// `conn.send(event, payload)`). Unlike [`broadcast_event`], this never touches
486    /// the shared broadcast, so no other connection and no raw broadcast subscriber
487    /// observes it. No-op if `conn` has closed or the room actor has exited.
488    ///
489    /// [`broadcast_event`]: RoomHandle::broadcast_event
490    pub fn send_event(&self, conn: ConnId, name: impl Into<String>, payload: Value) {
491        let _ = self.cmd.send(RoomCommand::SendTo {
492            conn_id: conn,
493            frame: Frame::Event(encode_event(name, payload)),
494        });
495    }
496
497    /// Open a typed subscriber [`Connection`] on this room: it receives both
498    /// broadcasts and events addressed to its [`ConnId`] via [`send_event`], and
499    /// unregisters itself on `Drop` (the RAII unsubscribe handle). Distinct from the
500    /// raw [`subscribe`] receiver, which is broadcast-only and cannot be targeted.
501    ///
502    /// [`send_event`]: RoomHandle::send_event
503    /// [`subscribe`]: RoomHandle::subscribe
504    pub fn open_connection(&self) -> Connection {
505        let conn_id = ConnId::next();
506        let (tx, targeted_rx) = mpsc::unbounded_channel();
507        let _ = self.cmd.send(RoomCommand::OpenConn { conn_id, tx });
508        Connection {
509            conn_id,
510            cmd: self.cmd.clone(),
511            broadcast_rx: self.broadcast.subscribe(),
512            targeted_rx,
513            broadcast_open: true,
514            targeted_open: true,
515        }
516    }
517
518    /// Snapshot the number of registered typed connections (diagnostic / test
519    /// helper). Returns 0 if the actor has already exited.
520    pub async fn conn_count(&self) -> usize {
521        let (reply, rx) = oneshot::channel();
522        if self.cmd.send(RoomCommand::ConnCount { reply }).is_err() {
523            return 0;
524        }
525        rx.await.unwrap_or(0)
526    }
527
528    /// Snapshot the live presence member ids (diagnostic / test helper). Returns
529    /// an empty vec if the actor has already exited.
530    pub async fn presence_members(&self) -> Vec<String> {
531        let (reply, rx) = oneshot::channel();
532        if self
533            .cmd
534            .send(RoomCommand::PresenceMembers { reply })
535            .is_err()
536        {
537            return Vec::new();
538        }
539        rx.await.unwrap_or_default()
540    }
541}
542
543// ── Membership guard ─────────────────────────────────────────────────────────
544
545/// RAII guard for one member's presence in a room. Created by
546/// [`RoomHandle::join`]. On `Drop` (or explicit [`RoomMembership::leave`]) it
547/// decrements the member count, evicts this member's presence, and broadcasts a
548/// `presence_leave` delta — so an abrupt disconnect is still reaped.
549pub struct RoomMembership {
550    handle: RoomHandle,
551    member_id: String,
552    left: bool,
553}
554
555impl RoomMembership {
556    /// The member id this guard represents.
557    pub fn member_id(&self) -> &str {
558        &self.member_id
559    }
560
561    /// The room this membership is in.
562    pub fn handle(&self) -> &RoomHandle {
563        &self.handle
564    }
565
566    /// Subscribe to the room's fan-out (each call yields a fresh receiver).
567    pub fn subscribe(&self) -> broadcast::Receiver<Frame> {
568        self.handle.subscribe()
569    }
570
571    /// Publish this member's presence (cursor/typing/etc.).
572    pub fn publish_presence(&self, value: Value) {
573        self.handle.publish_presence(&self.member_id, value);
574    }
575
576    /// Open a typed subscriber [`Connection`] on this member's room (convenience for
577    /// [`RoomHandle::open_connection`]).
578    pub fn open_connection(&self) -> Connection {
579        self.handle.open_connection()
580    }
581
582    /// Explicitly leave now (idempotent; `Drop` also calls this).
583    pub fn leave(&mut self) {
584        if self.left {
585            return;
586        }
587        self.left = true;
588        self.handle.members.fetch_sub(1, Ordering::SeqCst);
589        let _ = self.handle.cmd.send(RoomCommand::Left {
590            member_id: self.member_id.clone(),
591        });
592    }
593}
594
595impl Drop for RoomMembership {
596    fn drop(&mut self) {
597        self.leave();
598    }
599}
600
601// ── Typed connection (subscribe = unsubscribe-on-drop handle) ────────────────
602
603/// A typed subscriber to one room, addressable by its [`ConnId`]. It merges the
604/// room's broadcast fan-out with events [`RoomHandle::send_event`] delivers to it
605/// privately, decoding each into a typed [`Event`]. Dropping it unregisters the
606/// targeted channel from the room actor — the RAII unsubscribe handle, the same
607/// pattern Rivet's `actor.on(...)` teardown gives you.
608///
609/// Non-event frames (presence deltas, DocSync blobs, raw non-envelope
610/// `publish_event` values) are skipped by [`recv`], never surfaced as bogus events;
611/// consumers that need the raw wire use [`RoomHandle::subscribe`] instead.
612///
613/// [`recv`]: Connection::recv
614pub struct Connection {
615    conn_id: ConnId,
616    cmd: mpsc::UnboundedSender<RoomCommand>,
617    broadcast_rx: broadcast::Receiver<Frame>,
618    targeted_rx: mpsc::UnboundedReceiver<Frame>,
619    broadcast_open: bool,
620    targeted_open: bool,
621}
622
623impl Connection {
624    /// This connection's process-unique id — the address for
625    /// [`RoomHandle::send_event`].
626    pub fn id(&self) -> ConnId {
627        self.conn_id
628    }
629
630    /// Await the next typed [`Event`] for this connection, from either the room
631    /// broadcast or a targeted `send_event`. Non-event frames are skipped. Returns
632    /// `None` once both delivery paths are permanently closed (the room actor exited
633    /// and the broadcast channel is drained), so it drives a `while let` loop.
634    pub async fn recv(&mut self) -> Option<Event> {
635        loop {
636            if !self.broadcast_open && !self.targeted_open {
637                return None;
638            }
639            let frame = tokio::select! {
640                // Prefer targeted delivery so a private send is never starved by a
641                // busy broadcast stream.
642                biased;
643                targeted = self.targeted_rx.recv(), if self.targeted_open => match targeted {
644                    Some(frame) => frame,
645                    None => {
646                        // Actor dropped the sender (room hibernated/exited): stop
647                        // polling this arm and fall back to draining the broadcast.
648                        self.targeted_open = false;
649                        continue;
650                    }
651                },
652                broadcast = self.broadcast_rx.recv(), if self.broadcast_open => match broadcast {
653                    Ok(frame) => frame,
654                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
655                    Err(broadcast::error::RecvError::Closed) => {
656                        self.broadcast_open = false;
657                        continue;
658                    }
659                },
660            };
661            if let Some(event) = Event::decode(&frame) {
662                return Some(event);
663            }
664            // Non-event frame (presence / doc-sync / raw value): skip and keep waiting.
665        }
666    }
667}
668
669impl Drop for Connection {
670    fn drop(&mut self) {
671        // RAII unsubscribe: unregister our targeted channel from the actor. Best
672        // effort — if the actor already exited the send simply fails.
673        let _ = self.cmd.send(RoomCommand::CloseConn {
674            conn_id: self.conn_id,
675        });
676    }
677}
678
679// ── Room actor ───────────────────────────────────────────────────────────────
680
681/// Per-room actor task. Owns the ephemeral presence map and the idle clock,
682/// serializes all state mutation, fans out presence deltas, reaps stale presence,
683/// and hibernates (removing itself from the registry) after the idle window with
684/// zero members.
685async fn run_room(
686    room_id: String,
687    members: Arc<AtomicUsize>,
688    broadcast_tx: broadcast::Sender<Frame>,
689    mut cmd_rx: mpsc::UnboundedReceiver<RoomCommand>,
690    registry: Weak<Mutex<RoomMap>>,
691    config: RoomConfig,
692) {
693    // Presence: member_id -> (latest value, last heartbeat). Never persisted.
694    let mut presence: HashMap<String, (Value, Instant)> = HashMap::new();
695    // Typed connections: conn_id -> its private targeted-delivery channel. Used only
696    // by `send_event`; broadcasts never touch this map. Dropped wholesale on
697    // hibernation (targeted delivery is ephemeral, exactly like presence/broadcast).
698    let mut conns: HashMap<ConnId, mpsc::UnboundedSender<Frame>> = HashMap::new();
699    // Invariant: `empty_since` is `Some` whenever members == 0. Armed at birth so a
700    // room created without any join still hibernates (no leak); cleared on join.
701    let mut empty_since: Option<Instant> = Some(Instant::now());
702
703    let mut sweep = tokio::time::interval(config.sweep_interval);
704    sweep.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
705    // The immediate first tick carries no elapsed time; skip it.
706    sweep.tick().await;
707
708    loop {
709        tokio::select! {
710            cmd = cmd_rx.recv() => {
711                match cmd {
712                    // All handles dropped: nothing can ever join again. Exit.
713                    None => {
714                        evict(&registry, &room_id, "all handles dropped");
715                        return;
716                    }
717                    Some(RoomCommand::Joined) => {
718                        empty_since = None;
719                    }
720                    Some(RoomCommand::Left { member_id }) => {
721                        if presence.remove(&member_id).is_some() {
722                            let _ = broadcast_tx.send(Frame::Presence(presence_leave(&member_id)));
723                        }
724                        if members.load(Ordering::SeqCst) == 0 {
725                            empty_since = Some(Instant::now());
726                        }
727                    }
728                    Some(RoomCommand::Presence { member_id, value }) => {
729                        presence.insert(member_id, (value.clone(), Instant::now()));
730                        let _ = broadcast_tx.send(Frame::Presence(value));
731                    }
732                    Some(RoomCommand::PresenceMembers { reply }) => {
733                        let mut ids: Vec<String> = presence.keys().cloned().collect();
734                        ids.sort();
735                        let _ = reply.send(ids);
736                    }
737                    Some(RoomCommand::OpenConn { conn_id, tx }) => {
738                        conns.insert(conn_id, tx);
739                    }
740                    Some(RoomCommand::CloseConn { conn_id }) => {
741                        conns.remove(&conn_id);
742                    }
743                    Some(RoomCommand::SendTo { conn_id, frame }) => {
744                        if let Some(tx) = conns.get(&conn_id) {
745                            // Prune on the failed send so a connection that vanished
746                            // without its CloseConn being processed is still reaped.
747                            if tx.send(frame).is_err() {
748                                conns.remove(&conn_id);
749                            }
750                        }
751                    }
752                    Some(RoomCommand::ConnCount { reply }) => {
753                        let _ = reply.send(conns.len());
754                    }
755                }
756            }
757            _ = sweep.tick() => {
758                // Reap stale presence and broadcast a leave delta for each.
759                let ttl = config.presence_ttl;
760                let stale: Vec<String> = presence
761                    .iter()
762                    .filter(|(_, (_, seen))| seen.elapsed() >= ttl)
763                    .map(|(id, _)| id.clone())
764                    .collect();
765                for id in stale {
766                    presence.remove(&id);
767                    let _ = broadcast_tx.send(Frame::Presence(presence_leave(&id)));
768                }
769
770                // Hibernate if empty for the idle window. The recheck under the
771                // registry lock serializes against `join`'s increment, so a join
772                // racing the eviction can never be lost.
773                if let Some(since) = empty_since {
774                    if since.elapsed() >= config.idle_window
775                        && try_evict(&registry, &room_id, &members)
776                    {
777                        return;
778                    }
779                }
780            }
781        }
782    }
783}
784
785/// Build a `presence_leave` delta frame body for a departed/reaped member.
786fn presence_leave(member_id: &str) -> Value {
787    json!({ "type": "presence_leave", "member_id": member_id })
788}
789
790/// Attempt eviction under the registry lock, rechecking member count so a join
791/// that incremented after the actor's last observation aborts the eviction.
792/// Returns `true` if the room was removed (actor should exit).
793fn try_evict(registry: &Weak<Mutex<RoomMap>>, room_id: &str, members: &Arc<AtomicUsize>) -> bool {
794    let Some(map) = registry.upgrade() else {
795        // Registry gone (server shutdown): nothing to remove, just exit.
796        return true;
797    };
798    let mut map = map.lock().unwrap_or_else(|e| e.into_inner());
799    if members.load(Ordering::SeqCst) != 0 {
800        // A member joined in the gap; stay alive.
801        return false;
802    }
803    map.remove(room_id);
804    tracing::info!(room_id, "realtime: hibernating idle room (0 members)");
805    true
806}
807
808/// Remove the room from the registry on a non-idle exit path (all handles
809/// dropped) and log it.
810fn evict(registry: &Weak<Mutex<RoomMap>>, room_id: &str, reason: &str) {
811    if let Some(map) = registry.upgrade() {
812        map.lock()
813            .unwrap_or_else(|e| e.into_inner())
814            .remove(room_id);
815    }
816    tracing::info!(room_id, reason, "realtime: evicting room");
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822
823    fn fast_config() -> RoomConfig {
824        RoomConfig {
825            idle_window: Duration::from_millis(80),
826            presence_ttl: Duration::from_millis(100),
827            sweep_interval: Duration::from_millis(20),
828        }
829    }
830
831    #[tokio::test]
832    async fn get_or_create_is_idempotent() {
833        let reg = RoomRegistry::new();
834        let a = reg.get_or_create("room-1");
835        let b = reg.get_or_create("room-1");
836        // Same underlying broadcast channel: a frame on one reaches a receiver of
837        // the other.
838        let mut rx = b.subscribe();
839        a.publish_event(json!({"n": 1}));
840        let frame = rx.recv().await.expect("frame");
841        match frame {
842            Frame::Event(v) => assert_eq!(v["n"], 1),
843            _ => panic!("expected event frame"),
844        }
845        assert_eq!(reg.room_count(), 1, "one logical room");
846    }
847
848    #[tokio::test]
849    async fn join_leave_member_counting() {
850        let reg = RoomRegistry::new();
851        let handle = reg.get_or_create("room-2");
852        assert_eq!(handle.member_count(), 0);
853
854        let m1 = handle.join("alice");
855        assert_eq!(handle.member_count(), 1);
856        let m2 = handle.join("bob");
857        assert_eq!(handle.member_count(), 2);
858
859        drop(m2);
860        assert_eq!(handle.member_count(), 1);
861
862        // Explicit leave is idempotent with Drop.
863        let mut m1 = m1;
864        m1.leave();
865        assert_eq!(handle.member_count(), 0);
866        drop(m1);
867        assert_eq!(handle.member_count(), 0);
868    }
869
870    #[tokio::test]
871    async fn registry_join_counts_and_recreates() {
872        let reg = RoomRegistry::new();
873        // Race-safe join get-or-creates and increments under the lock.
874        let m1 = reg.join("room-j", "alice");
875        assert_eq!(reg.room_count(), 1);
876        assert_eq!(m1.handle().member_count(), 1);
877
878        let m2 = reg.join("room-j", "bob");
879        assert_eq!(m2.handle().member_count(), 2);
880
881        drop(m1);
882        drop(m2);
883        // Members gone, but the room is still mapped until the actor hibernates;
884        // a fresh join must observe a live, zero-or-recreated room and count 1.
885        let m3 = reg.join("room-j", "carol");
886        assert_eq!(m3.handle().member_count(), 1);
887        assert_eq!(reg.room_count(), 1);
888    }
889
890    #[tokio::test]
891    async fn published_event_reaches_subscriber() {
892        let reg = RoomRegistry::new();
893        let handle = reg.get_or_create("room-3");
894        let _member = handle.join("alice");
895        let mut rx = handle.subscribe();
896
897        reg.publish_event("room-3", json!({"type": "message", "id": "m1"}));
898
899        let frame = rx.recv().await.expect("frame");
900        match frame {
901            Frame::Event(v) => {
902                assert_eq!(v["type"], "message");
903                assert_eq!(v["id"], "m1");
904            }
905            _ => panic!("expected event frame"),
906        }
907        assert_eq!(handle.subscribe().len(), 0, "fresh receiver has no backlog");
908    }
909
910    #[tokio::test]
911    async fn presence_delta_is_broadcast() {
912        let reg = RoomRegistry::new();
913        let handle = reg.get_or_create("room-4");
914        let member = handle.join("alice");
915        let mut rx = handle.subscribe();
916
917        member.publish_presence(json!({"member_id": "alice", "cursor": [1, 2]}));
918
919        let frame = rx.recv().await.expect("frame");
920        match frame {
921            Frame::Presence(v) => assert_eq!(v["cursor"][0], 1),
922            _ => panic!("expected presence frame"),
923        }
924    }
925
926    #[tokio::test]
927    async fn presence_ttl_is_reaped() {
928        let reg = RoomRegistry::with_config(fast_config());
929        let handle = reg.get_or_create("room-5");
930        // Hold membership so the room does not hibernate while we wait for the
931        // presence sweep (presence reaping is independent of membership).
932        let member = handle.join("alice");
933        let mut rx = handle.subscribe();
934
935        member.publish_presence(json!({"member_id": "alice"}));
936        // Drain the initial presence upsert.
937        let _ = rx.recv().await.expect("upsert");
938        assert_eq!(handle.presence_members().await, vec!["alice".to_string()]);
939
940        // Wait past the TTL for the reaper to fire.
941        tokio::time::sleep(Duration::from_millis(220)).await;
942
943        assert!(
944            handle.presence_members().await.is_empty(),
945            "stale presence should be reaped"
946        );
947        // And a presence_leave delta should have been broadcast.
948        let mut saw_leave = false;
949        while let Ok(frame) = rx.try_recv() {
950            if let Frame::Presence(v) = frame {
951                if v["type"] == "presence_leave" {
952                    saw_leave = true;
953                }
954            }
955        }
956        assert!(saw_leave, "expected a presence_leave delta on reap");
957
958        drop(member);
959    }
960
961    #[tokio::test]
962    async fn idle_room_hibernates() {
963        let reg = RoomRegistry::with_config(fast_config());
964        let handle = reg.get_or_create("room-6");
965        {
966            let _m = handle.join("alice");
967            assert_eq!(reg.room_count(), 1);
968        } // member leaves here -> idle clock arms
969
970        // Wait past the idle window + a sweep tick.
971        tokio::time::sleep(Duration::from_millis(220)).await;
972        assert_eq!(reg.room_count(), 0, "idle room should hibernate");
973
974        // Rehydration: a fresh get_or_create spins up a new actor.
975        let handle2 = reg.get_or_create("room-6");
976        let _m2 = handle2.join("bob");
977        assert_eq!(reg.room_count(), 1, "room rehydrates on next join");
978    }
979
980    #[tokio::test]
981    async fn publish_event_to_absent_room_is_noop() {
982        let reg = RoomRegistry::new();
983        // No panic, no room created.
984        reg.publish_event("ghost", json!({"x": 1}));
985        assert_eq!(reg.room_count(), 0);
986    }
987
988    #[test]
989    fn frame_channel_tags() {
990        assert_eq!(Frame::Event(json!({})).channel(), RealtimeChannel::Events);
991        assert_eq!(
992            Frame::Presence(json!({})).channel(),
993            RealtimeChannel::Presence
994        );
995        assert_eq!(
996            Frame::DocSync(vec![1, 2, 3]).channel(),
997            RealtimeChannel::DocSync
998        );
999    }
1000
1001    // ── Typed named events ────────────────────────────────────────────────────
1002
1003    #[test]
1004    fn event_decode_only_matches_the_envelope() {
1005        // A real envelope round-trips name + payload.
1006        let frame = Frame::Event(encode_event("chat.message", json!({"id": "m1"})));
1007        let ev = Event::decode(&frame).expect("named event");
1008        assert_eq!(ev.name, "chat.message");
1009        assert_eq!(ev.payload["id"], "m1");
1010
1011        // A raw (non-envelope) event value is NOT a typed event — the pre-existing
1012        // `publish_event` wire is untouched and never misread as a zero-name event.
1013        assert!(Event::decode(&Frame::Event(json!({"id": "raw"}))).is_none());
1014        // Presence + DocSync frames are never typed events.
1015        assert!(Event::decode(&Frame::Presence(json!({"cursor": [1, 2]}))).is_none());
1016        assert!(Event::decode(&Frame::DocSync(vec![1, 2, 3])).is_none());
1017    }
1018
1019    #[tokio::test]
1020    async fn broadcast_event_reaches_typed_and_raw_subscribers() {
1021        let reg = RoomRegistry::new();
1022        let handle = reg.get_or_create("evt-room");
1023        let mut conn = handle.open_connection();
1024        // A raw broadcast receiver: proves the typed layer rides the existing wire
1025        // without breaking it — the frame is still an ordinary `Frame::Event`.
1026        let mut raw = handle.subscribe();
1027
1028        reg.broadcast_event("evt-room", "counter.tick", json!({"n": 7}));
1029
1030        let ev = conn.recv().await.expect("typed event");
1031        assert_eq!(ev.name, "counter.tick");
1032        assert_eq!(ev.payload["n"], 7);
1033
1034        match raw.recv().await.expect("raw frame") {
1035            Frame::Event(v) => {
1036                // Behavior-preserving: still a Frame::Event on the Events channel.
1037                let decoded = Event::decode(&Frame::Event(v)).expect("envelope");
1038                assert_eq!(decoded.name, "counter.tick");
1039            }
1040            other => panic!("expected Frame::Event, got {other:?}"),
1041        }
1042    }
1043
1044    #[tokio::test]
1045    async fn plugin_contributions_broadcast_payload_is_self_describing() {
1046        // Pins the wire contract of the first production consumer (Core's plugin
1047        // enable/disable/grants handlers → the desktop's `system:plugins`
1048        // subscription): the WS gateway's `frame_to_message` strips the envelope
1049        // (and with it the event NAME) before the client sees it, so the payload
1050        // itself must carry the discriminant the desktop keys off.
1051        let reg = RoomRegistry::new();
1052        let handle = reg.get_or_create("system:plugins");
1053        let mut raw = handle.subscribe();
1054
1055        reg.broadcast_event(
1056            "system:plugins",
1057            "plugin.contributions.changed",
1058            json!({"type": "contributions_changed"}),
1059        );
1060
1061        match raw.recv().await.expect("raw frame") {
1062            frame @ Frame::Event(_) => {
1063                let ev = Event::decode(&frame).expect("envelope");
1064                assert_eq!(ev.name, "plugin.contributions.changed");
1065                // What survives to the client after the envelope is unwrapped.
1066                assert_eq!(ev.payload["type"], "contributions_changed");
1067            }
1068            other => panic!("expected Frame::Event, got {other:?}"),
1069        }
1070    }
1071
1072    #[tokio::test]
1073    async fn send_event_is_isolated_to_its_connection() {
1074        let reg = RoomRegistry::new();
1075        let handle = reg.get_or_create("target-room");
1076        let mut conn_a = handle.open_connection();
1077        let mut conn_b = handle.open_connection();
1078        let mut raw = handle.subscribe();
1079        let a_id = conn_a.id();
1080
1081        handle.send_event(a_id, "secret", json!({"for": "a"}));
1082        // Round-trip through the actor so the targeted delivery is guaranteed queued
1083        // before we broadcast — makes the ordering below deterministic.
1084        assert_eq!(handle.conn_count().await, 2);
1085        handle.broadcast_event("marker", json!({}));
1086
1087        // conn_a sees its private event first (biased), then the broadcast.
1088        let first = conn_a.recv().await.expect("a first");
1089        assert_eq!(first.name, "secret");
1090        assert_eq!(first.payload["for"], "a");
1091        let second = conn_a.recv().await.expect("a second");
1092        assert_eq!(second.name, "marker");
1093
1094        // conn_b NEVER sees the targeted event — its first (and only) event is the
1095        // broadcast marker. This is the core isolation guarantee.
1096        let b_first = conn_b.recv().await.expect("b first");
1097        assert_eq!(b_first.name, "marker");
1098
1099        // The raw broadcast subscriber likewise only ever saw the broadcast, not the
1100        // targeted send (targeted delivery never touches the broadcast channel).
1101        match raw.recv().await.expect("raw first") {
1102            Frame::Event(v) => {
1103                assert_eq!(v[EVENT_NAME_KEY], "marker");
1104            }
1105            other => panic!("expected marker frame, got {other:?}"),
1106        }
1107        assert!(raw.try_recv().is_err(), "raw saw exactly one frame");
1108    }
1109
1110    #[tokio::test]
1111    async fn dropping_a_connection_prunes_it_from_the_actor() {
1112        let reg = RoomRegistry::new();
1113        let handle = reg.get_or_create("drop-room");
1114        let conn_a = handle.open_connection();
1115        let conn_b = handle.open_connection();
1116        assert_eq!(handle.conn_count().await, 2);
1117
1118        let a_id = conn_a.id();
1119        drop(conn_a);
1120        // CloseConn is ordered before this ConnCount on the same command channel.
1121        assert_eq!(handle.conn_count().await, 1);
1122
1123        // A targeted send to the dropped connection is now a no-op; conn_b (still
1124        // open) receives the following broadcast, proving the room is healthy.
1125        handle.send_event(a_id, "ghost", json!({}));
1126        handle.broadcast_event("alive", json!({}));
1127        let mut conn_b = conn_b;
1128        assert_eq!(conn_b.recv().await.expect("b").name, "alive");
1129    }
1130
1131    #[tokio::test]
1132    async fn typed_reader_skips_non_event_frames() {
1133        let reg = RoomRegistry::new();
1134        let handle = reg.get_or_create("skip-room");
1135        let mut conn = handle.open_connection();
1136
1137        // A raw (non-envelope) event, then a real named event — both synchronous on
1138        // the broadcast channel, so the raw one is delivered first and must be
1139        // skipped, surfacing only the named event.
1140        handle.publish_event(json!({"legacy": true}));
1141        handle.broadcast_event("real", json!({"ok": 1}));
1142
1143        let ev = conn.recv().await.expect("named event");
1144        assert_eq!(ev.name, "real");
1145        assert_eq!(ev.payload["ok"], 1);
1146    }
1147
1148    #[test]
1149    fn conn_ids_are_process_unique_and_monotonic() {
1150        let a = ConnId::next();
1151        let b = ConnId::next();
1152        assert_ne!(a, b);
1153        assert!(b.get() > a.get());
1154    }
1155}