#![warn(missing_docs)]
use std::collections::btree_map::BTreeMap;
use std::collections::HashMap;
use bytes::Bytes;
use rings_transport::core::transport::MAX_DATA_CHANNEL_MESSAGE_SIZE;
use serde::Deserialize;
use serde::Serialize;
use uuid::Uuid;
use crate::consts::DEFAULT_TTL_MS;
use crate::consts::MAX_CHUNK_ENVELOPE_OVERHEAD;
use crate::consts::MAX_TTL_MS;
use crate::consts::MIN_CHUNK_DATA;
use crate::consts::TRANSPORT_CUSTOM_OVERHEAD;
use crate::consts::TRANSPORT_MAX_SIZE;
use crate::consts::TS_OFFSET_TOLERANCE_MS;
use crate::error::Error;
use crate::error::Result;
use crate::utils::get_epoch_ms;
#[derive(Debug, Clone, Copy)]
pub struct ReassemblyLimits {
pub max_pending_messages: usize,
pub max_chunk_data_len: usize,
pub max_message_bytes: usize,
pub max_chunks_per_message: usize,
pub max_total_buffered_cost: usize,
pub slot_overhead: usize,
pub max_completed_ids: usize,
}
impl ReassemblyLimits {
pub fn production() -> Self {
Self {
max_pending_messages: 512,
max_chunk_data_len: MAX_DATA_CHANNEL_MESSAGE_SIZE,
max_message_bytes: TRANSPORT_MAX_SIZE,
max_chunks_per_message: TRANSPORT_MAX_SIZE / MIN_CHUNK_DATA + 1,
max_total_buffered_cost: TRANSPORT_MAX_SIZE * 4,
slot_overhead: 128,
max_completed_ids: 1024,
}
}
pub fn constrained() -> Self {
const CONSTRAINED_MESSAGE_BYTES: usize = 4 * 1024 * 1024;
const CONSTRAINED_TOTAL_COST: usize = 8 * 1024 * 1024;
Self {
max_pending_messages: 64,
max_chunk_data_len: MAX_DATA_CHANNEL_MESSAGE_SIZE,
max_message_bytes: CONSTRAINED_MESSAGE_BYTES,
max_chunks_per_message: CONSTRAINED_MESSAGE_BYTES / MIN_CHUNK_DATA + 1,
max_total_buffered_cost: CONSTRAINED_TOTAL_COST,
slot_overhead: 128,
max_completed_ids: 256,
}
}
fn normalized(self) -> Self {
Self {
max_pending_messages: self.max_pending_messages.max(1),
max_chunk_data_len: self.max_chunk_data_len.max(1),
max_message_bytes: self.max_message_bytes.max(1),
max_chunks_per_message: self.max_chunks_per_message.max(1),
max_total_buffered_cost: self.max_total_buffered_cost.max(1),
slot_overhead: self.slot_overhead,
max_completed_ids: self.max_completed_ids.max(1),
}
}
}
impl Default for ReassemblyLimits {
fn default() -> Self {
Self::production()
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Chunk {
pub chunk: [usize; 2],
pub data: Bytes,
pub meta: ChunkMeta,
}
impl Chunk {
pub fn to_bincode(&self) -> Result<Bytes> {
bincode::serialize(self)
.map(Bytes::from)
.map_err(Error::BincodeSerialize)
}
pub fn from_bincode(data: &[u8]) -> Result<Self> {
bincode::deserialize(data).map_err(Error::BincodeDeserialize)
}
}
#[derive(Debug, Copy, Clone, Deserialize, Serialize)]
pub struct ChunkMeta {
pub id: uuid::Uuid,
pub ts_ms: u128,
pub ttl_ms: u64,
}
impl Default for ChunkMeta {
fn default() -> Self {
Self {
id: uuid::Uuid::new_v4(),
ts_ms: get_epoch_ms(),
ttl_ms: DEFAULT_TTL_MS,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ChunkList(Vec<Chunk>);
impl ChunkList {
pub fn split(bytes: &Bytes, chunk_size: usize) -> Self {
let chunk_size = chunk_size.max(1);
let chunks: Vec<Bytes> = bytes
.chunks(chunk_size)
.map(|c| c.to_vec().into())
.collect();
let chunks_len: usize = chunks.len();
let meta = ChunkMeta::default();
Self(
chunks
.into_iter()
.enumerate()
.map(|(i, data)| Chunk {
meta,
chunk: [i, chunks_len],
data,
})
.collect::<Vec<Chunk>>(),
)
}
pub fn stream(bytes: Bytes, chunk_size: usize) -> impl Iterator<Item = Chunk> {
let chunk_size = chunk_size.max(1);
let total = bytes.len().div_ceil(chunk_size);
let meta = ChunkMeta::default();
(0..total).map(move |i| {
let start = i * chunk_size;
let end = start.saturating_add(chunk_size).min(bytes.len());
Chunk {
meta,
chunk: [i, total],
data: bytes.slice(start..end),
}
})
}
pub fn to_vec(&self) -> Vec<Chunk> {
self.0.clone()
}
pub fn as_vec(&self) -> &Vec<Chunk> {
&self.0
}
}
impl IntoIterator for &ChunkList {
type Item = Chunk;
type IntoIter = std::vec::IntoIter<Chunk>;
fn into_iter(self) -> Self::IntoIter {
self.to_vec().into_iter()
}
}
impl IntoIterator for ChunkList {
type Item = Chunk;
type IntoIter = std::vec::IntoIter<Chunk>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
impl From<ChunkList> for Vec<Chunk> {
fn from(l: ChunkList) -> Self {
l.0
}
}
impl From<Vec<Chunk>> for ChunkList {
fn from(data: Vec<Chunk>) -> Self {
Self(data)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Framing {
Whole,
Chunked {
chunk_size: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WireReserves {
pub whole: usize,
pub chunk: usize,
pub min_chunk_data: usize,
}
impl WireReserves {
pub const PRODUCTION: Self = Self {
whole: TRANSPORT_CUSTOM_OVERHEAD,
chunk: MAX_CHUNK_ENVELOPE_OVERHEAD + TRANSPORT_CUSTOM_OVERHEAD,
min_chunk_data: MIN_CHUNK_DATA,
};
pub fn plan(&self, payload_len: usize, max_message_size: usize) -> Option<Framing> {
let whole_fits = payload_len
.checked_add(self.whole)
.is_some_and(|wire| wire <= max_message_size);
if whole_fits {
return Some(Framing::Whole);
}
let min_viable = self.chunk.checked_add(self.min_chunk_data)?;
(max_message_size >= min_viable).then(|| Framing::Chunked {
chunk_size: max_message_size - self.chunk,
})
}
}
struct Pending {
total: usize,
slots: BTreeMap<usize, Bytes>,
data_bytes: usize,
ts_ms: u128,
ttl_ms: u64,
}
impl Pending {
fn new(total: usize, ts_ms: u128, ttl_ms: u64) -> Self {
Self {
total,
slots: BTreeMap::new(),
data_bytes: 0,
ts_ms,
ttl_ms,
}
}
fn is_complete(&self) -> bool {
self.slots.len() == self.total
}
fn cost(&self, slot_overhead: usize) -> usize {
self.slots
.len()
.saturating_mul(slot_overhead)
.saturating_add(self.data_bytes)
}
fn assemble(self) -> Bytes {
self.slots.into_values().flatten().collect()
}
}
pub struct MessageReassembler {
pending: HashMap<Uuid, Pending>,
buffered_cost: usize,
completed: std::collections::VecDeque<(Uuid, u128)>,
completed_ids: std::collections::HashSet<Uuid>,
limits: ReassemblyLimits,
}
impl Default for MessageReassembler {
fn default() -> Self {
Self::with_limits(ReassemblyLimits::production())
}
}
impl MessageReassembler {
pub fn new() -> Self {
Self::default()
}
pub fn with_limits(limits: ReassemblyLimits) -> Self {
Self {
pending: HashMap::new(),
buffered_cost: 0,
completed: std::collections::VecDeque::new(),
completed_ids: std::collections::HashSet::new(),
limits: limits.normalized(),
}
}
fn mark_completed(&mut self, id: Uuid, expiry: u128) {
if self.completed_ids.insert(id) {
self.completed.push_back((id, expiry));
}
while self.completed.len() > self.limits.max_completed_ids {
if let Some((old, _)) = self.completed.pop_front() {
self.completed_ids.remove(&old);
}
}
}
pub fn pending_count(&self) -> usize {
self.pending.len()
}
pub fn remove_expired(&mut self) {
self.remove_expired_at(get_epoch_ms());
}
fn remove_expired_at(&mut self, now: u128) {
let buffered_cost = &mut self.buffered_cost;
let slot_overhead = self.limits.slot_overhead;
self.pending.retain(|_, p| {
let alive = p.ts_ms.saturating_add(p.ttl_ms as u128) > now;
if !alive {
*buffered_cost = buffered_cost.saturating_sub(p.cost(slot_overhead));
}
alive
});
let completed_ids = &mut self.completed_ids;
self.completed.retain(|&(id, expiry)| {
let alive = expiry > now;
if !alive {
completed_ids.remove(&id);
}
alive
});
}
pub fn remove(&mut self, id: Uuid) {
if let Some(p) = self.pending.remove(&id) {
self.buffered_cost -= p.cost(self.limits.slot_overhead);
}
}
pub fn handle(&mut self, chunk: Chunk) -> Option<Bytes> {
self.handle_at(chunk, get_epoch_ms())
}
fn handle_at(&mut self, chunk: Chunk, now: u128) -> Option<Bytes> {
self.remove_expired_at(now);
match self.classify(&chunk, now) {
Ok(cost) => self.admit(chunk, cost),
Err(reason) => {
tracing::debug!(?reason, id = ?chunk.meta.id, "reassembler dropped chunk");
None
}
}
}
fn classify(&self, chunk: &Chunk, now: u128) -> std::result::Result<usize, Rejected> {
let meta = &chunk.meta;
if meta.ttl_ms > MAX_TTL_MS {
return Err(Rejected::TtlTooLarge);
}
if meta.ts_ms.saturating_sub(TS_OFFSET_TOLERANCE_MS) > now {
return Err(Rejected::FutureTimestamp);
}
if meta.ts_ms.saturating_add(meta.ttl_ms as u128) <= now {
return Err(Rejected::Expired);
}
let [position, total] = chunk.chunk;
if total == 0 || position >= total {
return Err(Rejected::Malformed);
}
if total > self.limits.max_chunks_per_message {
return Err(Rejected::TooManyChunks);
}
if chunk.data.len() > self.limits.max_chunk_data_len {
return Err(Rejected::ChunkTooLarge);
}
if self.completed_ids.contains(&meta.id) {
return Err(Rejected::AlreadyCompleted);
}
let buffered_for_id = match self.pending.get(&meta.id) {
None if self.pending.len() >= self.limits.max_pending_messages => {
return Err(Rejected::PendingFull);
}
None => 0,
Some(p) => {
if p.total != total {
return Err(Rejected::TotalMismatch);
}
if p.ts_ms != meta.ts_ms || p.ttl_ms != meta.ttl_ms {
return Err(Rejected::MetadataMismatch);
}
if p.slots.contains_key(&position) {
return Err(Rejected::DuplicatePosition);
}
p.data_bytes
}
};
if buffered_for_id.saturating_add(chunk.data.len()) > self.limits.max_message_bytes {
return Err(Rejected::PerMessageBytes);
}
let cost = chunk.data.len().saturating_add(self.limits.slot_overhead);
if self.buffered_cost.saturating_add(cost) > self.limits.max_total_buffered_cost {
return Err(Rejected::GlobalBudget);
}
Ok(cost)
}
fn admit(&mut self, chunk: Chunk, cost: usize) -> Option<Bytes> {
let id = chunk.meta.id;
let [position, _total] = chunk.chunk;
let pending = self
.pending
.entry(id)
.or_insert_with(|| Pending::new(chunk.chunk[1], chunk.meta.ts_ms, chunk.meta.ttl_ms));
pending.data_bytes = pending.data_bytes.saturating_add(chunk.data.len());
pending.slots.insert(position, chunk.data);
self.buffered_cost = self.buffered_cost.saturating_add(cost);
if !pending.is_complete() {
return None;
}
let done = self.pending.remove(&id)?;
self.buffered_cost = self
.buffered_cost
.saturating_sub(done.cost(self.limits.slot_overhead));
self.mark_completed(id, done.ts_ms.saturating_add(done.ttl_ms as u128));
Some(done.assemble())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Rejected {
TtlTooLarge,
FutureTimestamp,
Expired,
Malformed,
TooManyChunks,
ChunkTooLarge,
AlreadyCompleted,
PendingFull,
TotalMismatch,
MetadataMismatch,
DuplicatePosition,
PerMessageBytes,
GlobalBudget,
}
#[cfg(test)]
mod test {
use super::*;
fn chunks_of(data: &Bytes, mtu: usize) -> Vec<Chunk> {
ChunkList::split(data, mtu).into()
}
fn small_limits() -> ReassemblyLimits {
ReassemblyLimits {
max_pending_messages: 4,
max_chunk_data_len: 16,
max_message_bytes: 100,
max_chunks_per_message: 64,
max_total_buffered_cost: 256,
slot_overhead: 8,
max_completed_ids: 8,
}
}
#[test]
fn constrained_reassembly_limits_are_smaller_than_production() {
let production = ReassemblyLimits::production();
let constrained = ReassemblyLimits::constrained();
assert!(constrained.max_pending_messages < production.max_pending_messages);
assert!(constrained.max_message_bytes < production.max_message_bytes);
assert!(constrained.max_chunks_per_message < production.max_chunks_per_message);
assert!(constrained.max_total_buffered_cost < production.max_total_buffered_cost);
assert!(constrained.max_completed_ids < production.max_completed_ids);
assert_eq!(
constrained.max_chunk_data_len,
production.max_chunk_data_len
);
}
#[test]
fn test_data_chunks() {
let data = "helloworld".repeat(2).into();
let ret: Vec<Chunk> = ChunkList::split(&data, 32).into();
assert_eq!(ret.len(), 1);
assert_eq!(ret[ret.len() - 1].chunk, [0, 1]);
let data = "helloworld".repeat(1024).into();
let ret: Vec<Chunk> = ChunkList::split(&data, 32).into();
assert_eq!(ret.len(), 10 * 1024 / 32);
assert_eq!(ret[ret.len() - 1].chunk, [319, 320]);
}
#[test]
fn split_empty_yields_no_chunks() {
assert!(ChunkList::split(&Bytes::new(), 32).to_vec().is_empty());
}
#[test]
fn split_exact_multiple_all_full() {
let data: Bytes = vec![0u8; 64].into();
let chunks = ChunkList::split(&data, 32).to_vec();
assert_eq!(chunks.len(), 2);
assert!(chunks.iter().all(|c| c.data.len() == 32));
assert_eq!(chunks[0].chunk, [0, 2]);
assert_eq!(chunks[1].chunk, [1, 2]);
}
#[test]
fn split_non_multiple_last_is_remainder() {
let data: Bytes = vec![0u8; 70].into();
let chunks = ChunkList::split(&data, 32).to_vec();
assert_eq!(chunks.len(), 3);
assert_eq!(chunks[0].data.len(), 32);
assert_eq!(chunks[1].data.len(), 32);
assert_eq!(chunks[2].data.len(), 6);
}
#[test]
fn split_larger_than_data_is_single_chunk() {
let data: Bytes = vec![0u8; 10].into();
let chunks = ChunkList::split(&data, 1024).to_vec();
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].chunk, [0, 1]);
}
#[test]
fn split_zero_size_is_clamped_to_one() {
let data: Bytes = vec![0u8; 4].into();
let chunks = ChunkList::split(&data, 0).to_vec();
assert_eq!(chunks.len(), 4);
assert!(chunks.iter().all(|c| c.data.len() == 1));
}
#[test]
fn split_chunks_share_one_message_id() {
let data: Bytes = vec![0u8; 100].into();
let chunks = ChunkList::split(&data, 32).to_vec();
let id = chunks[0].meta.id;
assert!(chunks.iter().all(|c| c.meta.id == id));
}
#[test]
fn split_then_reassemble_round_trips() {
for (len, size) in [
(1usize, 7usize),
(7, 7),
(8, 7),
(100, 7),
(1000, 64),
(5, 1),
] {
let data: Bytes = (0..len).map(|i| i as u8).collect::<Vec<u8>>().into();
let mut r = MessageReassembler::new();
let mut out = None;
for c in ChunkList::split(&data, size) {
out = r.handle(c).or(out);
}
assert_eq!(out.unwrap(), data, "len={len} size={size}");
}
}
fn reserves(whole: usize, chunk: usize, min_chunk_data: usize) -> WireReserves {
WireReserves {
whole,
chunk,
min_chunk_data,
}
}
#[test]
fn plan_whole_includes_whole_overhead() {
let r = reserves(10, 20, 1);
assert_eq!(r.plan(0, 100), Some(Framing::Whole));
assert_eq!(r.plan(90, 100), Some(Framing::Whole));
assert_eq!(r.plan(91, 100), Some(Framing::Chunked { chunk_size: 80 }));
}
#[test]
fn plan_chunk_size_reserves_overhead() {
let (limit, chunk_overhead) = (65536usize, 4096usize);
let Some(Framing::Chunked { chunk_size }) =
reserves(16, chunk_overhead, 16).plan(limit * 2, limit)
else {
panic!("expected chunked");
};
assert_eq!(chunk_size, limit - chunk_overhead);
assert!(chunk_size + chunk_overhead <= limit);
}
#[test]
fn plan_none_when_chunk_too_small() {
assert_eq!(reserves(4, 10, 1).plan(100, 5), None); assert_eq!(reserves(4, 10, 1).plan(100, 10), None); assert_eq!(
reserves(4, 10, 1).plan(100, 11),
Some(Framing::Chunked { chunk_size: 1 })
);
assert_eq!(reserves(4, 10, 8).plan(100, 17), None); assert_eq!(
reserves(4, 10, 8).plan(100, 18),
Some(Framing::Chunked { chunk_size: 8 })
);
}
#[test]
fn plan_is_total_on_overflow() {
assert_eq!(
reserves(10, 20, 1).plan(usize::MAX, 100),
Some(Framing::Chunked { chunk_size: 80 })
);
assert_eq!(reserves(10, 20, 1).plan(usize::MAX, 10), None);
}
#[test]
fn reassembles_in_order() {
let data: Bytes = "helloworld".repeat(1024).into();
let mut r = MessageReassembler::new();
let chunks = chunks_of(&data, 32);
let mut out = None;
for c in chunks {
out = r.handle(c).or(out);
}
assert_eq!(out.unwrap(), data);
assert_eq!(r.pending_count(), 0, "completed message is forgotten");
}
#[test]
fn reassembles_out_of_order() {
let data: Bytes = "helloworld".repeat(64).into();
let mut chunks = chunks_of(&data, 32);
chunks.reverse();
let mut r = MessageReassembler::new();
let mut out = None;
for c in chunks {
out = r.handle(c).or(out);
}
assert_eq!(out.unwrap(), data);
}
#[test]
fn full_retransmit_after_completion_is_not_redelivered() {
let data: Bytes = "helloworld".repeat(64).into();
let chunks = chunks_of(&data, 32);
assert!(chunks.len() > 1, "need a multi-chunk message for this test");
let mut r = MessageReassembler::new();
let mut first = None;
for c in chunks.clone() {
first = r.handle(c).or(first);
}
assert_eq!(first.unwrap(), data, "first assembly delivers once");
assert_eq!(r.pending_count(), 0);
for c in chunks {
assert!(
r.handle(c).is_none(),
"a retransmit of an already-completed message must be dropped"
);
}
assert_eq!(
r.pending_count(),
0,
"no pending re-opened by the retransmit"
);
}
#[test]
fn duplicate_chunk_does_not_break_reassembly() {
let data: Bytes = "helloworld".repeat(8).into(); let chunks = chunks_of(&data, 32);
assert!(chunks.len() >= 2);
let mut r = MessageReassembler::new();
assert!(r.handle(chunks[0].clone()).is_none());
for c in &chunks[1..] {
let _ = r.handle(chunks[0].clone()); if let Some(out) = r.handle(c.clone()) {
assert_eq!(out, data);
assert_eq!(r.pending_count(), 0);
return;
}
}
panic!("message never completed despite all chunks arriving");
}
#[test]
fn interleaved_messages_are_isolated() {
let d1: Bytes = "hello".repeat(64).into();
let d2: Bytes = "world".repeat(64).into();
let c1 = chunks_of(&d1, 32);
let c2 = chunks_of(&d2, 32);
let mut r = MessageReassembler::new();
let (mut o1, mut o2) = (None, None);
for pair in c1.iter().zip(c2.iter()) {
o1 = r.handle(pair.0.clone()).or(o1);
o2 = r.handle(pair.1.clone()).or(o2);
}
for c in c1.iter().chain(c2.iter()) {
let out = r.handle(c.clone());
o1 = out.clone().filter(|b| *b == d1).or(o1);
o2 = out.filter(|b| *b == d2).or(o2);
}
assert_eq!(o1.unwrap(), d1);
assert_eq!(o2.unwrap(), d2);
}
#[test]
fn incomplete_message_stays_pending() {
let data: Bytes = "helloworld".repeat(64).into();
let chunks = chunks_of(&data, 32);
let mut r = MessageReassembler::new();
for c in &chunks[..chunks.len() - 1] {
assert!(r.handle(c.clone()).is_none());
}
assert_eq!(r.pending_count(), 1);
let out = r.handle(chunks.last().unwrap().clone());
assert_eq!(out.unwrap(), data);
}
#[test]
fn malformed_chunks_are_dropped() {
let mut r = MessageReassembler::new();
assert!(r
.handle(Chunk {
chunk: [0, 0],
data: Bytes::from_static(b"x"),
meta: ChunkMeta::default(),
})
.is_none());
assert!(r
.handle(Chunk {
chunk: [5, 3],
data: Bytes::from_static(b"x"),
meta: ChunkMeta::default(),
})
.is_none());
assert_eq!(r.pending_count(), 0);
}
#[test]
fn old_timestamp_is_dropped_without_panic() {
let mut r = MessageReassembler::new();
let out = r.handle(Chunk {
chunk: [0, 1],
data: Bytes::from_static(b"ok"),
meta: ChunkMeta {
id: Uuid::new_v4(),
ts_ms: 0,
ttl_ms: DEFAULT_TTL_MS,
},
});
assert!(out.is_none());
assert_eq!(r.pending_count(), 0);
}
#[test]
fn expired_single_chunk_is_not_delivered() {
let mut r = MessageReassembler::new();
let now = get_epoch_ms();
let out = r.handle(Chunk {
chunk: [0, 1],
data: Bytes::from_static(b"x"),
meta: ChunkMeta {
id: Uuid::new_v4(),
ts_ms: now.saturating_sub(1000),
ttl_ms: 100, },
});
assert!(out.is_none());
assert_eq!(r.pending_count(), 0);
}
#[test]
fn oversize_chunk_data_is_rejected() {
let limits = small_limits();
let mut r = MessageReassembler::with_limits(limits);
let data: Bytes = vec![0u8; limits.max_chunk_data_len + 1].into();
let out = r.handle(Chunk {
chunk: [0, 1],
data,
meta: ChunkMeta::default(),
});
assert!(out.is_none());
assert_eq!(r.pending_count(), 0);
assert_eq!(r.buffered_cost, 0);
}
#[test]
fn buffered_cost_returns_to_zero_after_completion() {
let data: Bytes = "helloworld".repeat(100).into();
let mut r = MessageReassembler::new();
for c in ChunkList::split(&data, 32) {
r.handle(c);
}
assert_eq!(r.pending_count(), 0);
assert_eq!(r.buffered_cost, 0, "completing a message frees its budget");
}
#[test]
fn per_message_byte_cap_bounds_one_id() {
let limits = small_limits();
let mut r = MessageReassembler::with_limits(limits);
let meta = ChunkMeta::default();
let data: Bytes = vec![0u8; limits.max_chunk_data_len].into();
let total = limits.max_chunks_per_message;
let mut accepted = 0usize;
for position in 0..50 {
let before = r.pending.get(&meta.id).map(|p| p.slots.len()).unwrap_or(0);
r.handle(Chunk {
meta,
chunk: [position, total],
data: data.clone(),
});
let after = r.pending.get(&meta.id).map(|p| p.slots.len()).unwrap_or(0);
if after > before {
accepted += 1;
}
}
let pending = r.pending.get(&meta.id).expect("still pending");
assert!(
pending.data_bytes <= limits.max_message_bytes,
"per-message buffered data must stay within the cap"
);
assert!(
accepted < 50,
"the cap must reject some chunks, got {accepted}"
);
assert_eq!(
r.buffered_cost,
pending.cost(limits.slot_overhead),
"accounting stays exact"
);
}
#[test]
fn global_cost_cap_bounds_total() {
let limits = small_limits();
let mut r = MessageReassembler::with_limits(limits);
for _ in 0..(limits.max_pending_messages * 4) {
r.handle(Chunk {
chunk: [0, 2],
data: vec![0u8; limits.max_chunk_data_len].into(),
meta: ChunkMeta::default(),
});
}
assert!(
r.buffered_cost <= limits.max_total_buffered_cost,
"global buffered cost {} exceeded cap {}",
r.buffered_cost,
limits.max_total_buffered_cost
);
}
#[test]
fn future_timestamp_is_dropped() {
let mut r = MessageReassembler::new();
let out = r.handle(Chunk {
chunk: [0, 1],
data: Bytes::from_static(b"x"),
meta: ChunkMeta {
id: Uuid::new_v4(),
ts_ms: get_epoch_ms() + 10 * TS_OFFSET_TOLERANCE_MS,
ttl_ms: DEFAULT_TTL_MS,
},
});
assert!(out.is_none());
}
#[test]
fn expired_partial_messages_are_evicted() {
let mut r = MessageReassembler::new();
let now = get_epoch_ms();
r.handle(Chunk {
chunk: [0, 2],
data: Bytes::from_static(b"x"),
meta: ChunkMeta {
id: Uuid::new_v4(),
ts_ms: now.saturating_sub(1000),
ttl_ms: 100,
},
});
r.handle(Chunk {
chunk: [0, 2],
data: Bytes::from_static(b"y"),
meta: ChunkMeta {
id: Uuid::new_v4(),
ts_ms: now,
ttl_ms: DEFAULT_TTL_MS,
},
});
assert_eq!(r.pending_count(), 1, "only the fresh partial remains");
}
#[test]
fn pending_messages_are_capped() {
let limits = small_limits();
let mut r = MessageReassembler::with_limits(limits);
for _ in 0..(limits.max_pending_messages + 10) {
r.handle(Chunk {
chunk: [0, 2],
data: Bytes::from_static(b"x"),
meta: ChunkMeta::default(), });
}
assert_eq!(r.pending_count(), limits.max_pending_messages);
}
#[test]
fn round_trip_reordered_with_duplicates() {
let data: Bytes = "abcdefghij".repeat(500).into();
let mut chunks = chunks_of(&data, 64);
chunks.reverse();
let dup = chunks[chunks.len() / 2].clone();
chunks.insert(1, dup.clone());
chunks.insert(chunks.len() / 3, dup);
let mut r = MessageReassembler::new();
let mut out = None;
for c in chunks {
out = r.handle(c).or(out);
}
assert_eq!(out.unwrap(), data);
assert_eq!(r.pending_count(), 0);
}
#[test]
fn total_over_slot_cap_is_rejected() {
let limits = small_limits();
let mut r = MessageReassembler::with_limits(limits);
let out = r.handle(Chunk {
chunk: [0, limits.max_chunks_per_message + 1],
data: Bytes::from_static(b"x"),
meta: ChunkMeta::default(),
});
assert!(out.is_none());
assert_eq!(r.pending_count(), 0);
assert_eq!(r.buffered_cost, 0);
}
#[test]
fn mismatched_ts_or_ttl_for_same_id_is_rejected() {
let mut r = MessageReassembler::new();
let id = Uuid::new_v4();
let now = get_epoch_ms();
assert!(r
.handle(Chunk {
chunk: [0, 2],
data: Bytes::from_static(b"a"),
meta: ChunkMeta {
id,
ts_ms: now,
ttl_ms: DEFAULT_TTL_MS
},
})
.is_none());
let out = r.handle(Chunk {
chunk: [1, 2],
data: Bytes::from_static(b"b"),
meta: ChunkMeta {
id,
ts_ms: now + 1,
ttl_ms: DEFAULT_TTL_MS,
},
});
assert!(out.is_none(), "must not complete by mixing transmissions");
let p = r.pending.get(&id).expect("first chunk still pending");
assert_eq!(p.slots.len(), 1, "the mismatched chunk left no trace");
}
#[test]
fn tombstone_expires_then_id_is_reusable() {
let mut r = MessageReassembler::new();
let id = Uuid::new_v4();
let base = 1_000_000u128;
let ttl = 100u64;
let one_chunk = |label: &'static [u8], ts_ms: u128, ttl_ms: u64| Chunk {
chunk: [0, 1],
data: Bytes::from_static(label),
meta: ChunkMeta { id, ts_ms, ttl_ms },
};
let first = r.handle_at(one_chunk(b"first", base, ttl), base);
assert_eq!(first.as_deref(), Some(&b"first"[..]));
assert!(r.completed_ids.contains(&id), "tombstoned after completion");
let dup = r.handle_at(one_chunk(b"first", base, ttl), base + (ttl as u128) / 2);
assert!(
dup.is_none(),
"post-completion retransmit suppressed within TTL"
);
assert!(
r.completed_ids.contains(&id),
"tombstone still live within TTL"
);
let later = base + ttl as u128 + 1;
let reused = r.handle_at(one_chunk(b"second", later, ttl), later);
assert_eq!(
reused.as_deref(),
Some(&b"second"[..]),
"id reusable after its tombstone expired via remove_expired_at"
);
}
}