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