1use std::sync::Arc;
90
91use redis::aio::{ConnectionLike, MultiplexedConnection};
92use redis::{Client, Cmd};
93
94use crate::codec::Frame;
95use crate::error::WsError;
96use crate::room::{RoomManager, RoomRegistry};
97
98pub const DEFAULT_CHANNEL_PREFIX: &str = "ws-kit:room:";
100
101const ENVELOPE_VERSION: u8 = 1;
103
104const KIND_TEXT: u8 = 0;
106const KIND_BINARY: u8 = 1;
107
108const HEADER_LEN: usize = 18;
110
111fn generate_node_id() -> [u8; 16] {
117 use std::collections::hash_map::RandomState;
118 use std::hash::{BuildHasher, Hasher};
119 let mut a = RandomState::new().build_hasher();
120 let mut b = RandomState::new().build_hasher();
121 a.write_u64(0x77_73_2d_6b_69_74_01);
122 b.write_u64(std::process::id() as u64);
123 let hi = a.finish();
124 let lo = b.finish();
125 let mut id = [0u8; 16];
126 let (left, right) = id.split_at_mut(8);
127 left.copy_from_slice(&hi.to_le_bytes());
128 right.copy_from_slice(&lo.to_le_bytes());
129 id
130}
131
132fn encode_envelope(node_id: &[u8; 16], frame: &Frame) -> Vec<u8> {
135 let payload = frame.as_bytes();
136 let mut out = Vec::with_capacity(HEADER_LEN + payload.len());
137 out.push(ENVELOPE_VERSION);
138 out.extend_from_slice(node_id);
139 out.push(match frame {
140 Frame::Text(_) => KIND_TEXT,
141 Frame::Binary(_) => KIND_BINARY,
142 });
143 out.extend_from_slice(payload);
144 out
145}
146
147fn decode_envelope(bytes: &[u8]) -> Result<([u8; 16], Frame), WsError> {
151 if bytes.len() < HEADER_LEN || bytes.first() != Some(&ENVELOPE_VERSION) {
152 return Err(WsError::InvalidMessage);
153 }
154 let mut node_id = [0u8; 16];
155 let id_bytes = bytes.get(1..17).ok_or(WsError::InvalidMessage)?;
156 node_id.copy_from_slice(id_bytes);
157 let kind = bytes.get(17).copied().ok_or(WsError::InvalidMessage)?;
158 let payload = bytes.get(HEADER_LEN..).ok_or(WsError::InvalidMessage)?;
159 let frame = match kind {
160 KIND_TEXT => {
161 let s = String::from_utf8(payload.to_vec()).map_err(|_| WsError::InvalidMessage)?;
162 Frame::Text(s)
163 }
164 KIND_BINARY => Frame::Binary(payload.to_vec().into()),
165 _ => return Err(WsError::InvalidMessage),
166 };
167 Ok((node_id, frame))
168}
169
170pub struct RedisRoomRegistry<C = MultiplexedConnection> {
179 local: RoomManager,
180 client: Client,
181 publish: Arc<tokio::sync::Mutex<C>>,
182 prefix: String,
183 node_id: [u8; 16],
184}
185
186impl RedisRoomRegistry<MultiplexedConnection> {
187 pub async fn connect(url: &str) -> Result<Self, WsError> {
195 Self::connect_with_prefix(url, DEFAULT_CHANNEL_PREFIX).await
196 }
197
198 pub async fn connect_with_prefix(url: &str, prefix: &str) -> Result<Self, WsError> {
201 let client = Client::open(url.to_string()).map_err(redis_err)?;
202 let publish = client
203 .get_multiplexed_async_connection()
204 .await
205 .map_err(redis_err)?;
206 Ok(Self::from_parts(
207 RoomManager::new(),
208 client,
209 publish,
210 prefix,
211 ))
212 }
213}
214
215impl<C> RedisRoomRegistry<C>
216where
217 C: ConnectionLike + Clone + Send + 'static,
218{
219 pub fn from_parts(
226 local: RoomManager,
227 client: Client,
228 publish: C,
229 prefix: impl Into<String>,
230 ) -> Self {
231 Self {
232 local,
233 client,
234 publish: Arc::new(tokio::sync::Mutex::new(publish)),
235 prefix: prefix.into(),
236 node_id: generate_node_id(),
237 }
238 }
239}
240
241impl<C> RedisRoomRegistry<C> {
242 pub fn local(&self) -> &RoomManager {
245 &self.local
246 }
247
248 pub fn prefix(&self) -> &str {
250 &self.prefix
251 }
252
253 pub fn channel_name(&self, room_id: &str) -> String {
255 format!("{}{}", self.prefix, room_id)
256 }
257
258 pub fn room_id_from_channel<'a>(&self, channel: &'a str) -> Option<&'a str> {
260 channel.strip_prefix(self.prefix.as_str())
261 }
262
263 pub fn node_id(&self) -> [u8; 16] {
265 self.node_id
266 }
267}
268
269impl<C> RedisRoomRegistry<C>
270where
271 C: ConnectionLike + Clone + Send + 'static,
272{
273 pub async fn broadcast(
289 &self,
290 room_id: &str,
291 frame: impl Into<Frame>,
292 ) -> Result<usize, WsError> {
293 let frame = frame.into();
294 let local_n = match self.local.get_or_create(room_id).broadcast(frame.clone()) {
296 Ok(n) => n,
297 Err(WsError::BroadcastFull) => 0,
299 Err(e) => return Err(e),
300 };
301 self.publish(&frame, room_id).await?;
302 Ok(local_n)
303 }
304
305 pub async fn publish(&self, frame: &Frame, room_id: &str) -> Result<(), WsError> {
307 let envelope = encode_envelope(&self.node_id, frame);
308 let mut conn = self.publish.lock().await;
309 let mut cmd = Cmd::new();
310 cmd.arg("PUBLISH")
311 .arg(self.channel_name(room_id))
312 .arg(envelope);
313 conn.req_packed_command(&cmd).await.map_err(redis_err)?;
314 Ok(())
315 }
316
317 pub fn apply_remote(&self, room_id: &str, envelope: &[u8]) -> Result<Option<usize>, WsError> {
325 let (publisher, frame) = decode_envelope(envelope)?;
326 if publisher == self.node_id {
327 return Ok(None);
328 }
329 match self.local.get(room_id) {
330 Some(room) => match room.broadcast(frame) {
331 Ok(n) => Ok(Some(n)),
332 Err(WsError::BroadcastFull) => Ok(None),
333 Err(e) => Err(e),
334 },
335 None => Ok(None),
336 }
337 }
338}
339
340impl<C> RoomRegistry for RedisRoomRegistry<C>
341where
342 C: ConnectionLike + Clone + Send + 'static,
343{
344 fn get_or_create(&self, room_id: &str) -> std::sync::Arc<crate::room::Room> {
348 self.local.get_or_create(room_id)
349 }
350
351 fn get(&self, room_id: &str) -> Option<std::sync::Arc<crate::room::Room>> {
352 self.local.get(room_id)
353 }
354
355 fn remove(&self, room_id: &str) -> Option<std::sync::Arc<crate::room::Room>> {
356 self.local.remove(room_id)
357 }
358
359 fn room_count(&self) -> usize {
360 self.local.room_count()
361 }
362
363 fn room_ids(&self) -> Vec<String> {
364 self.local.room_ids()
365 }
366
367 fn total_participants(&self) -> usize {
368 self.local.total_participants()
369 }
370
371 fn cleanup(&self) -> usize {
372 self.local.cleanup()
373 }
374
375 fn cleanup_empty(&self) -> usize {
376 self.local.cleanup_empty()
377 }
378}
379
380impl RedisRoomRegistry<MultiplexedConnection> {
381 pub async fn run_subscriber(&self) -> Result<(), WsError> {
394 let mut pubsub = self.client.get_async_pubsub().await.map_err(redis_err)?;
395 let pattern = format!("{}*", self.prefix);
396 pubsub.psubscribe(pattern).await.map_err(redis_err)?;
397 let mut stream = pubsub.on_message();
398 while let Some(msg) = futures_util::StreamExt::next(&mut stream).await {
399 let channel = msg.get_channel_name().to_string();
400 let payload = msg.get_payload_bytes().to_vec();
401 if let Some(room_id) = self.room_id_from_channel(&channel) {
402 let _ = self.apply_remote(room_id, &payload);
405 }
406 }
407 Err(WsError::Closed)
408 }
409}
410
411fn redis_err(e: redis::RedisError) -> WsError {
412 WsError::Redis(e.to_string())
413}
414
415#[allow(
419 clippy::unwrap_used,
420 clippy::expect_used,
421 clippy::indexing_slicing,
422 clippy::panic
423)]
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use bytes::Bytes;
428 use std::sync::{Arc, Mutex};
429
430 #[derive(Clone, Default)]
434 struct MockPublishConn {
435 published: Arc<Mutex<Vec<Vec<u8>>>>,
436 }
437
438 impl ConnectionLike for MockPublishConn {
439 fn req_packed_command<'a>(
440 &'a mut self,
441 cmd: &'a Cmd,
442 ) -> redis::RedisFuture<'a, redis::Value> {
443 let packed = cmd.get_packed_command();
444 self.published.lock().unwrap().push(packed);
445 Box::pin(async { Ok(redis::Value::Nil) })
446 }
447
448 fn req_packed_commands<'a>(
449 &'a mut self,
450 _cmd: &'a redis::Pipeline,
451 _offset: usize,
452 _count: usize,
453 ) -> redis::RedisFuture<'a, Vec<redis::Value>> {
454 Box::pin(async { Ok(Vec::new()) })
455 }
456
457 fn get_db(&self) -> i64 {
458 0
459 }
460 }
461
462 type PublishedLog = Arc<Mutex<Vec<Vec<u8>>>>;
463
464 fn test_registry() -> (RedisRoomRegistry<MockPublishConn>, PublishedLog) {
465 let conn = MockPublishConn::default();
466 let published = Arc::clone(&conn.published);
467 let client = Client::open("redis://127.0.0.1:6379").unwrap();
469 let reg =
470 RedisRoomRegistry::from_parts(RoomManager::new(), client, conn, DEFAULT_CHANNEL_PREFIX);
471 (reg, published)
472 }
473
474 #[tokio::test]
475 async fn broadcast_fans_out_locally_and_publishes() {
476 let (reg, published) = test_registry();
477 let mut rx = reg.local().get_or_create("lobby").subscribe();
478 let n = reg
479 .broadcast("lobby", Frame::from("hi fleet"))
480 .await
481 .unwrap();
482 assert_eq!(n, 1, "one local receiver");
483 assert_eq!(
484 rx.recv().await.unwrap(),
485 Frame::from("hi fleet"),
486 "local sink got the frame directly"
487 );
488 let sent = published.lock().unwrap();
489 assert_eq!(sent.len(), 1, "exactly one PUBLISH");
490 let packed = &sent[0];
491 let packed_str = String::from_utf8_lossy(packed).to_string();
492 assert!(
493 packed_str.contains("ws-kit:room:lobby"),
494 "PUBLISH targets the room channel: {packed_str:?}"
495 );
496 let expected = encode_envelope(®.node_id(), &Frame::from("hi fleet"));
499 assert!(
500 packed
501 .windows(expected.len())
502 .any(|w| w == expected.as_slice()),
503 "PUBLISH carries the exact envelope"
504 );
505 }
506
507 #[tokio::test]
508 async fn broadcast_creates_local_room_on_demand() {
509 let (reg, _published) = test_registry();
510 assert!(reg.local().get("fresh").is_none());
511 let n = reg.broadcast("fresh", Frame::from("x")).await.unwrap();
513 assert_eq!(n, 0);
514 assert!(reg.local().get("fresh").is_some(), "room materialized");
515 }
516
517 #[tokio::test]
518 async fn remote_envelope_reaches_hosted_room() {
519 let (reg, _published) = test_registry();
520 let mut rx = reg.local().get_or_create("lobby").subscribe();
521
522 let peer_id = [7u8; 16];
524 let frame = Frame::from("from peer");
525 let envelope = encode_envelope(&peer_id, &frame);
526 let delivered = reg.apply_remote("lobby", &envelope).unwrap();
527 assert_eq!(delivered, Some(1));
528 assert_eq!(rx.recv().await.unwrap(), frame);
529 }
530
531 #[tokio::test]
532 async fn remote_binary_envelope_roundtrips() {
533 let (reg, _published) = test_registry();
534 let mut rx = reg.local().get_or_create("bin").subscribe();
535 let frame = Frame::Binary(Bytes::from_static(b"\x00\xff\x10"));
536 let envelope = encode_envelope(&[9u8; 16], &frame);
537 reg.apply_remote("bin", &envelope).unwrap();
538 let got = rx.recv().await.unwrap();
539 assert!(got.is_binary());
540 assert_eq!(got.as_bytes(), b"\x00\xff\x10");
541 }
542
543 #[tokio::test]
544 async fn self_echo_is_dropped() {
545 let (reg, _published) = test_registry();
546 let mut rx = reg.local().get_or_create("lobby").subscribe();
547 let envelope = encode_envelope(®.node_id(), &Frame::from("my own publish"));
548 assert_eq!(reg.apply_remote("lobby", &envelope).unwrap(), None);
549 use tokio::time::{timeout, Duration};
550 assert!(timeout(Duration::from_millis(20), rx.recv()).await.is_err());
551 }
552
553 #[tokio::test]
554 async fn remote_traffic_never_materializes_rooms() {
555 let (reg, _published) = test_registry();
556 let envelope = encode_envelope(&[3u8; 16], &Frame::from("anyone there?"));
557 assert_eq!(reg.apply_remote("ghost", &envelope).unwrap(), None);
558 assert!(reg.local().get("ghost").is_none());
559 }
560
561 #[test]
562 fn malformed_envelopes_fail_closed() {
563 let (reg, _published) = test_registry();
564 assert_eq!(
566 reg.apply_remote("r", &[0u8; 10]).unwrap_err(),
567 WsError::InvalidMessage
568 );
569 assert_eq!(
571 reg.apply_remote("r", &[]).unwrap_err(),
572 WsError::InvalidMessage
573 );
574 let mut evil = encode_envelope(&[0u8; 16], &Frame::from("v0"));
576 evil[0] = 99;
577 assert_eq!(
578 reg.apply_remote("r", &evil).unwrap_err(),
579 WsError::InvalidMessage
580 );
581 let mut evil = encode_envelope(&[0u8; 16], &Frame::from("bad kind"));
583 evil[17] = 42;
584 assert_eq!(
585 reg.apply_remote("r", &evil).unwrap_err(),
586 WsError::InvalidMessage
587 );
588 let mut evil = encode_envelope(&[0u8; 16], &Frame::from("ok"));
590 evil[17] = KIND_TEXT;
591 evil[18..].copy_from_slice(&[0xFF, 0xFE]);
592 assert_eq!(
593 reg.apply_remote("r", &evil).unwrap_err(),
594 WsError::InvalidMessage
595 );
596 }
597
598 #[test]
599 fn channel_names_roundtrip() {
600 let (reg, _published) = test_registry();
601 assert_eq!(reg.channel_name("lobby"), "ws-kit:room:lobby");
602 assert_eq!(reg.room_id_from_channel("ws-kit:room:lobby"), Some("lobby"));
603 assert_eq!(reg.room_id_from_channel("ws-kit:room:a:b:c"), Some("a:b:c"));
605 assert_eq!(reg.room_id_from_channel("other:room:x"), None);
606 assert_eq!(reg.room_id_from_channel("ws-kit:room:"), Some(""));
607 }
608
609 #[test]
610 fn custom_prefix_changes_channels() {
611 let conn = MockPublishConn::default();
612 let client = Client::open("redis://127.0.0.1:6379").unwrap();
613 let reg: RedisRoomRegistry<MockPublishConn> =
614 RedisRoomRegistry::from_parts(RoomManager::new(), client, conn, "tenant-a/rooms:");
615 assert_eq!(reg.channel_name("lobby"), "tenant-a/rooms:lobby");
616 assert_eq!(
617 reg.room_id_from_channel("tenant-a/rooms:lobby"),
618 Some("lobby")
619 );
620 assert_eq!(reg.prefix(), "tenant-a/rooms:");
621 }
622
623 #[test]
624 fn envelope_decode_roundtrip_preserves_frame_kind() {
625 let node_id = [1u8; 16];
626 for frame in [
627 Frame::from("hello".to_string()),
628 Frame::Binary(Bytes::from_static(&[0, 1, 2, 255])),
629 ] {
630 let env = encode_envelope(&node_id, &frame);
631 let (got_id, got_frame) = decode_envelope(&env).unwrap();
632 assert_eq!(got_id, node_id);
633 assert_eq!(got_frame, frame);
634 }
635 }
636
637 #[test]
638 fn node_ids_are_distinct_per_registry() {
639 let (a, _) = test_registry();
640 let (b, _) = test_registry();
641 assert_ne!(a.node_id(), b.node_id());
642 }
643
644 #[tokio::test]
649 #[ignore = "requires a live Redis server (docker run -p 6379:6379 redis:7)"]
650 async fn live_two_nodes_fan_out_over_pubsub() {
651 let node_a = Arc::new(
652 RedisRoomRegistry::connect("redis://127.0.0.1:6379")
653 .await
654 .unwrap(),
655 );
656 let node_b = Arc::new(
657 RedisRoomRegistry::connect("redis://127.0.0.1:6379")
658 .await
659 .unwrap(),
660 );
661 assert_ne!(node_a.node_id(), node_b.node_id());
662
663 let sub_a = Arc::clone(&node_a);
665 let sub_b = Arc::clone(&node_b);
666 tokio::spawn(async move { sub_a.run_subscriber().await });
667 tokio::spawn(async move { sub_b.run_subscriber().await });
668
669 let mut rx_a = node_a.local().get_or_create("live").subscribe();
671 let _rx_b = node_b.local().get_or_create("live").subscribe();
672
673 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
675
676 node_b
678 .broadcast("live", Frame::from("cross-node hello"))
679 .await
680 .unwrap();
681 let got = tokio::time::timeout(std::time::Duration::from_secs(5), rx_a.recv())
682 .await
683 .unwrap()
684 .unwrap();
685 assert_eq!(got, Frame::from("cross-node hello"));
686
687 node_a
690 .broadcast("live", Frame::from("from a itself"))
691 .await
692 .unwrap();
693 let got = tokio::time::timeout(std::time::Duration::from_secs(5), rx_a.recv())
694 .await
695 .unwrap()
696 .unwrap();
697 assert_eq!(got, Frame::from("from a itself"));
698 let dup = tokio::time::timeout(std::time::Duration::from_millis(300), rx_a.recv()).await;
700 assert!(dup.is_err(), "self-echo must be suppressed");
701 }
702
703 #[tokio::test]
705 #[ignore = "requires a live Redis server (docker run -p 6379:6379 redis:7)"]
706 async fn live_binary_frame_crosses_nodes() {
707 let node_a = Arc::new(
708 RedisRoomRegistry::connect("redis://127.0.0.1:6379")
709 .await
710 .unwrap(),
711 );
712 let node_b = Arc::new(
713 RedisRoomRegistry::connect("redis://127.0.0.1:6379")
714 .await
715 .unwrap(),
716 );
717 let sub_a = Arc::clone(&node_a);
718 tokio::spawn(async move { sub_a.run_subscriber().await });
719 let mut rx_a = node_a.local().get_or_create("bin-live").subscribe();
720 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
721
722 node_b
723 .broadcast(
724 "bin-live",
725 Frame::Binary(Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef])),
726 )
727 .await
728 .unwrap();
729 let got = tokio::time::timeout(std::time::Duration::from_secs(5), rx_a.recv())
730 .await
731 .unwrap()
732 .unwrap();
733 assert!(got.is_binary());
734 assert_eq!(got.as_bytes(), &[0xde, 0xad, 0xbe, 0xef]);
735 }
736
737 #[derive(Clone, Default)]
740 struct FailingConn;
741
742 impl ConnectionLike for FailingConn {
743 fn req_packed_command<'a>(
744 &'a mut self,
745 _cmd: &'a Cmd,
746 ) -> redis::RedisFuture<'a, redis::Value> {
747 Box::pin(async {
748 Err(redis::RedisError::from((
749 redis::ErrorKind::IoError,
750 "connection dropped",
751 )))
752 })
753 }
754
755 fn req_packed_commands<'a>(
756 &'a mut self,
757 _cmd: &'a redis::Pipeline,
758 _offset: usize,
759 _count: usize,
760 ) -> redis::RedisFuture<'a, Vec<redis::Value>> {
761 Box::pin(async {
762 Err(redis::RedisError::from((
763 redis::ErrorKind::IoError,
764 "connection dropped",
765 )))
766 })
767 }
768
769 fn get_db(&self) -> i64 {
770 0
771 }
772 }
773
774 #[tokio::test]
775 async fn publish_connection_failure_maps_to_ws_error() {
776 let client = Client::open("redis://127.0.0.1:6379").unwrap();
777 let reg = RedisRoomRegistry::from_parts(
778 RoomManager::new(),
779 client,
780 FailingConn,
781 DEFAULT_CHANNEL_PREFIX,
782 );
783 let err = reg
784 .publish(&Frame::from("x"), "lobby")
785 .await
786 .expect_err("failing conn must surface an error");
787 assert!(
788 matches!(err, WsError::Redis(_)),
789 "redis failure must map to WsError::Redis, got: {err:?}"
790 );
791 }
792
793 #[tokio::test]
794 async fn connect_rejects_invalid_url() {
795 assert!(RedisRoomRegistry::connect("not a url").await.is_err());
796 }
797
798 #[test]
802 fn room_registry_trait_delegates_to_local_manager() {
803 let (reg, _published) = test_registry();
804 let reg: Arc<dyn RoomRegistry> = Arc::new(reg);
805
806 let room = reg.get_or_create("trait-lobby");
807 assert!(reg.get("trait-lobby").is_some());
808 assert_eq!(reg.room_count(), 1);
809 assert_eq!(reg.room_ids(), vec!["trait-lobby".to_string()]);
810
811 room.join(7, "alice".to_string());
812 assert!(room.contains(7));
813 assert_eq!(reg.total_participants(), 1);
814
815 assert!(reg.remove("trait-lobby").is_some());
816 assert!(reg.get("trait-lobby").is_none());
817 assert_eq!(reg.room_count(), 0);
818
819 assert_eq!(reg.cleanup(), 0);
820 assert_eq!(reg.cleanup_empty(), 0);
821 }
822}