Skip to main content

beam/adapters/
multicast.rs

1//! UDP multicast LAN discovery and sync adapter with message chunking.
2//!
3//! [`Multicast`] uses UDP multicast to discover and sync with BEAM peers on
4//! the local network. It broadcasts `Put` and `Get` messages to a multicast
5//! group, enabling zero-config peer discovery on LANs.
6//!
7//! # Configuration
8//!
9//! - Multicast group: `233.255.255.255:7654`
10//! - Buffer size: 64 KB
11//! - Interfaces: all IPv4 interfaces
12//!
13//! # Behavior
14//!
15//! - `pre_start`: Joins the multicast group and starts a blocking receive
16//!   loop in a `blocking_child_task`
17//! - `handle`: Broadcasts outgoing `Put` and `Get` messages to the group
18//! - Incoming messages are parsed and forwarded to the [`crate::router::Router`]
19//! - Marks itself as `subscribe_to_everything` (receives all messages)
20//!
21//! # Message Chunking
22//!
23//! UDP datagrams are limited by the Ethernet MTU (~1500 bytes, ~1472 bytes
24//! safe payload after IP + UDP headers). Messages exceeding this limit would
25//! trigger IP fragmentation, which is unreliable — if any fragment is lost,
26//! the entire datagram is dropped.
27//!
28//! BEAM solves this with application-layer chunking entirely within the
29//! multicast adapter:
30//!
31//! - Messages ≤ [`MAX_DATAGRAM_SIZE`] are sent as raw JSON (backward compatible)
32//! - Messages > [`MAX_DATAGRAM_SIZE`] are split into chunks, each wrapped in a
33//!   JSON envelope: `{"beam_chunk":{"id","seq","total","data":"<base64>"}}`
34//! - The [`ReassemblyBuffer`] collects chunks by `id` and reassembles the
35//!   complete message when all chunks arrive
36//! - Incomplete reassemblies time out after [`CHUNK_TIMEOUT`] seconds
37//! - At most [`MAX_REASSEMBLY_SLOTS`] messages can be in-flight simultaneously
38//!
39//! This is transparent to the rest of the actor system — the router, message
40//! types, and node logic are unaware of chunking.
41//!
42//! # Limitations
43//!
44//! The receive loop uses `blocking_child_task` — the `MulticastSocket::receive`
45//! call is synchronous and blocks. This is not optimal for async contexts
46//! but is required by the `multicast_socket` crate's API.
47//!
48//! There is no retransmission — UDP is unreliable by design. If a chunk is
49//! lost, the message will not be reassembled and will time out. This is
50//! acceptable for multicast's best-effort LAN sync use case.
51
52use base64::prelude::*;
53use oko_multicast_socket::{MulticastOptions, MulticastSocket, all_ipv4_interfaces};
54use serde::{Deserialize, Serialize};
55use std::collections::HashMap;
56use std::net::SocketAddrV4;
57use web_time::{Duration, Instant};
58
59use crate::Config;
60use crate::actor::{Actor, ActorContext};
61use crate::message::Message;
62use async_trait::async_trait;
63use log::{debug, error, info, warn};
64use std::sync::Arc;
65use tokio::sync::RwLock;
66
67// ─── Chunking constants ─────────────────────────────────────────────────────
68
69/// Maximum safe UDP datagram payload size.
70///
71/// Ethernet MTU is 1500 bytes. Subtracting IP header (20) and UDP header (8)
72/// gives 1472 bytes. We use 1400 to leave room for the JSON chunk envelope
73/// overhead and any lower-layer encapsulation (e.g. VPN tunnels).
74const MAX_DATAGRAM_SIZE: usize = 1400;
75
76/// Maximum payload bytes per chunk, accounting for base64 expansion (~33%)
77/// and the JSON envelope overhead (~80 bytes for the wrapper structure).
78///
79/// `1200` base64-encodes to ~1600 bytes — but we're encoding *fragments* of
80/// the original message, not the whole thing. The envelope + encoded fragment
81/// must fit under [`MAX_DATAGRAM_SIZE`]. A 900-byte fragment base64-encodes
82/// to ~1200 bytes, plus ~80 bytes of envelope = ~1280 bytes total. Safe.
83const CHUNK_PAYLOAD_SIZE: usize = 900;
84
85/// How long to keep incomplete reassembly slots before evicting them.
86const CHUNK_TIMEOUT: Duration = Duration::from_secs(5);
87
88/// Maximum number of concurrent incomplete reassemblies.
89///
90/// Prevents memory exhaustion from malformed or hostile chunk floods.
91/// Once this limit is reached, the oldest incomplete slot is evicted.
92const MAX_REASSEMBLY_SLOTS: usize = 64;
93
94// ─── Wire format ────────────────────────────────────────────────────────────
95
96/// Wire-format envelope for a single chunk of a fragmented message.
97///
98/// Serialized as JSON:
99/// ```json
100/// {"beam_chunk":{"id":"abc123","seq":0,"total":3,"data":"<base64>"}}
101/// ```
102///
103/// The receiver collects all `total` chunks for a given `id`, concatenates
104/// them in `seq` order, base64-decodes the result, and parses the reassembled
105/// string as a normal BEAM [`Message`].
106#[derive(Debug, Clone, Serialize, Deserialize)]
107struct ChunkEnvelope {
108    beam_chunk: ChunkFields,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
112struct ChunkFields {
113    /// Message ID shared by all chunks of the same message.
114    id: String,
115    /// Zero-indexed sequence number.
116    seq: usize,
117    /// Total number of chunks for this message.
118    total: usize,
119    /// Base64-encoded fragment of the original serialized message.
120    data: String,
121}
122
123// ─── Reassembly buffer (T1) ─────────────────────────────────────────────────
124
125/// Reassembly buffer for collecting chunked multicast messages.
126///
127/// Tracks partial messages keyed by chunk `id`. When all `total` chunks for
128/// a message have arrived, the reassembled payload is returned to the caller.
129///
130/// Incomplete slots are evicted after [`CHUNK_TIMEOUT`] and when the
131/// [`MAX_REASSEMBLY_SLOTS`] cap is exceeded (oldest first).
132///
133/// This struct is not `Send` — it's intended to be owned by the `Multicast`
134/// actor's blocking receive loop, which runs on a dedicated thread.
135#[derive(Debug)]
136struct ReassemblyBuffer {
137    /// Active reassembly slots, keyed by message ID.
138    slots: HashMap<String, PartialMessage>,
139}
140
141/// A partially-reassembled message awaiting remaining chunks.
142#[derive(Debug)]
143struct PartialMessage {
144    /// Total expected chunk count.
145    total: usize,
146    /// Received fragments, indexed by `seq`. `None` = not yet received.
147    received: Vec<Option<Vec<u8>>>,
148    /// Deadline for eviction. Set when the first chunk arrives.
149    deadline: Instant,
150}
151
152impl ReassemblyBuffer {
153    /// Creates a new empty reassembly buffer.
154    fn new() -> Self {
155        Self {
156            slots: HashMap::new(),
157        }
158    }
159
160    /// Inserts a chunk and returns the reassembled payload if this was the
161    /// final missing piece.
162    ///
163    /// # Returns
164    ///
165    /// - `Ok(Some(data))` — all chunks received; `data` is the reassembled
166    ///   byte string (the original serialized message).
167    /// - `Ok(None)` — chunk stored, more chunks needed.
168    /// - `Err(msg)` — invalid chunk (bad seq, bad total, duplicate completion).
169    fn insert(&mut self, chunk: &ChunkFields) -> Result<Option<Vec<u8>>, &'static str> {
170        self.evict_expired();
171
172        // If this is a duplicate of an already-received chunk, ignore it.
173        if let Some(partial) = self.slots.get(&chunk.id) {
174            if chunk.seq < partial.received.len() && partial.received[chunk.seq].is_some() {
175                debug!("duplicate chunk {} seq {} — ignoring", chunk.id, chunk.seq);
176                return Ok(None);
177            }
178        }
179
180        // Validate seq bounds.
181        if chunk.seq >= chunk.total {
182            return Err("chunk seq >= total");
183        }
184
185        // Evict oldest if at capacity (before borrowing via entry).
186        if !self.slots.contains_key(&chunk.id) && self.slots.len() >= MAX_REASSEMBLY_SLOTS {
187            self.evict_oldest();
188        }
189
190        // Get or create the partial message slot.
191        let partial = self
192            .slots
193            .entry(chunk.id.clone())
194            .or_insert_with(|| PartialMessage {
195                total: chunk.total,
196                received: vec![None; chunk.total],
197                deadline: Instant::now() + CHUNK_TIMEOUT,
198            });
199
200        // If the slot already exists with a different total, something is wrong.
201        if partial.total != chunk.total {
202            warn!(
203                "chunk total mismatch for {}: expected {}, got {}",
204                chunk.id, partial.total, chunk.total
205            );
206            return Err("chunk total mismatch");
207        }
208
209        // Ensure the received vector is large enough.
210        if chunk.seq >= partial.received.len() {
211            // This shouldn't happen if total is consistent, but guard anyway.
212            partial.received.resize(chunk.total, None);
213        }
214
215        // Store the decoded fragment.
216        let fragment = BASE64_STANDARD
217            .decode(&chunk.data)
218            .map_err(|_| "invalid base64 in chunk data")?;
219        partial.received[chunk.seq] = Some(fragment);
220
221        // Check if all chunks have arrived.
222        if partial.received.iter().all(|f| f.is_some()) {
223            // Reassemble: concatenate all fragments in order.
224            let mut assembled = Vec::with_capacity(
225                partial
226                    .received
227                    .iter()
228                    .map(|f| f.as_ref().map(|d| d.len()).unwrap_or(0))
229                    .sum(),
230            );
231            for data in partial.received.iter().flatten() {
232                assembled.extend_from_slice(data);
233            }
234            self.slots.remove(&chunk.id);
235            Ok(Some(assembled))
236        } else {
237            Ok(None)
238        }
239    }
240
241    /// Evicts slots whose deadline has passed.
242    fn evict_expired(&mut self) {
243        let now = Instant::now();
244        self.slots.retain(|id, partial| {
245            if partial.deadline <= now {
246                warn!("evicting expired incomplete chunk: {}", id);
247                false
248            } else {
249                true
250            }
251        });
252    }
253
254    /// Evicts the slot with the earliest deadline.
255    fn evict_oldest(&mut self) {
256        if let Some((oldest_id, _)) = self
257            .slots
258            .iter()
259            .min_by_key(|(_, partial)| partial.deadline)
260            .map(|(id, p)| (id.clone(), p.deadline))
261        {
262            warn!("evicting oldest chunk to make room: {}", oldest_id);
263            self.slots.remove(&oldest_id);
264        }
265    }
266
267    /// Returns the number of active reassembly slots.
268    #[cfg(test)]
269    fn len(&self) -> usize {
270        self.slots.len()
271    }
272}
273
274// ─── Chunk sender (T2) ──────────────────────────────────────────────────────
275
276/// Splits a serialized message into chunk envelopes for multicast broadcast.
277///
278/// Messages ≤ [`MAX_DATAGRAM_SIZE`] are returned as a single raw JSON string
279/// (no envelope) for backward compatibility.
280///
281/// Messages > [`MAX_DATAGRAM_SIZE`] are split into [`CHUNK_PAYLOAD_SIZE`]-byte
282/// fragments, each wrapped in a [`ChunkEnvelope`] and serialized to JSON.
283///
284/// # Arguments
285///
286/// - `data` — the serialized wire-format message string
287/// - `msg_id` — the message ID used for reassembly grouping
288///
289/// # Returns
290///
291/// A vector of strings, each ≤ [`MAX_DATAGRAM_SIZE`] bytes, ready for
292/// `socket.broadcast()`.
293fn chunk_message(data: &str, msg_id: &str) -> Vec<String> {
294    // If the message fits in a single datagram, send it raw.
295    if data.len() <= MAX_DATAGRAM_SIZE {
296        return vec![data.to_string()];
297    }
298
299    let data_bytes = data.as_bytes();
300    let total = data_bytes.len().div_ceil(CHUNK_PAYLOAD_SIZE);
301
302    let mut chunks = Vec::with_capacity(total);
303    for (seq, fragment) in data_bytes.chunks(CHUNK_PAYLOAD_SIZE).enumerate() {
304        let envelope = ChunkEnvelope {
305            beam_chunk: ChunkFields {
306                id: msg_id.to_string(),
307                seq,
308                total,
309                data: BASE64_STANDARD.encode(fragment),
310            },
311        };
312        let serialized = serde_json::to_string(&envelope)
313            .expect("chunk envelope serialization should never fail");
314        debug_assert!(
315            serialized.len() <= MAX_DATAGRAM_SIZE,
316            "chunk envelope {} bytes exceeds MAX_DATAGRAM_SIZE ({}): total={}, seq={}",
317            serialized.len(),
318            MAX_DATAGRAM_SIZE,
319            total,
320            seq
321        );
322        chunks.push(serialized);
323    }
324
325    chunks
326}
327
328// ─── Multicast adapter (T3) ─────────────────────────────────────────────────
329
330/// UDP multicast adapter for LAN peer discovery and sync.
331///
332/// Broadcasts Gun protocol messages to the multicast group `233.255.255.255:7654`
333/// and receives messages from other peers on the same LAN. Messages exceeding
334/// the safe UDP datagram size are transparently chunked and reassembled.
335pub struct Multicast {
336    socket: Arc<RwLock<MulticastSocket>>,
337    config: Config,
338}
339
340impl Multicast {
341    /// Creates a new multicast adapter bound to the default group.
342    ///
343    /// # Panics
344    ///
345    /// Panics if the multicast socket cannot be created (e.g. no network
346    /// interfaces available, or port 7654 is in use).
347    pub fn new(config: Config) -> Self {
348        let bind_address = SocketAddrV4::new([233, 255, 255, 255].into(), 7654);
349        let options = MulticastOptions {
350            buffer_size: 64 * 1024,
351            ..MulticastOptions::default()
352        };
353        let interfaces = all_ipv4_interfaces().expect("could not list multicast interfaces");
354        let socket = MulticastSocket::with_options(bind_address, interfaces, options)
355            .expect("could not create and bind multicast socket");
356        let socket = Arc::new(RwLock::new(socket));
357        Multicast { socket, config }
358    }
359
360    /// Parses an incoming multicast datagram and forwards it to the router.
361    ///
362    /// Handles three cases:
363    /// 1. Normal BEAM message — parse and forward immediately
364    /// 2. Chunk envelope — store in reassembly buffer; forward when complete
365    /// 3. Neither — log and discard
366    ///
367    /// Only `Put` and `Get` messages are forwarded — other message types
368    /// (Hi, Flush, RtcSignal) are not meaningful over multicast.
369    fn handle_incoming_message(
370        data: &str,
371        ctx: &ActorContext,
372        allow_public_space: bool,
373        reassembly: &mut ReassemblyBuffer,
374    ) {
375        debug!("in {} bytes", data.len());
376
377        // Try parsing as a normal BEAM message first (backward compat).
378        match Message::try_from(data, ctx.addr.clone(), allow_public_space) {
379            Ok(msgs) => {
380                for msg in msgs.into_iter() {
381                    Self::forward_message(msg, ctx);
382                }
383                return;
384            }
385            Err(_) => {
386                // Not a normal message — might be a chunk envelope.
387            }
388        }
389
390        // Try parsing as a chunk envelope.
391        match serde_json::from_str::<ChunkEnvelope>(data) {
392            Ok(envelope) => {
393                let chunk = &envelope.beam_chunk;
394                debug!("chunk id={} seq={}/{}", chunk.id, chunk.seq, chunk.total);
395
396                match reassembly.insert(chunk) {
397                    Ok(Some(reassembled)) => {
398                        // All chunks received — parse the reassembled message.
399                        match String::from_utf8(reassembled) {
400                            Ok(json_str) => {
401                                debug!(
402                                    "reassembled message {} ({} bytes)",
403                                    chunk.id,
404                                    json_str.len()
405                                );
406                                match Message::try_from(
407                                    &json_str,
408                                    ctx.addr.clone(),
409                                    allow_public_space,
410                                ) {
411                                    Ok(msgs) => {
412                                        for msg in msgs.into_iter() {
413                                            Self::forward_message(msg, ctx);
414                                        }
415                                    }
416                                    Err(e) => {
417                                        error!(
418                                            "reassembled message parse failed: {} (id={})",
419                                            e, chunk.id
420                                        );
421                                    }
422                                }
423                            }
424                            Err(e) => {
425                                error!(
426                                    "reassembled payload not valid UTF-8: {} (id={})",
427                                    e, chunk.id
428                                );
429                            }
430                        }
431                    }
432                    Ok(None) => {
433                        // Chunk stored, waiting for more.
434                    }
435                    Err(e) => {
436                        warn!("chunk insert failed: {} (id={})", e, chunk.id);
437                    }
438                }
439            }
440            Err(_) => {
441                // Neither a normal message nor a chunk envelope — discard.
442                debug!("discarding unrecognizable multicast datagram");
443            }
444        }
445    }
446
447    /// Forwards a parsed message to the router, filtering to Put/Get only.
448    fn forward_message(msg: Message, ctx: &ActorContext) {
449        match msg {
450            Message::Put(put) => {
451                let put = put.clone();
452                if let Err(e) = ctx.router.read().send(Message::Put(put)) {
453                    error!("failed to send message to node: {:?}", e);
454                }
455            }
456            Message::Get(get) => {
457                let get = get.clone();
458                if let Err(e) = ctx.router.read().send(Message::Get(get)) {
459                    error!("failed to send message to node: {:?}", e);
460                }
461            }
462            _ => {}
463        }
464    }
465
466    /// Broadcasts a serialized message over multicast, chunking if necessary.
467    async fn broadcast_message(&self, serialized: String, msg_id: String) {
468        let chunks = chunk_message(&serialized, &msg_id);
469        let socket = self.socket.read().await;
470        for chunk in chunks {
471            if let Err(e) = socket.broadcast(chunk.as_bytes()) {
472                error!("multicast send error: {}", e);
473            }
474        }
475    }
476}
477
478#[async_trait]
479impl Actor for Multicast {
480    async fn handle(&mut self, msg: Arc<Message>, ctx: &ActorContext) {
481        debug!("out {}", msg.get_id());
482        if msg.is_from(&ctx.addr) {
483            return;
484        }
485        match &*msg {
486            Message::Put(put) => {
487                let msg_id = put.id.clone();
488                let serialized = put.to_string();
489                self.broadcast_message(serialized, msg_id).await;
490            }
491            Message::Get(get) => {
492                let msg_id = get.id.clone();
493                let serialized = get.to_string();
494                self.broadcast_message(serialized, msg_id).await;
495            }
496            _ => {
497                debug!("not sending");
498            }
499        }
500    }
501
502    /// Returns `true` — multicast subscribes to all messages.
503    fn subscribe_to_everything(&self) -> bool {
504        true
505    }
506
507    async fn pre_start(&mut self, ctx: &ActorContext) {
508        info!("Syncing over multicast\n");
509
510        let ctx_clone = ctx.clone();
511
512        let bind_address = SocketAddrV4::new([233, 255, 255, 255].into(), 7654);
513        let options = MulticastOptions {
514            buffer_size: 64 * 1024,
515            ..MulticastOptions::default()
516        };
517        let interfaces = all_ipv4_interfaces().expect("could not list multicast interfaces");
518        let socket = MulticastSocket::with_options(bind_address, interfaces, options)
519            .expect("could not create and bind multicast socket");
520
521        let allow_public_space = self.config.allow_public_space;
522        ctx.blocking_child_task(move || {
523            let mut reassembly = ReassemblyBuffer::new();
524            loop {
525                if let Ok(message) = socket.receive() {
526                    // TODO: if message.from == multicast_[interface], don't resend to [interface]
527                    if let Ok(data) = std::str::from_utf8(&message.data) {
528                        Self::handle_incoming_message(
529                            data,
530                            &ctx_clone,
531                            allow_public_space,
532                            &mut reassembly,
533                        );
534                    }
535                }
536                if *ctx_clone.is_stopped.read() {
537                    break;
538                }
539            }
540        });
541    }
542
543    async fn stopping(&mut self, _ctx: &ActorContext) {
544        // The blocking child task checks is_stopped and will break on the
545        // next iteration. The multicast socket is dropped when the task
546        // completes. No additional cleanup needed.
547        info!("Multicast stopping");
548    }
549}
550
551// ─── Tests (T4) ─────────────────────────────────────────────────────────────
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    // ── ReassemblyBuffer tests ──
558
559    #[test]
560    fn test_reassembly_single_chunk() {
561        let mut buf = ReassemblyBuffer::new();
562        let data = b"hello world";
563        let encoded = BASE64_STANDARD.encode(data);
564
565        let chunk = ChunkFields {
566            id: "msg1".to_string(),
567            seq: 0,
568            total: 1,
569            data: encoded,
570        };
571
572        let result = buf.insert(&chunk).expect("insert should succeed");
573        assert_eq!(result, Some(b"hello world".to_vec()));
574        assert_eq!(buf.len(), 0); // slot was consumed
575    }
576
577    #[test]
578    fn test_reassembly_multiple_chunks_in_order() {
579        let mut buf = ReassemblyBuffer::new();
580        let fragments: Vec<Vec<u8>> = vec![b"AAA".to_vec(), b"BBB".to_vec(), b"CCC".to_vec()];
581
582        for (seq, frag) in fragments.iter().enumerate() {
583            let chunk = ChunkFields {
584                id: "msg2".to_string(),
585                seq,
586                total: 3,
587                data: BASE64_STANDARD.encode(frag),
588            };
589            let result = buf.insert(&chunk).expect("insert should succeed");
590            if seq < 2 {
591                assert!(result.is_none(), "should not be complete at seq {}", seq);
592            } else {
593                assert_eq!(
594                    result,
595                    Some(b"AAABBBCCC".to_vec()),
596                    "should reassemble on final chunk"
597                );
598            }
599        }
600        assert_eq!(buf.len(), 0);
601    }
602
603    #[test]
604    fn test_reassembly_multiple_chunks_out_of_order() {
605        let mut buf = ReassemblyBuffer::new();
606        let fragments: Vec<Vec<u8>> = vec![b"AAA".to_vec(), b"BBB".to_vec(), b"CCC".to_vec()];
607
608        // Insert in reverse order: seq 2, then 0, then 1.
609        let order = [2, 0, 1];
610        for &seq in &order {
611            let chunk = ChunkFields {
612                id: "msg3".to_string(),
613                seq,
614                total: 3,
615                data: BASE64_STANDARD.encode(&fragments[seq]),
616            };
617            let result = buf.insert(&chunk).expect("insert should succeed");
618            if seq != 1 {
619                assert!(result.is_none(), "should not be complete yet");
620            } else {
621                assert_eq!(
622                    result,
623                    Some(b"AAABBBCCC".to_vec()),
624                    "should reassemble when last chunk arrives"
625                );
626            }
627        }
628    }
629
630    #[test]
631    fn test_reassembly_duplicate_chunk_ignored() {
632        let mut buf = ReassemblyBuffer::new();
633
634        let chunk = ChunkFields {
635            id: "msg4".to_string(),
636            seq: 0,
637            total: 2,
638            data: BASE64_STANDARD.encode(b"AAA"),
639        };
640        buf.insert(&chunk).expect("first insert");
641
642        // Insert same chunk again.
643        let result = buf.insert(&chunk).expect("duplicate insert");
644        assert!(result.is_none(), "duplicate should return None");
645        assert_eq!(buf.len(), 1, "slot should still exist");
646    }
647
648    #[test]
649    fn test_reassembly_total_mismatch_rejected() {
650        let mut buf = ReassemblyBuffer::new();
651
652        let chunk1 = ChunkFields {
653            id: "msg5".to_string(),
654            seq: 0,
655            total: 3,
656            data: BASE64_STANDARD.encode(b"AAA"),
657        };
658        buf.insert(&chunk1).expect("first insert");
659
660        let chunk2 = ChunkFields {
661            id: "msg5".to_string(),
662            seq: 1,
663            total: 2, // different total!
664            data: BASE64_STANDARD.encode(b"BBB"),
665        };
666        let result = buf.insert(&chunk2);
667        assert!(result.is_err(), "total mismatch should be rejected");
668    }
669
670    #[test]
671    fn test_reassembly_seq_out_of_bounds_rejected() {
672        let mut buf = ReassemblyBuffer::new();
673
674        let chunk = ChunkFields {
675            id: "msg6".to_string(),
676            seq: 5,
677            total: 3,
678            data: BASE64_STANDARD.encode(b"AAA"),
679        };
680        let result = buf.insert(&chunk);
681        assert!(result.is_err(), "seq >= total should be rejected");
682    }
683
684    #[test]
685    fn test_reassembly_invalid_base64_rejected() {
686        let mut buf = ReassemblyBuffer::new();
687
688        let chunk = ChunkFields {
689            id: "msg7".to_string(),
690            seq: 0,
691            total: 1,
692            data: "not valid base64!!!".to_string(),
693        };
694        let result = buf.insert(&chunk);
695        assert!(result.is_err(), "invalid base64 should be rejected");
696    }
697
698    #[test]
699    fn test_reassembly_concurrent_messages() {
700        let mut buf = ReassemblyBuffer::new();
701
702        // Interleave chunks from two messages.
703        let chunks = [
704            ChunkFields {
705                id: "a".to_string(),
706                seq: 0,
707                total: 2,
708                data: BASE64_STANDARD.encode(b"A0"),
709            },
710            ChunkFields {
711                id: "b".to_string(),
712                seq: 0,
713                total: 2,
714                data: BASE64_STANDARD.encode(b"B0"),
715            },
716            ChunkFields {
717                id: "a".to_string(),
718                seq: 1,
719                total: 2,
720                data: BASE64_STANDARD.encode(b"A1"),
721            },
722            ChunkFields {
723                id: "b".to_string(),
724                seq: 1,
725                total: 2,
726                data: BASE64_STANDARD.encode(b"B1"),
727            },
728        ];
729
730        let results: Vec<_> = chunks.iter().map(|c| buf.insert(c).unwrap()).collect();
731
732        assert!(results[0].is_none()); // a:0
733        assert!(results[1].is_none()); // b:0
734        assert_eq!(results[2], Some(b"A0A1".to_vec())); // a:1 → complete
735        assert_eq!(results[3], Some(b"B0B1".to_vec())); // b:1 → complete
736    }
737
738    #[test]
739    fn test_reassembly_max_slots_eviction() {
740        let mut buf = ReassemblyBuffer::new();
741
742        // Fill up to MAX_REASSEMBLY_SLOTS with single-chunk-pending messages.
743        for i in 0..MAX_REASSEMBLY_SLOTS {
744            let chunk = ChunkFields {
745                id: format!("fill{}", i),
746                seq: 0,
747                total: 2, // leave incomplete
748                data: BASE64_STANDARD.encode(b"x"),
749            };
750            buf.insert(&chunk).expect("fill insert");
751        }
752        assert_eq!(buf.len(), MAX_REASSEMBLY_SLOTS);
753
754        // Insert one more — should evict the oldest.
755        let chunk = ChunkFields {
756            id: "new".to_string(),
757            seq: 0,
758            total: 2,
759            data: BASE64_STANDARD.encode(b"y"),
760        };
761        buf.insert(&chunk).expect("overflow insert");
762        assert_eq!(
763            buf.len(),
764            MAX_REASSEMBLY_SLOTS,
765            "should evict oldest to maintain cap"
766        );
767        // The "new" slot should exist.
768        assert!(buf.slots.contains_key("new"));
769        // The oldest ("fill0") should have been evicted.
770        assert!(!buf.slots.contains_key("fill0"));
771    }
772
773    #[test]
774    fn test_reassembly_empty_data() {
775        let mut buf = ReassemblyBuffer::new();
776        let chunk = ChunkFields {
777            id: "empty".to_string(),
778            seq: 0,
779            total: 1,
780            data: BASE64_STANDARD.encode(b""),
781        };
782        let result = buf.insert(&chunk).expect("empty insert");
783        assert_eq!(result, Some(Vec::new()));
784    }
785
786    // ── chunk_message tests ──
787
788    #[test]
789    fn test_chunk_small_message_passthrough() {
790        let msg =
791            r##"{"put":{"~test":{"_":{"#":"~test",">":{"name":1}},"name":"hello"}},"#":"abc123"}"##;
792        let chunks = chunk_message(msg, "abc123");
793        assert_eq!(chunks.len(), 1, "small message should not be chunked");
794        assert_eq!(chunks[0], msg, "passthrough should be identical");
795    }
796
797    #[test]
798    fn test_chunk_exactly_at_threshold() {
799        // A message exactly MAX_DATAGRAM_SIZE bytes should pass through.
800        let msg = "x".repeat(MAX_DATAGRAM_SIZE);
801        let chunks = chunk_message(&msg, "threshold");
802        assert_eq!(
803            chunks.len(),
804            1,
805            "message at threshold should not be chunked"
806        );
807    }
808
809    #[test]
810    fn test_chunk_one_byte_over_threshold() {
811        let msg = "x".repeat(MAX_DATAGRAM_SIZE + 1);
812        let chunks = chunk_message(&msg, "over");
813        assert!(chunks.len() > 1, "message over threshold should be chunked");
814
815        // Verify each chunk fits within MAX_DATAGRAM_SIZE.
816        for chunk in &chunks {
817            assert!(
818                chunk.len() <= MAX_DATAGRAM_SIZE,
819                "chunk of {} bytes exceeds MAX_DATAGRAM_SIZE",
820                chunk.len()
821            );
822        }
823    }
824
825    #[test]
826    fn test_chunk_round_trip_reassembly() {
827        let mut buf = ReassemblyBuffer::new();
828        // Create a message large enough to require multiple chunks.
829        let payload = "Z".repeat(MAX_DATAGRAM_SIZE * 3 + 42);
830        let msg_id = "roundtrip";
831
832        let chunks = chunk_message(&payload, msg_id);
833        assert!(chunks.len() > 1, "should produce multiple chunks");
834
835        // The first chunk is the raw message if it fit — but this one doesn't,
836        // so all chunks should be envelopes.
837        for (i, chunk) in chunks.iter().enumerate() {
838            let envelope: ChunkEnvelope =
839                serde_json::from_str(chunk).expect("chunk should be valid envelope");
840            assert_eq!(envelope.beam_chunk.id, msg_id);
841            assert_eq!(envelope.beam_chunk.seq, i);
842            assert_eq!(envelope.beam_chunk.total, chunks.len());
843
844            let result = buf
845                .insert(&envelope.beam_chunk)
846                .expect("insert should succeed");
847            if i < chunks.len() - 1 {
848                assert!(result.is_none(), "should not be complete at chunk {}", i);
849            } else {
850                let reassembled = result.expect("should be complete on last chunk");
851                let reassembled_str =
852                    String::from_utf8(reassembled).expect("reassembled should be UTF-8");
853                assert_eq!(
854                    reassembled_str, payload,
855                    "reassembled payload should match original"
856                );
857            }
858        }
859    }
860
861    #[test]
862    fn test_chunk_each_envelope_under_max() {
863        // Test with realistic Gun.js-style Put message content.
864        let large_value = "A".repeat(5000);
865        let msg = format!(
866            r##"{{"put":{{"~test":{{"_":{{"#":"~test",">":{{"data":1}}}},"data":"{}"}}}},"#":"bigmsg"}}"##,
867            large_value
868        );
869        let chunks = chunk_message(&msg, "bigmsg");
870        assert!(chunks.len() > 1, "large message should be chunked");
871
872        for (i, chunk) in chunks.iter().enumerate() {
873            assert!(
874                chunk.len() <= MAX_DATAGRAM_SIZE,
875                "chunk {} is {} bytes, exceeds MAX_DATAGRAM_SIZE ({})",
876                i,
877                chunk.len(),
878                MAX_DATAGRAM_SIZE
879            );
880        }
881    }
882
883    #[test]
884    fn test_chunk_empty_message() {
885        let chunks = chunk_message("", "empty");
886        assert_eq!(
887            chunks.len(),
888            1,
889            "empty message should be single passthrough"
890        );
891        assert_eq!(chunks[0], "");
892    }
893
894    #[test]
895    fn test_chunk_preserves_message_id() {
896        let msg = "x".repeat(MAX_DATAGRAM_SIZE + 100);
897        let chunks = chunk_message(&msg, "myID123");
898
899        for chunk in &chunks {
900            let envelope: ChunkEnvelope =
901                serde_json::from_str(chunk).expect("chunk should be valid envelope");
902            assert_eq!(envelope.beam_chunk.id, "myID123");
903        }
904    }
905
906    #[test]
907    fn test_chunk_total_is_consistent() {
908        let msg = "y".repeat(CHUNK_PAYLOAD_SIZE * 5 + 1);
909        let chunks = chunk_message(&msg, "consistency");
910
911        let total = chunks
912            .first()
913            .map(|c| {
914                let env: ChunkEnvelope = serde_json::from_str(c).expect("first chunk is envelope");
915                env.beam_chunk.total
916            })
917            .expect("should have at least one chunk");
918
919        assert_eq!(chunks.len(), total, "chunk count should match total field");
920
921        for (i, chunk) in chunks.iter().enumerate() {
922            let env: ChunkEnvelope = serde_json::from_str(chunk).expect("chunk is valid envelope");
923            assert_eq!(env.beam_chunk.total, total);
924            assert_eq!(env.beam_chunk.seq, i);
925        }
926    }
927}