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(®istry, &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(®istry, &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}