use crate::utils::FxHashMap;
use base64::prelude::*;
use oko_multicast_socket::{MulticastOptions, MulticastSocket, all_ipv4_interfaces};
use serde::{Deserialize, Serialize};
use std::net::SocketAddrV4;
use web_time::{Duration, Instant};
use crate::Config;
use crate::actor::{Actor, ActorContext};
use crate::message::Message;
use async_trait::async_trait;
use log::{debug, error, info, warn};
use std::sync::Arc;
use tokio::sync::RwLock;
const MAX_DATAGRAM_SIZE: usize = 1400;
const CHUNK_PAYLOAD_SIZE: usize = 900;
const CHUNK_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_REASSEMBLY_SLOTS: usize = 64;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ChunkEnvelope {
beam_chunk: ChunkFields,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ChunkFields {
id: String,
seq: usize,
total: usize,
data: String,
}
#[derive(Debug)]
struct ReassemblyBuffer {
slots: FxHashMap<String, PartialMessage>,
}
#[derive(Debug)]
struct PartialMessage {
total: usize,
received: Vec<Option<Vec<u8>>>,
deadline: Instant,
}
impl ReassemblyBuffer {
fn new() -> Self {
Self {
slots: FxHashMap::default(),
}
}
fn insert(&mut self, chunk: &ChunkFields) -> Result<Option<Vec<u8>>, &'static str> {
self.evict_expired();
if let Some(partial) = self.slots.get(&chunk.id) {
if chunk.seq < partial.received.len() && partial.received[chunk.seq].is_some() {
debug!("duplicate chunk {} seq {} — ignoring", chunk.id, chunk.seq);
return Ok(None);
}
}
if chunk.seq >= chunk.total {
return Err("chunk seq >= total");
}
if !self.slots.contains_key(&chunk.id) && self.slots.len() >= MAX_REASSEMBLY_SLOTS {
self.evict_oldest();
}
let partial = self
.slots
.entry(chunk.id.clone())
.or_insert_with(|| PartialMessage {
total: chunk.total,
received: vec![None; chunk.total],
deadline: Instant::now() + CHUNK_TIMEOUT,
});
if partial.total != chunk.total {
warn!(
"chunk total mismatch for {}: expected {}, got {}",
chunk.id, partial.total, chunk.total
);
return Err("chunk total mismatch");
}
if chunk.seq >= partial.received.len() {
partial.received.resize(chunk.total, None);
}
let fragment = BASE64_STANDARD
.decode(&chunk.data)
.map_err(|_| "invalid base64 in chunk data")?;
partial.received[chunk.seq] = Some(fragment);
if partial.received.iter().all(|f| f.is_some()) {
let mut assembled = Vec::with_capacity(
partial
.received
.iter()
.map(|f| f.as_ref().map(|d| d.len()).unwrap_or(0))
.sum(),
);
for data in partial.received.iter().flatten() {
assembled.extend_from_slice(data);
}
self.slots.remove(&chunk.id);
Ok(Some(assembled))
} else {
Ok(None)
}
}
fn evict_expired(&mut self) {
let now = Instant::now();
self.slots.retain(|id, partial| {
if partial.deadline <= now {
warn!("evicting expired incomplete chunk: {}", id);
false
} else {
true
}
});
}
fn evict_oldest(&mut self) {
if let Some((oldest_id, _)) = self
.slots
.iter()
.min_by_key(|(_, partial)| partial.deadline)
.map(|(id, p)| (id.clone(), p.deadline))
{
warn!("evicting oldest chunk to make room: {}", oldest_id);
self.slots.remove(&oldest_id);
}
}
#[cfg(test)]
fn len(&self) -> usize {
self.slots.len()
}
}
fn chunk_message(data: &str, msg_id: &str) -> Vec<String> {
if data.len() <= MAX_DATAGRAM_SIZE {
return vec![data.to_string()];
}
let data_bytes = data.as_bytes();
let total = data_bytes.len().div_ceil(CHUNK_PAYLOAD_SIZE);
let mut chunks = Vec::with_capacity(total);
for (seq, fragment) in data_bytes.chunks(CHUNK_PAYLOAD_SIZE).enumerate() {
let envelope = ChunkEnvelope {
beam_chunk: ChunkFields {
id: msg_id.to_string(),
seq,
total,
data: BASE64_STANDARD.encode(fragment),
},
};
let serialized = serde_json::to_string(&envelope)
.expect("chunk envelope serialization should never fail");
debug_assert!(
serialized.len() <= MAX_DATAGRAM_SIZE,
"chunk envelope {} bytes exceeds MAX_DATAGRAM_SIZE ({}): total={}, seq={}",
serialized.len(),
MAX_DATAGRAM_SIZE,
total,
seq
);
chunks.push(serialized);
}
chunks
}
pub struct Multicast {
socket: Arc<RwLock<MulticastSocket>>,
config: Config,
}
impl Multicast {
pub fn new(config: Config) -> Self {
let bind_address = SocketAddrV4::new([233, 255, 255, 255].into(), 7654);
let options = MulticastOptions {
buffer_size: 64 * 1024,
..MulticastOptions::default()
};
let interfaces = all_ipv4_interfaces().expect("could not list multicast interfaces");
let socket = MulticastSocket::with_options(bind_address, interfaces, options)
.expect("could not create and bind multicast socket");
let socket = Arc::new(RwLock::new(socket));
Multicast { socket, config }
}
fn handle_incoming_message(
data: &str,
ctx: &ActorContext,
allow_public_space: bool,
reassembly: &mut ReassemblyBuffer,
) {
debug!("in {} bytes", data.len());
match Message::try_from(data, ctx.addr.clone(), allow_public_space) {
Ok(msgs) => {
for msg in msgs.into_iter() {
Self::forward_message(msg, ctx);
}
return;
}
Err(_) => {
}
}
match serde_json::from_str::<ChunkEnvelope>(data) {
Ok(envelope) => {
let chunk = &envelope.beam_chunk;
debug!("chunk id={} seq={}/{}", chunk.id, chunk.seq, chunk.total);
match reassembly.insert(chunk) {
Ok(Some(reassembled)) => {
match String::from_utf8(reassembled) {
Ok(json_str) => {
debug!(
"reassembled message {} ({} bytes)",
chunk.id,
json_str.len()
);
match Message::try_from(
&json_str,
ctx.addr.clone(),
allow_public_space,
) {
Ok(msgs) => {
for msg in msgs.into_iter() {
Self::forward_message(msg, ctx);
}
}
Err(e) => {
error!(
"reassembled message parse failed: {} (id={})",
e, chunk.id
);
}
}
}
Err(e) => {
error!(
"reassembled payload not valid UTF-8: {} (id={})",
e, chunk.id
);
}
}
}
Ok(None) => {
}
Err(e) => {
warn!("chunk insert failed: {} (id={})", e, chunk.id);
}
}
}
Err(_) => {
debug!("discarding unrecognizable multicast datagram");
}
}
}
fn forward_message(msg: Message, ctx: &ActorContext) {
match msg {
Message::Put(put) => {
let put = put.clone();
if let Err(e) = ctx.router.read().send(Message::Put(put)) {
error!("failed to send message to node: {:?}", e);
}
}
Message::Get(get) => {
let get = get.clone();
if let Err(e) = ctx.router.read().send(Message::Get(get)) {
error!("failed to send message to node: {:?}", e);
}
}
_ => {}
}
}
async fn broadcast_message(&self, serialized: String, msg_id: String) {
let chunks = chunk_message(&serialized, &msg_id);
let socket = self.socket.read().await;
for chunk in chunks {
if let Err(e) = socket.broadcast(chunk.as_bytes()) {
error!("multicast send error: {}", e);
}
}
}
}
#[async_trait]
impl Actor for Multicast {
async fn handle(&mut self, msg: Arc<Message>, ctx: &ActorContext) {
debug!("out {}", msg.get_id());
if msg.is_from(&ctx.addr) {
return;
}
match &*msg {
Message::Put(put) => {
let msg_id = put.id.clone();
let serialized = put.to_string();
self.broadcast_message(serialized, msg_id).await;
}
Message::Get(get) => {
let msg_id = get.id.clone();
let serialized = get.to_string();
self.broadcast_message(serialized, msg_id).await;
}
_ => {
debug!("not sending");
}
}
}
fn subscribe_to_everything(&self) -> bool {
true
}
async fn pre_start(&mut self, ctx: &ActorContext) {
info!("Syncing over multicast\n");
let ctx_clone = ctx.clone();
let bind_address = SocketAddrV4::new([233, 255, 255, 255].into(), 7654);
let options = MulticastOptions {
buffer_size: 64 * 1024,
..MulticastOptions::default()
};
let interfaces = all_ipv4_interfaces().expect("could not list multicast interfaces");
let socket = MulticastSocket::with_options(bind_address, interfaces, options)
.expect("could not create and bind multicast socket");
let allow_public_space = self.config.allow_public_space;
ctx.blocking_child_task(move || {
let mut reassembly = ReassemblyBuffer::new();
loop {
if let Ok(message) = socket.receive() {
if let Ok(data) = std::str::from_utf8(&message.data) {
Self::handle_incoming_message(
data,
&ctx_clone,
allow_public_space,
&mut reassembly,
);
}
}
if *ctx_clone.is_stopped.read() {
break;
}
}
});
}
async fn stopping(&mut self, _ctx: &ActorContext) {
info!("Multicast stopping");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_reassembly_single_chunk() {
let mut buf = ReassemblyBuffer::new();
let data = b"hello world";
let encoded = BASE64_STANDARD.encode(data);
let chunk = ChunkFields {
id: "msg1".to_string(),
seq: 0,
total: 1,
data: encoded,
};
let result = buf.insert(&chunk).expect("insert should succeed");
assert_eq!(result, Some(b"hello world".to_vec()));
assert_eq!(buf.len(), 0); }
#[test]
fn test_reassembly_multiple_chunks_in_order() {
let mut buf = ReassemblyBuffer::new();
let fragments: Vec<Vec<u8>> = vec![b"AAA".to_vec(), b"BBB".to_vec(), b"CCC".to_vec()];
for (seq, frag) in fragments.iter().enumerate() {
let chunk = ChunkFields {
id: "msg2".to_string(),
seq,
total: 3,
data: BASE64_STANDARD.encode(frag),
};
let result = buf.insert(&chunk).expect("insert should succeed");
if seq < 2 {
assert!(result.is_none(), "should not be complete at seq {}", seq);
} else {
assert_eq!(
result,
Some(b"AAABBBCCC".to_vec()),
"should reassemble on final chunk"
);
}
}
assert_eq!(buf.len(), 0);
}
#[test]
fn test_reassembly_multiple_chunks_out_of_order() {
let mut buf = ReassemblyBuffer::new();
let fragments: Vec<Vec<u8>> = vec![b"AAA".to_vec(), b"BBB".to_vec(), b"CCC".to_vec()];
let order = [2, 0, 1];
for &seq in &order {
let chunk = ChunkFields {
id: "msg3".to_string(),
seq,
total: 3,
data: BASE64_STANDARD.encode(&fragments[seq]),
};
let result = buf.insert(&chunk).expect("insert should succeed");
if seq != 1 {
assert!(result.is_none(), "should not be complete yet");
} else {
assert_eq!(
result,
Some(b"AAABBBCCC".to_vec()),
"should reassemble when last chunk arrives"
);
}
}
}
#[test]
fn test_reassembly_duplicate_chunk_ignored() {
let mut buf = ReassemblyBuffer::new();
let chunk = ChunkFields {
id: "msg4".to_string(),
seq: 0,
total: 2,
data: BASE64_STANDARD.encode(b"AAA"),
};
buf.insert(&chunk).expect("first insert");
let result = buf.insert(&chunk).expect("duplicate insert");
assert!(result.is_none(), "duplicate should return None");
assert_eq!(buf.len(), 1, "slot should still exist");
}
#[test]
fn test_reassembly_total_mismatch_rejected() {
let mut buf = ReassemblyBuffer::new();
let chunk1 = ChunkFields {
id: "msg5".to_string(),
seq: 0,
total: 3,
data: BASE64_STANDARD.encode(b"AAA"),
};
buf.insert(&chunk1).expect("first insert");
let chunk2 = ChunkFields {
id: "msg5".to_string(),
seq: 1,
total: 2, data: BASE64_STANDARD.encode(b"BBB"),
};
let result = buf.insert(&chunk2);
assert!(result.is_err(), "total mismatch should be rejected");
}
#[test]
fn test_reassembly_seq_out_of_bounds_rejected() {
let mut buf = ReassemblyBuffer::new();
let chunk = ChunkFields {
id: "msg6".to_string(),
seq: 5,
total: 3,
data: BASE64_STANDARD.encode(b"AAA"),
};
let result = buf.insert(&chunk);
assert!(result.is_err(), "seq >= total should be rejected");
}
#[test]
fn test_reassembly_invalid_base64_rejected() {
let mut buf = ReassemblyBuffer::new();
let chunk = ChunkFields {
id: "msg7".to_string(),
seq: 0,
total: 1,
data: "not valid base64!!!".to_string(),
};
let result = buf.insert(&chunk);
assert!(result.is_err(), "invalid base64 should be rejected");
}
#[test]
fn test_reassembly_concurrent_messages() {
let mut buf = ReassemblyBuffer::new();
let chunks = [
ChunkFields {
id: "a".to_string(),
seq: 0,
total: 2,
data: BASE64_STANDARD.encode(b"A0"),
},
ChunkFields {
id: "b".to_string(),
seq: 0,
total: 2,
data: BASE64_STANDARD.encode(b"B0"),
},
ChunkFields {
id: "a".to_string(),
seq: 1,
total: 2,
data: BASE64_STANDARD.encode(b"A1"),
},
ChunkFields {
id: "b".to_string(),
seq: 1,
total: 2,
data: BASE64_STANDARD.encode(b"B1"),
},
];
let results: Vec<_> = chunks.iter().map(|c| buf.insert(c).unwrap()).collect();
assert!(results[0].is_none()); assert!(results[1].is_none()); assert_eq!(results[2], Some(b"A0A1".to_vec())); assert_eq!(results[3], Some(b"B0B1".to_vec())); }
#[test]
fn test_reassembly_max_slots_eviction() {
let mut buf = ReassemblyBuffer::new();
for i in 0..MAX_REASSEMBLY_SLOTS {
let chunk = ChunkFields {
id: format!("fill{}", i),
seq: 0,
total: 2, data: BASE64_STANDARD.encode(b"x"),
};
buf.insert(&chunk).expect("fill insert");
}
assert_eq!(buf.len(), MAX_REASSEMBLY_SLOTS);
let chunk = ChunkFields {
id: "new".to_string(),
seq: 0,
total: 2,
data: BASE64_STANDARD.encode(b"y"),
};
buf.insert(&chunk).expect("overflow insert");
assert_eq!(
buf.len(),
MAX_REASSEMBLY_SLOTS,
"should evict oldest to maintain cap"
);
assert!(buf.slots.contains_key("new"));
assert!(!buf.slots.contains_key("fill0"));
}
#[test]
fn test_reassembly_empty_data() {
let mut buf = ReassemblyBuffer::new();
let chunk = ChunkFields {
id: "empty".to_string(),
seq: 0,
total: 1,
data: BASE64_STANDARD.encode(b""),
};
let result = buf.insert(&chunk).expect("empty insert");
assert_eq!(result, Some(Vec::new()));
}
#[test]
fn test_chunk_small_message_passthrough() {
let msg =
r##"{"put":{"~test":{"_":{"#":"~test",">":{"name":1}},"name":"hello"}},"#":"abc123"}"##;
let chunks = chunk_message(msg, "abc123");
assert_eq!(chunks.len(), 1, "small message should not be chunked");
assert_eq!(chunks[0], msg, "passthrough should be identical");
}
#[test]
fn test_chunk_exactly_at_threshold() {
let msg = "x".repeat(MAX_DATAGRAM_SIZE);
let chunks = chunk_message(&msg, "threshold");
assert_eq!(
chunks.len(),
1,
"message at threshold should not be chunked"
);
}
#[test]
fn test_chunk_one_byte_over_threshold() {
let msg = "x".repeat(MAX_DATAGRAM_SIZE + 1);
let chunks = chunk_message(&msg, "over");
assert!(chunks.len() > 1, "message over threshold should be chunked");
for chunk in &chunks {
assert!(
chunk.len() <= MAX_DATAGRAM_SIZE,
"chunk of {} bytes exceeds MAX_DATAGRAM_SIZE",
chunk.len()
);
}
}
#[test]
fn test_chunk_round_trip_reassembly() {
let mut buf = ReassemblyBuffer::new();
let payload = "Z".repeat(MAX_DATAGRAM_SIZE * 3 + 42);
let msg_id = "roundtrip";
let chunks = chunk_message(&payload, msg_id);
assert!(chunks.len() > 1, "should produce multiple chunks");
for (i, chunk) in chunks.iter().enumerate() {
let envelope: ChunkEnvelope =
serde_json::from_str(chunk).expect("chunk should be valid envelope");
assert_eq!(envelope.beam_chunk.id, msg_id);
assert_eq!(envelope.beam_chunk.seq, i);
assert_eq!(envelope.beam_chunk.total, chunks.len());
let result = buf
.insert(&envelope.beam_chunk)
.expect("insert should succeed");
if i < chunks.len() - 1 {
assert!(result.is_none(), "should not be complete at chunk {}", i);
} else {
let reassembled = result.expect("should be complete on last chunk");
let reassembled_str =
String::from_utf8(reassembled).expect("reassembled should be UTF-8");
assert_eq!(
reassembled_str, payload,
"reassembled payload should match original"
);
}
}
}
#[test]
fn test_chunk_each_envelope_under_max() {
let large_value = "A".repeat(5000);
let msg = format!(
r##"{{"put":{{"~test":{{"_":{{"#":"~test",">":{{"data":1}}}},"data":"{}"}}}},"#":"bigmsg"}}"##,
large_value
);
let chunks = chunk_message(&msg, "bigmsg");
assert!(chunks.len() > 1, "large message should be chunked");
for (i, chunk) in chunks.iter().enumerate() {
assert!(
chunk.len() <= MAX_DATAGRAM_SIZE,
"chunk {} is {} bytes, exceeds MAX_DATAGRAM_SIZE ({})",
i,
chunk.len(),
MAX_DATAGRAM_SIZE
);
}
}
#[test]
fn test_chunk_empty_message() {
let chunks = chunk_message("", "empty");
assert_eq!(
chunks.len(),
1,
"empty message should be single passthrough"
);
assert_eq!(chunks[0], "");
}
#[test]
fn test_chunk_preserves_message_id() {
let msg = "x".repeat(MAX_DATAGRAM_SIZE + 100);
let chunks = chunk_message(&msg, "myID123");
for chunk in &chunks {
let envelope: ChunkEnvelope =
serde_json::from_str(chunk).expect("chunk should be valid envelope");
assert_eq!(envelope.beam_chunk.id, "myID123");
}
}
#[test]
fn test_chunk_total_is_consistent() {
let msg = "y".repeat(CHUNK_PAYLOAD_SIZE * 5 + 1);
let chunks = chunk_message(&msg, "consistency");
let total = chunks
.first()
.map(|c| {
let env: ChunkEnvelope = serde_json::from_str(c).expect("first chunk is envelope");
env.beam_chunk.total
})
.expect("should have at least one chunk");
assert_eq!(chunks.len(), total, "chunk count should match total field");
for (i, chunk) in chunks.iter().enumerate() {
let env: ChunkEnvelope = serde_json::from_str(chunk).expect("chunk is valid envelope");
assert_eq!(env.beam_chunk.total, total);
assert_eq!(env.beam_chunk.seq, i);
}
}
}