Skip to main content

cratefield_runtime_native/ports/
realtime.rs

1//! [`Realtime`] over an in-process room registry (issue #103), the self-hosted
2//! counterpart of the Cloudflare Durable Object adapter. Each room keeps its
3//! connected members' outgoing channels in a map; broadcasts fan out over
4//! them, and the shared clock is a `tokio` timer.
5//!
6//! This is the adapter the conformance suite runs against (join, broadcast to
7//! N, leave, alarm once, a non-member's message refused). A real deployment
8//! bridges [`Connection`] to a `tokio-tungstenite` / axum WebSocket; the tests
9//! drive it directly.
10
11// The room registry is the adapter's own coordinator state, shared across a
12// room's connections — not per-request state (ADR 0007 allows a scoped,
13// justified Mutex, as the sqlite and testing crates do).
14#![allow(clippy::disallowed_types)]
15
16use std::collections::HashMap;
17use std::sync::atomic::{AtomicU64, Ordering};
18use std::sync::{Arc, Mutex};
19use std::time::Duration;
20
21use async_trait::async_trait;
22use cratefield_core::{Member, Realtime, RealtimeError, RoomContext, RoomHandler};
23use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
24
25/// A member id paired with its outgoing-message sender.
26type MemberSender = (String, UnboundedSender<Vec<u8>>);
27
28/// A [`Realtime`] adapter that holds every room's sockets in this process.
29#[derive(Clone)]
30pub struct InProcessRealtime {
31    handler: Arc<dyn RoomHandler>,
32    rooms: Arc<Mutex<HashMap<String, Room>>>,
33    /// Hands each [`Connection`] a session id. A member whose socket drops and
34    /// reconnects gets a second connection under the same member id, and the
35    /// two overlap: the server may not have noticed the old socket is gone
36    /// before the new one arrives. Without a session id the stale connection's
37    /// `close` removes the *live* one from the room — the member stays
38    /// connected and becomes invisible (see [`Room::members`]).
39    next_session: Arc<AtomicU64>,
40}
41
42/// One connected socket: the sender the room writes to, and the session that
43/// says which connection owns it.
44struct Registration {
45    session: u64,
46    tx: UnboundedSender<Vec<u8>>,
47}
48
49#[derive(Default)]
50struct Room {
51    /// Each connected member's outgoing channel, keyed by member id and
52    /// carrying the session that registered it: a message pushed here is
53    /// written to that member's socket by the caller pumping [`Connection`].
54    /// A second connection for the same member replaces the first's entry, so
55    /// a late `close` or `deliver` from the replaced one must check the
56    /// session before acting on the entry it finds.
57    members: HashMap<String, Registration>,
58    /// The pending shared-clock alarm, if any.
59    alarm: Option<tokio::task::JoinHandle<()>>,
60}
61
62impl InProcessRealtime {
63    #[must_use]
64    pub fn new(handler: Arc<dyn RoomHandler>) -> Self {
65        Self {
66            handler,
67            rooms: Arc::new(Mutex::new(HashMap::new())),
68            next_session: Arc::new(AtomicU64::new(1)),
69        }
70    }
71
72    fn context(&self, room_id: &str) -> NativeContext {
73        NativeContext {
74            rooms: Arc::clone(&self.rooms),
75            handler: Arc::clone(&self.handler),
76            room_id: room_id.to_owned(),
77        }
78    }
79
80    /// Opens a socket for `member` in `room_id`: registers its outgoing
81    /// channel, runs [`RoomHandler::on_join`], and returns a [`Connection`] the
82    /// caller pumps. `next_outgoing` yields messages to write to the socket;
83    /// `deliver` feeds messages received from it; `close` ends the membership.
84    ///
85    /// # Panics
86    ///
87    /// Panics if the room registry lock is poisoned.
88    ///
89    /// # Errors
90    ///
91    /// Propagates any [`RealtimeError`] the handler's `on_join` returns.
92    pub async fn connect(
93        &self,
94        room_id: &str,
95        member: Member,
96    ) -> Result<Connection, RealtimeError> {
97        let (tx, rx) = unbounded_channel();
98        let session = self.next_session.fetch_add(1, Ordering::Relaxed);
99        {
100            let mut rooms = self.rooms.lock().expect("rooms lock");
101            rooms
102                .entry(room_id.to_owned())
103                .or_default()
104                .members
105                .insert(member.id.clone(), Registration { session, tx });
106        }
107        let ctx = self.context(room_id);
108        self.handler.on_join(&ctx, &member).await?;
109        Ok(Connection {
110            rx,
111            member,
112            room_id: room_id.to_owned(),
113            session,
114            realtime: self.clone(),
115        })
116    }
117}
118
119/// One member's open socket. The caller writes [`Connection::next_outgoing`]
120/// messages to the wire and calls [`Connection::deliver`] with what it reads.
121pub struct Connection {
122    rx: UnboundedReceiver<Vec<u8>>,
123    member: Member,
124    room_id: String,
125    /// The session that registered this socket in the room. A member that
126    /// reconnected holds a newer one, and this connection no longer owns the
127    /// room's entry for that member id.
128    session: u64,
129    realtime: InProcessRealtime,
130}
131
132impl Connection {
133    /// The next message the room wants written to this socket, or `None` once
134    /// the connection is closed.
135    pub async fn next_outgoing(&mut self) -> Option<Vec<u8>> {
136        self.rx.recv().await
137    }
138
139    /// Feeds a message received from the socket to [`RoomHandler::on_message`].
140    ///
141    /// # Errors
142    ///
143    /// [`RealtimeError::NotAMember`] if this member has already left, or has
144    /// reconnected on a newer socket since (so a late or forged frame is
145    /// dropped), else any error the handler returns.
146    ///
147    /// # Panics
148    ///
149    /// Panics if the room registry lock is poisoned.
150    pub async fn deliver(&self, message: &[u8]) -> Result<(), RealtimeError> {
151        let present = {
152            let rooms = self.realtime.rooms.lock().expect("rooms lock");
153            rooms.get(&self.room_id).is_some_and(|room| {
154                room.members
155                    .get(&self.member.id)
156                    .is_some_and(|registration| registration.session == self.session)
157            })
158        };
159        if !present {
160            return Err(RealtimeError::NotAMember);
161        }
162        let ctx = self.realtime.context(&self.room_id);
163        self.realtime
164            .handler
165            .on_message(&ctx, &self.member, message)
166            .await
167    }
168
169    /// Ends the membership: removes the socket and runs
170    /// [`RoomHandler::on_leave`].
171    ///
172    /// A socket this member has already replaced by reconnecting no longer
173    /// owns the room's entry for that id, so this leaves the live socket
174    /// wired: the stale close must not unregister it, empty the room, or
175    /// abort the alarm a live member set.
176    ///
177    /// # Errors
178    ///
179    /// Propagates any error the handler's `on_leave` returns.
180    ///
181    /// # Panics
182    ///
183    /// Panics if the room registry lock is poisoned.
184    pub async fn close(self) -> Result<(), RealtimeError> {
185        {
186            let mut rooms = self.realtime.rooms.lock().expect("rooms lock");
187            let owns_entry = rooms.get(&self.room_id).is_some_and(|room| {
188                room.members
189                    .get(&self.member.id)
190                    .is_some_and(|registration| registration.session == self.session)
191            });
192            if owns_entry && let Some(room) = rooms.get_mut(&self.room_id) {
193                room.members.remove(&self.member.id);
194                if room.members.is_empty() {
195                    if let Some(alarm) = room.alarm.take() {
196                        alarm.abort();
197                    }
198                    rooms.remove(&self.room_id);
199                }
200            }
201        }
202        let ctx = self.realtime.context(&self.room_id);
203        self.realtime.handler.on_leave(&ctx, &self.member).await
204    }
205}
206
207/// The [`RoomContext`] handed to handler callbacks on the native runtime.
208struct NativeContext {
209    rooms: Arc<Mutex<HashMap<String, Room>>>,
210    handler: Arc<dyn RoomHandler>,
211    room_id: String,
212}
213
214impl NativeContext {
215    /// Snapshots the room's senders without holding the lock across the sends
216    /// (a handler's `broadcast` must not deadlock against a lock the runtime
217    /// holds while calling it).
218    fn senders(&self) -> Vec<MemberSender> {
219        let rooms = self.rooms.lock().expect("rooms lock");
220        rooms
221            .get(&self.room_id)
222            .map(|room| {
223                room.members
224                    .iter()
225                    .map(|(id, registration)| (id.clone(), registration.tx.clone()))
226                    .collect()
227            })
228            .unwrap_or_default()
229    }
230}
231
232#[async_trait]
233impl RoomContext for NativeContext {
234    fn room_id(&self) -> &str {
235        &self.room_id
236    }
237
238    async fn members(&self) -> Vec<Member> {
239        self.senders()
240            .into_iter()
241            .map(|(id, _)| Member::new(id))
242            .collect()
243    }
244
245    async fn broadcast(&self, message: &[u8]) -> Result<(), RealtimeError> {
246        for (_, tx) in self.senders() {
247            // A closed receiver just means that socket is gone; ignore it.
248            let _ = tx.send(message.to_vec());
249        }
250        Ok(())
251    }
252
253    async fn send(&self, member_id: &str, message: &[u8]) -> Result<(), RealtimeError> {
254        let sender = self
255            .senders()
256            .into_iter()
257            .find(|(id, _)| id == member_id)
258            .map(|(_, tx)| tx);
259        if let Some(tx) = sender {
260            let _ = tx.send(message.to_vec());
261        }
262        Ok(())
263    }
264
265    async fn set_alarm(&self, delay: Duration) -> Result<(), RealtimeError> {
266        let rooms = Arc::clone(&self.rooms);
267        let handler = Arc::clone(&self.handler);
268        let room_id = self.room_id.clone();
269        let handle = tokio::spawn(async move {
270            tokio::time::sleep(delay).await;
271            let ctx = NativeContext {
272                rooms,
273                handler: Arc::clone(&handler),
274                room_id,
275            };
276            let _ = handler.on_alarm(&ctx).await;
277        });
278        let mut rooms = self.rooms.lock().expect("rooms lock");
279        if let Some(room) = rooms.get_mut(&self.room_id) {
280            if let Some(previous) = room.alarm.replace(handle) {
281                previous.abort();
282            }
283        } else {
284            // No room to hang the alarm on (everyone left): cancel it.
285            handle.abort();
286        }
287        Ok(())
288    }
289}
290
291#[async_trait]
292impl Realtime for InProcessRealtime {
293    async fn broadcast(&self, room_id: &str, message: &[u8]) -> Result<(), RealtimeError> {
294        self.context(room_id).broadcast(message).await
295    }
296
297    async fn members(&self, room_id: &str) -> Result<Vec<Member>, RealtimeError> {
298        Ok(self.context(room_id).members().await)
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use std::sync::atomic::{AtomicUsize, Ordering};
306
307    /// A handler that records its callbacks and, on join/message, re-broadcasts
308    /// so the tests can observe what reached the sockets.
309    #[derive(Default)]
310    struct Recorder {
311        joins: AtomicUsize,
312        leaves: AtomicUsize,
313        alarms: AtomicUsize,
314    }
315
316    #[async_trait]
317    impl RoomHandler for Recorder {
318        async fn on_join(
319            &self,
320            ctx: &dyn RoomContext,
321            member: &Member,
322        ) -> Result<(), RealtimeError> {
323            self.joins.fetch_add(1, Ordering::SeqCst);
324            ctx.broadcast(format!("join:{}", member.id).as_bytes())
325                .await
326        }
327
328        async fn on_message(
329            &self,
330            ctx: &dyn RoomContext,
331            member: &Member,
332            message: &[u8],
333        ) -> Result<(), RealtimeError> {
334            let echo = format!("{}:{}", member.id, String::from_utf8_lossy(message));
335            ctx.broadcast(echo.as_bytes()).await
336        }
337
338        async fn on_leave(
339            &self,
340            _ctx: &dyn RoomContext,
341            _member: &Member,
342        ) -> Result<(), RealtimeError> {
343            self.leaves.fetch_add(1, Ordering::SeqCst);
344            Ok(())
345        }
346
347        async fn on_alarm(&self, ctx: &dyn RoomContext) -> Result<(), RealtimeError> {
348            self.alarms.fetch_add(1, Ordering::SeqCst);
349            ctx.broadcast(b"tick").await
350        }
351    }
352
353    fn recorder() -> (InProcessRealtime, Arc<Recorder>) {
354        let recorder = Arc::new(Recorder::default());
355        (InProcessRealtime::new(recorder.clone()), recorder)
356    }
357
358    #[tokio::test]
359    async fn join_broadcasts_to_every_member() {
360        let (rt, rec) = recorder();
361        let mut a = rt.connect("room", Member::new("a")).await.unwrap();
362        let mut b = rt.connect("room", Member::new("b")).await.unwrap();
363
364        // Both joins broadcast; from the outside, a fresh broadcast reaches N.
365        rt.broadcast("room", b"hello").await.unwrap();
366        assert_eq!(rt.members("room").await.unwrap().len(), 2);
367        assert_eq!(rec.joins.load(Ordering::SeqCst), 2);
368
369        // `a` sees its own join, `b`'s join, then the external hello.
370        assert_eq!(a.next_outgoing().await.unwrap(), b"join:a");
371        assert_eq!(a.next_outgoing().await.unwrap(), b"join:b");
372        assert_eq!(a.next_outgoing().await.unwrap(), b"hello");
373        // `b` joined after `a`, so it only sees its own join and the hello.
374        assert_eq!(b.next_outgoing().await.unwrap(), b"join:b");
375        assert_eq!(b.next_outgoing().await.unwrap(), b"hello");
376    }
377
378    #[tokio::test]
379    async fn a_message_reaches_the_other_members() {
380        let (rt, _rec) = recorder();
381        let a = rt.connect("room", Member::new("a")).await.unwrap();
382        let mut b = rt.connect("room", Member::new("b")).await.unwrap();
383        // `a` joined first, so `b` only sees its own join broadcast (`join:b`);
384        // `a`'s `join:a` fired before `b` connected. Drain that one notice.
385        assert_eq!(b.next_outgoing().await.unwrap(), b"join:b");
386
387        a.deliver(b"hi").await.unwrap();
388        assert_eq!(b.next_outgoing().await.unwrap(), b"a:hi");
389    }
390
391    #[tokio::test]
392    async fn leaving_removes_the_member_and_runs_on_leave() {
393        let (rt, rec) = recorder();
394        let a = rt.connect("room", Member::new("a")).await.unwrap();
395        rt.connect("room", Member::new("b")).await.unwrap();
396        a.close().await.unwrap();
397        assert_eq!(rec.leaves.load(Ordering::SeqCst), 1);
398        let ids: Vec<String> = rt
399            .members("room")
400            .await
401            .unwrap()
402            .into_iter()
403            .map(|m| m.id)
404            .collect();
405        assert_eq!(ids, ["b"]);
406    }
407
408    #[tokio::test]
409    async fn a_message_from_a_departed_member_is_refused() {
410        let (rt, _rec) = recorder();
411        rt.connect("room", Member::new("keep")).await.unwrap();
412        let gone = rt.connect("room", Member::new("gone")).await.unwrap();
413        // Take a handle to deliver through after closing.
414        let realtime = rt.clone();
415        let member = gone.member.clone();
416        gone.close().await.unwrap();
417        let orphan = Connection {
418            rx: unbounded_channel().1,
419            member,
420            room_id: "room".to_owned(),
421            session: 0,
422            realtime,
423        };
424        assert!(matches!(
425            orphan.deliver(b"late").await,
426            Err(RealtimeError::NotAMember)
427        ));
428    }
429
430    #[tokio::test(start_paused = true)]
431    async fn an_alarm_fires_exactly_once() {
432        let (rt, rec) = recorder();
433        let mut a = rt.connect("room", Member::new("a")).await.unwrap();
434        let _ = a.next_outgoing().await; // the join notice
435
436        // Schedule the shared-clock tick via a handler event.
437        rt.context("room")
438            .set_alarm(Duration::from_millis(50))
439            .await
440            .unwrap();
441        tokio::time::advance(Duration::from_millis(60)).await;
442        // Let the spawned alarm task run.
443        tokio::task::yield_now().await;
444
445        assert_eq!(a.next_outgoing().await.unwrap(), b"tick");
446        assert_eq!(rec.alarms.load(Ordering::SeqCst), 1);
447    }
448
449    /// A member whose socket drops and reconnects gets a second
450    /// [`Connection`] under the same member id. If the first connection's
451    /// `close` is what lands last — the network case: the server has not yet
452    /// noticed the old socket is gone when the new one arrives — it removes
453    /// the *live* socket's sender from the room. The member stays connected,
454    /// and is now invisible: every broadcast misses it and its own `deliver`
455    /// is refused.
456    #[tokio::test]
457    async fn a_stale_connection_closing_leaves_the_reconnected_member_wired() {
458        let (rt, _rec) = recorder();
459        let old = rt.connect("room", Member::new("a")).await.unwrap();
460        let mut new = rt.connect("room", Member::new("a")).await.unwrap();
461        // The reconnected socket sees its own join notice; the stale one's
462        // notice went to the socket it replaced.
463        assert_eq!(new.next_outgoing().await.unwrap(), b"join:a");
464
465        old.close().await.unwrap();
466
467        // Still a member, and still reachable.
468        assert_eq!(rt.members("room").await.unwrap().len(), 1);
469        rt.broadcast("room", b"hello").await.unwrap();
470        assert_eq!(new.next_outgoing().await.unwrap(), b"hello");
471        new.deliver(b"hi").await.unwrap();
472    }
473
474    /// The same stale `close`, reaching the room's shared clock: with the live
475    /// socket unregistered the room reads empty, so it is torn down and its
476    /// pending alarm is aborted — the alarm a live member set never fires.
477    #[tokio::test(start_paused = true)]
478    async fn a_stale_connection_closing_does_not_cancel_the_live_members_alarm() {
479        let (rt, rec) = recorder();
480        let old = rt.connect("room", Member::new("a")).await.unwrap();
481        let mut new = rt.connect("room", Member::new("a")).await.unwrap();
482        let _ = new.next_outgoing().await; // the join notice
483
484        rt.context("room")
485            .set_alarm(Duration::from_millis(50))
486            .await
487            .unwrap();
488        // Let the spawned alarm task reach its `sleep`.
489        tokio::task::yield_now().await;
490
491        old.close().await.unwrap();
492
493        tokio::time::advance(Duration::from_millis(60)).await;
494        tokio::task::yield_now().await;
495        assert_eq!(new.next_outgoing().await.unwrap(), b"tick");
496        assert_eq!(rec.alarms.load(Ordering::SeqCst), 1);
497    }
498}