rings_core/chunk.rs
1#![warn(missing_docs)]
2//! Message framing / chunking. A message larger than the connection's negotiated
3//! `max_message_size` is split into MTU-sized [`Chunk`]s on the sender and reassembled on the
4//! receiver.
5//!
6//! NOTE: this is **whole-message** buffering, not MSRP-style (RFC 4975) streaming. There is no
7//! mid-message interruption, interleaving, or incremental delivery — the receiver yields a payload
8//! only once *every* chunk has arrived (or drops it on TTL). The "split into ordered, id-tagged
9//! pieces and reassemble" idea is borrowed from MSRP chunking; the interruption semantics are not.
10//!
11//! Two halves, deliberately separated:
12//!
13//! - **Send** — [`ChunkList`] turns a [`Bytes`] into ordered [`Chunk`]s, where `chunk_size` comes
14//! from the connection's negotiated `max_message_size`. The sender uses [`ChunkList::stream`],
15//! which yields chunks lazily as zero-copy slices so one chunk is held in flight at a time;
16//! [`ChunkList::split`] (eager `Vec`) remains for tests.
17//! - **Receive** — [`MessageReassembler`] collects incoming [`Chunk`]s keyed by message id and
18//! yields the original payload once every position has arrived.
19//!
20//! The receiver is robust to the realities of a multi-hop / DHT overlay: out-of-order arrival,
21//! **duplicates / retransmits** (first write per position wins), and partial messages (evicted
22//! by TTL). It is also bounded against a hostile peer: per-chunk and per-message byte caps, a
23//! global buffered-cost ceiling (charging a per-slot overhead so tiny-chunk floods are bounded by
24//! count too), an id-count cap, and up-front rejection of already-expired chunks. No single id and
25//! no peer-supplied `total` can drive memory without limit. See [`MessageReassembler`].
26//!
27//! ```text
28//! send : Bytes ↦ [Chunk{ chunk=[i, n], data=dataᵢ, meta } | i ∈ 0..n] (Rust range, exclusive)
29//! receive : a message id is complete ⟺ received positions = 0..total (all n of them);
30//! then payload = concat(dataᵢ for i ∈ 0..total)
31//! ```
32
33use std::collections::btree_map::BTreeMap;
34use std::collections::HashMap;
35
36use bytes::Bytes;
37use rings_transport::core::transport::MAX_DATA_CHANNEL_MESSAGE_SIZE;
38use serde::Deserialize;
39use serde::Serialize;
40use uuid::Uuid;
41
42use crate::consts::DEFAULT_TTL_MS;
43use crate::consts::MAX_CHUNK_ENVELOPE_OVERHEAD;
44use crate::consts::MAX_TTL_MS;
45use crate::consts::MIN_CHUNK_DATA;
46use crate::consts::TRANSPORT_CUSTOM_OVERHEAD;
47use crate::consts::TRANSPORT_MAX_SIZE;
48use crate::consts::TS_OFFSET_TOLERANCE_MS;
49use crate::error::Error;
50use crate::error::Result;
51use crate::utils::get_epoch_ms;
52
53/// The limits a [`MessageReassembler`] enforces on incoming chunks, as an explicit value rather
54/// than module globals. This keeps the core admission rule independent of *where* the numbers come
55/// from: the shell supplies them (see [`ReassemblyLimits::production`]), the reassembler only
56/// enforces what it is given, and tests can use small limits instead of giant synthetic payloads.
57#[derive(Debug, Clone, Copy)]
58pub struct ReassemblyLimits {
59 /// Max number of distinct in-flight message ids (a cheap first-line cap; the byte budgets are
60 /// the real memory guard).
61 pub max_pending_messages: usize,
62 /// Max `data` bytes a single chunk may carry.
63 pub max_chunk_data_len: usize,
64 /// Max buffered data bytes for one in-flight message.
65 pub max_message_bytes: usize,
66 /// Max number of slots (chunks) one in-flight message may have — i.e. the largest `total` a
67 /// chunk may claim. Caps the slot/`BTreeMap` count of a single message so a hostile peer cannot
68 /// use one id with a huge `total` and tiny chunks to allocate millions of slots while staying
69 /// under [`max_message_bytes`](Self::max_message_bytes) (which only counts data bytes).
70 pub max_chunks_per_message: usize,
71 /// Max buffered cost (data bytes + per-slot overhead) summed across all in-flight messages.
72 pub max_total_buffered_cost: usize,
73 /// Bookkeeping charge per slot — a *conservative estimate* (not an exact measurement) of the
74 /// `BTreeMap` node plus `Bytes` header/refcount a slot costs, so a flood of *tiny* chunks is
75 /// bounded by slot count, not only by summed data bytes. Real per-slot heap use may differ;
76 /// this is deliberately generous so the budget over- rather than under-counts.
77 pub slot_overhead: usize,
78 /// Max number of recently-completed message ids remembered as tombstones, to suppress a
79 /// re-delivery if a message is fully retransmitted after it already completed (within its TTL
80 /// window). Bounds the tombstone memory. NOTE: past this many *concurrent* live tombstones the
81 /// oldest is dropped even if its TTL has not elapsed, so the "no post-completion redelivery"
82 /// guarantee holds only for the most recent `max_completed_ids` completions within a TTL window.
83 pub max_completed_ids: usize,
84}
85
86impl ReassemblyLimits {
87 /// The limits used in production, derived from the transport / message ceilings. This is the one
88 /// place that reaches for transport-specific constants; the reassembler itself does not.
89 pub fn production() -> Self {
90 Self {
91 max_pending_messages: 512,
92 // A chunk crosses the wire as one data-channel message, capped by SCTP.
93 max_chunk_data_len: MAX_DATA_CHANNEL_MESSAGE_SIZE,
94 // The sender refuses to send more than this, so a larger reassembled message is forged;
95 // this is what stops the "one id, huge `total`, stream unique positions" attack.
96 max_message_bytes: TRANSPORT_MAX_SIZE,
97 // The sender never produces chunks smaller than `MIN_CHUNK_DATA`, so a legitimate
98 // message needs at most this many; a larger `total` is forged.
99 max_chunks_per_message: TRANSPORT_MAX_SIZE / MIN_CHUNK_DATA + 1,
100 // Admits several concurrent maximum-size transfers while staying hard-bounded.
101 max_total_buffered_cost: TRANSPORT_MAX_SIZE * 4,
102 slot_overhead: 128,
103 max_completed_ids: 1024,
104 }
105 }
106
107 /// Smaller limits for constrained deployments.
108 ///
109 /// This profile preserves the protocol-level 60 MB send ceiling elsewhere,
110 /// but bounds one receiver's reassembly memory to a few MiB so weak devices
111 /// can reject oversized in-flight transfers before allocating for them.
112 pub fn constrained() -> Self {
113 const CONSTRAINED_MESSAGE_BYTES: usize = 4 * 1024 * 1024;
114 const CONSTRAINED_TOTAL_COST: usize = 8 * 1024 * 1024;
115
116 Self {
117 max_pending_messages: 64,
118 max_chunk_data_len: MAX_DATA_CHANNEL_MESSAGE_SIZE,
119 max_message_bytes: CONSTRAINED_MESSAGE_BYTES,
120 max_chunks_per_message: CONSTRAINED_MESSAGE_BYTES / MIN_CHUNK_DATA + 1,
121 max_total_buffered_cost: CONSTRAINED_TOTAL_COST,
122 slot_overhead: 128,
123 max_completed_ids: 256,
124 }
125 }
126
127 /// Clamp nonsensical values to safe minimums so a caller-supplied [`ReassemblyLimits`] cannot
128 /// disable an invariant: every cap is forced to at least `1` (a `0` cap would, depending on the
129 /// field, reject all traffic or — for `max_completed_ids` — silently void the tombstone
130 /// guarantee the docs advertise). Applied by [`MessageReassembler::with_limits`].
131 fn normalized(self) -> Self {
132 Self {
133 max_pending_messages: self.max_pending_messages.max(1),
134 max_chunk_data_len: self.max_chunk_data_len.max(1),
135 max_message_bytes: self.max_message_bytes.max(1),
136 max_chunks_per_message: self.max_chunks_per_message.max(1),
137 max_total_buffered_cost: self.max_total_buffered_cost.max(1),
138 slot_overhead: self.slot_overhead,
139 max_completed_ids: self.max_completed_ids.max(1),
140 }
141 }
142}
143
144impl Default for ReassemblyLimits {
145 fn default() -> Self {
146 Self::production()
147 }
148}
149
150/// One chunk of a chunked message, as it travels on the wire.
151#[derive(Debug, Clone, Deserialize, Serialize)]
152pub struct Chunk {
153 /// `[position, total]` — this chunk's index and the number of chunks in the message.
154 pub chunk: [usize; 2],
155 /// chunk payload bytes
156 pub data: Bytes,
157 /// meta data of chunk
158 pub meta: ChunkMeta,
159}
160
161impl Chunk {
162 /// serialize chunk to bytes
163 pub fn to_bincode(&self) -> Result<Bytes> {
164 bincode::serialize(self)
165 .map(Bytes::from)
166 .map_err(Error::BincodeSerialize)
167 }
168
169 /// deserialize bytes to chunk
170 pub fn from_bincode(data: &[u8]) -> Result<Self> {
171 bincode::deserialize(data).map_err(Error::BincodeDeserialize)
172 }
173}
174
175/// Meta data of a chunk
176#[derive(Debug, Copy, Clone, Deserialize, Serialize)]
177pub struct ChunkMeta {
178 /// uuid of msg
179 pub id: uuid::Uuid,
180 /// Created time
181 pub ts_ms: u128,
182 /// Time to live
183 pub ttl_ms: u64,
184}
185
186impl Default for ChunkMeta {
187 fn default() -> Self {
188 Self {
189 id: uuid::Uuid::new_v4(),
190 ts_ms: get_epoch_ms(),
191 ttl_ms: DEFAULT_TTL_MS,
192 }
193 }
194}
195
196/// Sender side: an ordered list of [`Chunk`]s for one message. Build it from the payload with
197/// [`ChunkList::split`], passing the per-message data size to cut at (the connection's negotiated
198/// `max_message_size` minus the envelope reserve), then iterate (or convert to `Vec<Chunk>`) to put
199/// each chunk on the wire. The cut size is a runtime argument rather than a type parameter because
200/// it is decided per connection from the negotiated limit. Reassembly is the receiver's job — see
201/// [`MessageReassembler`].
202#[derive(Debug, Clone, Default, Deserialize, Serialize)]
203pub struct ChunkList(Vec<Chunk>);
204
205impl ChunkList {
206 /// Eagerly split `bytes` into chunks of at most `chunk_size` data bytes each, tagged
207 /// `[i, total]`. A **test/helper** constructor (the production send path uses
208 /// [`stream`](Self::stream), and [`WireReserves::plan`] never yields an unusable `chunk_size` —
209 /// it returns `None` instead). `chunk_size` is clamped to ≥ 1 only as a defensive guard against
210 /// a caller passing `0`; it is not a sanctioned way to produce 1-byte chunks on the wire.
211 pub fn split(bytes: &Bytes, chunk_size: usize) -> Self {
212 let chunk_size = chunk_size.max(1);
213 let chunks: Vec<Bytes> = bytes
214 .chunks(chunk_size)
215 .map(|c| c.to_vec().into())
216 .collect();
217 let chunks_len: usize = chunks.len();
218 let meta = ChunkMeta::default();
219 Self(
220 chunks
221 .into_iter()
222 .enumerate()
223 .map(|(i, data)| Chunk {
224 meta,
225 chunk: [i, chunks_len],
226 data,
227 })
228 .collect::<Vec<Chunk>>(),
229 )
230 }
231
232 /// Stream `bytes` into chunks of at most `chunk_size` data bytes each **without materializing
233 /// the whole list**: each chunk's `data` is a zero-copy [`Bytes::slice`] of the input, and the
234 /// chunks are yielded lazily, so a sender can frame and flush one chunk at a time with bounded
235 /// memory (rather than allocating every chunk up front). All chunks share one `[i, total]`
236 /// numbering and one [`ChunkMeta`]. `chunk_size` is clamped to ≥ 1 so a degenerate value still
237 /// terminates; empty input yields **no** chunks, agreeing with [`split`](Self::split).
238 pub fn stream(bytes: Bytes, chunk_size: usize) -> impl Iterator<Item = Chunk> {
239 let chunk_size = chunk_size.max(1);
240 let total = bytes.len().div_ceil(chunk_size);
241 let meta = ChunkMeta::default();
242 (0..total).map(move |i| {
243 let start = i * chunk_size;
244 let end = start.saturating_add(chunk_size).min(bytes.len());
245 Chunk {
246 meta,
247 chunk: [i, total],
248 data: bytes.slice(start..end),
249 }
250 })
251 }
252
253 /// Clone out the chunks.
254 pub fn to_vec(&self) -> Vec<Chunk> {
255 self.0.clone()
256 }
257
258 /// Borrow the chunks.
259 pub fn as_vec(&self) -> &Vec<Chunk> {
260 &self.0
261 }
262}
263
264impl IntoIterator for &ChunkList {
265 type Item = Chunk;
266 type IntoIter = std::vec::IntoIter<Chunk>;
267
268 fn into_iter(self) -> Self::IntoIter {
269 self.to_vec().into_iter()
270 }
271}
272
273impl IntoIterator for ChunkList {
274 type Item = Chunk;
275 type IntoIter = std::vec::IntoIter<Chunk>;
276
277 fn into_iter(self) -> Self::IntoIter {
278 self.0.into_iter()
279 }
280}
281
282impl From<ChunkList> for Vec<Chunk> {
283 fn from(l: ChunkList) -> Self {
284 l.0
285 }
286}
287
288impl From<Vec<Chunk>> for ChunkList {
289 fn from(data: Vec<Chunk>) -> Self {
290 Self(data)
291 }
292}
293
294/// How one payload should be framed for a size-limited connection: sent whole, or split.
295///
296/// This is the *decision* only — a value, with no I/O — so the sender's effectful path
297/// (`do_send_payload`) is a thin shell that matches on it. Separating the rule from the act keeps
298/// the rule exhaustively testable in isolation (functional core / imperative shell).
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub enum Framing {
301 /// The payload is within the connection's limit; send it as a single message, unchanged.
302 Whole,
303 /// The payload exceeds the limit; split it into [`Chunk`]s of at most `chunk_size` data bytes
304 /// each (via [`ChunkList::split`]), each then re-wrapped in its own envelope.
305 Chunked {
306 /// Maximum data bytes per chunk.
307 chunk_size: usize,
308 },
309}
310
311/// The bytes the transport adds around a payload on the wire, per framing path. Bundled as a named
312/// value so the framing rule reads `reserves.plan(len, limit)` instead of a row of positional
313/// `usize`s, and so the production reserves live in exactly one place ([`WireReserves::PRODUCTION`]).
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub struct WireReserves {
316 /// Bytes added around a *whole* payload — the outer `TransportMessage::Custom` frame.
317 pub whole: usize,
318 /// Bytes added around *each chunk's* data — its `MessagePayload` envelope **and** the outer
319 /// `TransportMessage::Custom` frame.
320 pub chunk: usize,
321 /// Smallest per-chunk data payload worth producing; a limit that cannot fit `chunk +
322 /// min_chunk_data` is rejected rather than fragmented into near-empty chunks.
323 pub min_chunk_data: usize,
324}
325
326impl WireReserves {
327 /// The reserves used in production, derived from the transport/message ceilings.
328 pub const PRODUCTION: Self = Self {
329 whole: TRANSPORT_CUSTOM_OVERHEAD,
330 chunk: MAX_CHUNK_ENVELOPE_OVERHEAD + TRANSPORT_CUSTOM_OVERHEAD,
331 min_chunk_data: MIN_CHUNK_DATA,
332 };
333
334 /// Frame a `payload_len`-byte payload for a connection whose negotiated per-message limit is
335 /// `max_message_size`. The decision is taken against the *wire* bytes (payload + reserves), not
336 /// the bare payload, and is a pure total function:
337 ///
338 /// ```text
339 /// plan : (len, limit) ↦ Whole if len + whole ≤ limit
340 /// ↦ Chunked(limit − chunk) if limit ≥ chunk + min_chunk_data
341 /// ↦ ∅ otherwise
342 /// ```
343 ///
344 /// `∅` (`None`) means the peer's limit is too small for even one useful chunk — a failure the
345 /// caller surfaces, never a flood of 1-byte chunks. When `Chunked { chunk_size }` is returned,
346 /// `min_chunk_data ≤ chunk_size` and `chunk_size + chunk ≤ limit`, so every wrapped chunk fits
347 /// and a payload yields at most `⌈len / min_chunk_data⌉` chunks. Every sum is `checked`, so the
348 /// function is total over all `usize` inputs (no overflow/underflow).
349 pub fn plan(&self, payload_len: usize, max_message_size: usize) -> Option<Framing> {
350 let whole_fits = payload_len
351 .checked_add(self.whole)
352 .is_some_and(|wire| wire <= max_message_size);
353 if whole_fits {
354 return Some(Framing::Whole);
355 }
356 let min_viable = self.chunk.checked_add(self.min_chunk_data)?;
357 (max_message_size >= min_viable).then(|| Framing::Chunked {
358 chunk_size: max_message_size - self.chunk,
359 })
360 }
361}
362
363/// One message being reassembled: the chunks seen so far, keyed by position.
364struct Pending {
365 /// total number of chunks the message claims (from `chunk[1]`).
366 total: usize,
367 /// received positions → bytes. A `BTreeMap` dedups by position (first write wins) and keeps
368 /// the data ordered, so assembly is a single in-order concat.
369 slots: BTreeMap<usize, Bytes>,
370 /// running sum of buffered data bytes, so the per-message cap is O(1) to check.
371 data_bytes: usize,
372 /// creation time / ttl of the first chunk seen, used for TTL eviction.
373 ts_ms: u128,
374 ttl_ms: u64,
375}
376
377impl Pending {
378 fn new(total: usize, ts_ms: u128, ttl_ms: u64) -> Self {
379 Self {
380 total,
381 slots: BTreeMap::new(),
382 data_bytes: 0,
383 ts_ms,
384 ttl_ms,
385 }
386 }
387
388 /// Complete iff every position has arrived. Each inserted position is unique (map key) and in
389 /// `0..total`, so `slots.len() == total` ⟺ the present set is exactly `{0..total-1}`.
390 fn is_complete(&self) -> bool {
391 self.slots.len() == self.total
392 }
393
394 /// Buffered cost charged to the global budget: data bytes plus `slot_overhead` per slot.
395 /// Saturating arithmetic, so adversarial limit values can never overflow/wrap the budget —
396 /// an overflowing cost simply saturates to `usize::MAX` and is rejected as over-budget.
397 fn cost(&self, slot_overhead: usize) -> usize {
398 self.slots
399 .len()
400 .saturating_mul(slot_overhead)
401 .saturating_add(self.data_bytes)
402 }
403
404 fn assemble(self) -> Bytes {
405 self.slots.into_values().flatten().collect()
406 }
407}
408
409/// Receiver side: **whole-message** reassembly for reliable data-channel `MessagePayload`
410/// fragments. Buffers a message's chunks keyed by id and yields the complete [`Bytes`] once every
411/// position has arrived (then forgets it).
412///
413/// Correct under duplicates / retransmits (first write per position wins *during* assembly,
414/// out-of-order arrival sorted), partial delivery (TTL eviction), and a message **fully
415/// retransmitted after it already completed**: a completed id is kept as a tombstone until it would
416/// expire, so a late re-send within the TTL window is dropped rather than re-assembled and delivered
417/// twice.
418///
419/// **Bounded against a hostile peer** by the [`ReassemblyLimits`] it is built with: every accepted
420/// chunk is validated and charged to a budget, so reassembly memory cannot grow without limit no
421/// matter how the load is shaped — per-chunk data, per-message data, a global buffered-cost ceiling
422/// (charging a per-slot overhead so a tiny-chunk flood is bounded by slot count too), the id count,
423/// and the completed-id tombstone set are all capped, and an already-expired chunk is rejected
424/// before it can be delivered or buffered.
425pub struct MessageReassembler {
426 pending: HashMap<Uuid, Pending>,
427 /// Sum of `Pending::cost(..)` over `pending`, maintained incrementally for an O(1) global cap.
428 buffered_cost: usize,
429 /// Tombstones for ids that have already been delivered, each paired with its expiry
430 /// (`ts_ms + ttl_ms`). A chunk for one of these is dropped, so a post-completion retransmit of a
431 /// whole message is not re-assembled and delivered again. `VecDeque` for FIFO/TTL eviction, the
432 /// `HashSet` for an O(1) membership check; the two are kept in lockstep.
433 completed: std::collections::VecDeque<(Uuid, u128)>,
434 completed_ids: std::collections::HashSet<Uuid>,
435 /// The bounds enforced on every incoming chunk.
436 limits: ReassemblyLimits,
437}
438
439impl Default for MessageReassembler {
440 fn default() -> Self {
441 Self::with_limits(ReassemblyLimits::production())
442 }
443}
444
445impl MessageReassembler {
446 /// Empty reassembler with [`ReassemblyLimits::production`] bounds.
447 pub fn new() -> Self {
448 Self::default()
449 }
450
451 /// Empty reassembler enforcing the given `limits`. Tests use this with small limits to exercise
452 /// the admission rule without giant synthetic payloads.
453 pub fn with_limits(limits: ReassemblyLimits) -> Self {
454 Self {
455 pending: HashMap::new(),
456 buffered_cost: 0,
457 completed: std::collections::VecDeque::new(),
458 completed_ids: std::collections::HashSet::new(),
459 // Clamp nonsensical caps so a caller cannot disable an invariant (e.g. a `0` cap).
460 limits: limits.normalized(),
461 }
462 }
463
464 /// Record `id` as delivered so a later full retransmit (within the TTL window) is suppressed,
465 /// dropping the oldest tombstone if the cap is reached. `expiry` is the message's `ts_ms + ttl_ms`
466 /// — after it, a retransmit is rejected by the expiry check anyway, so the tombstone can go.
467 fn mark_completed(&mut self, id: Uuid, expiry: u128) {
468 if self.completed_ids.insert(id) {
469 self.completed.push_back((id, expiry));
470 }
471 while self.completed.len() > self.limits.max_completed_ids {
472 if let Some((old, _)) = self.completed.pop_front() {
473 self.completed_ids.remove(&old);
474 }
475 }
476 }
477
478 /// Number of messages currently being reassembled (incomplete).
479 pub fn pending_count(&self) -> usize {
480 self.pending.len()
481 }
482
483 /// Drop messages whose TTL has elapsed, returning their cost to the budget, and evict completed-id
484 /// tombstones that have likewise expired (a retransmit past its expiry is rejected anyway).
485 pub fn remove_expired(&mut self) {
486 self.remove_expired_at(get_epoch_ms());
487 }
488
489 /// [`remove_expired`](Self::remove_expired) with the clock injected (tests pass a controlled
490 /// `now` to drive the real eviction logic).
491 fn remove_expired_at(&mut self, now: u128) {
492 let buffered_cost = &mut self.buffered_cost;
493 let slot_overhead = self.limits.slot_overhead;
494 self.pending.retain(|_, p| {
495 let alive = p.ts_ms.saturating_add(p.ttl_ms as u128) > now;
496 if !alive {
497 *buffered_cost = buffered_cost.saturating_sub(p.cost(slot_overhead));
498 }
499 alive
500 });
501 // Evict *every* expired tombstone, not just a leading run: completion order need not equal
502 // expiry order, so a `retain` is correct where front-popping would leave an out-of-order
503 // early-expiry entry behind a still-live front.
504 let completed_ids = &mut self.completed_ids;
505 self.completed.retain(|&(id, expiry)| {
506 let alive = expiry > now;
507 if !alive {
508 completed_ids.remove(&id);
509 }
510 alive
511 });
512 }
513
514 /// Forget a message (e.g. after it has been delivered), returning its cost to the budget.
515 pub fn remove(&mut self, id: Uuid) {
516 if let Some(p) = self.pending.remove(&id) {
517 self.buffered_cost -= p.cost(self.limits.slot_overhead);
518 }
519 }
520
521 /// Accept one chunk. Returns the fully reassembled payload when this chunk completes its
522 /// message (which is then forgotten), otherwise `None`.
523 ///
524 /// Imperative shell over a functional core: expire stale state, ask the pure `classify` for an
525 /// admission verdict, and apply it. The only mutation of the buffer is in `admit`; a rejected
526 /// chunk leaves no trace and is logged once with its typed `Rejected` reason.
527 pub fn handle(&mut self, chunk: Chunk) -> Option<Bytes> {
528 self.handle_at(chunk, get_epoch_ms())
529 }
530
531 /// [`handle`](Self::handle) with the clock injected, so tests drive expiry/admission against a
532 /// controlled `now` through the real production path instead of poking internal state.
533 fn handle_at(&mut self, chunk: Chunk, now: u128) -> Option<Bytes> {
534 // Reclaim expired pending entries and tombstones FIRST — before classify reads them — so
535 // invalid traffic still frees memory and an expired tombstone cannot suppress a fresh
536 // message that reuses its id after the TTL window.
537 self.remove_expired_at(now);
538 match self.classify(&chunk, now) {
539 Ok(cost) => self.admit(chunk, cost),
540 Err(reason) => {
541 tracing::debug!(?reason, id = ?chunk.meta.id, "reassembler dropped chunk");
542 None
543 }
544 }
545 }
546
547 /// The pure admission rule: `(state, chunk, now) ↦ Ok(cost) | Err(reason)`. Borrows `&self`,
548 /// mutates nothing, does no I/O. On success it returns the buffered cost [`admit`] must charge;
549 /// on failure a typed [`Rejected`] reason. Validating the existing pending entry here, before
550 /// any mutation, is what keeps a rejected chunk side-effect-free and the accounting exact.
551 ///
552 /// [`admit`]: Self::admit
553 fn classify(&self, chunk: &Chunk, now: u128) -> std::result::Result<usize, Rejected> {
554 let meta = &chunk.meta;
555 if meta.ttl_ms > MAX_TTL_MS {
556 return Err(Rejected::TtlTooLarge);
557 }
558 // `saturating_sub` avoids the `u128` underflow a forged `ts_ms < TS_OFFSET_TOLERANCE_MS`
559 // would cause; `saturating_add` avoids overflow on a forged ttl.
560 if meta.ts_ms.saturating_sub(TS_OFFSET_TOLERANCE_MS) > now {
561 return Err(Rejected::FutureTimestamp);
562 }
563 // Reject an already-expired chunk up front, so a stale `total == 1` is never delivered.
564 if meta.ts_ms.saturating_add(meta.ttl_ms as u128) <= now {
565 return Err(Rejected::Expired);
566 }
567
568 let [position, total] = chunk.chunk;
569 // A real message has ≥ 1 chunk and every position in `0..total`.
570 if total == 0 || position >= total {
571 return Err(Rejected::Malformed);
572 }
573 // Cap the slot count: a forged `total` is refused before it can allocate a huge `BTreeMap`.
574 if total > self.limits.max_chunks_per_message {
575 return Err(Rejected::TooManyChunks);
576 }
577 // One chunk cannot exceed one data-channel message.
578 if chunk.data.len() > self.limits.max_chunk_data_len {
579 return Err(Rejected::ChunkTooLarge);
580 }
581 // Already delivered: drop a post-completion retransmit (expired tombstones were swept).
582 if self.completed_ids.contains(&meta.id) {
583 return Err(Rejected::AlreadyCompleted);
584 }
585
586 // Bytes already buffered for this id (`0` for a new message). Used for the per-message cap
587 // below, which must hold for the *first* chunk too — not only once a pending entry exists —
588 // or a caller-supplied `max_chunk_data_len > max_message_bytes` could admit an oversized
589 // lone chunk.
590 let buffered_for_id = match self.pending.get(&meta.id) {
591 // A new id: admit only if there is room for another concurrent message.
592 None if self.pending.len() >= self.limits.max_pending_messages => {
593 return Err(Rejected::PendingFull);
594 }
595 None => 0,
596 Some(p) => {
597 // A chunk of an in-flight message must agree on its shape and provenance.
598 if p.total != total {
599 return Err(Rejected::TotalMismatch);
600 }
601 // Chunks of one message share id+ts+ttl; a same-id chunk from a different
602 // transmission must not be merged in (it would skew expiry/tombstone behaviour).
603 if p.ts_ms != meta.ts_ms || p.ttl_ms != meta.ttl_ms {
604 return Err(Rejected::MetadataMismatch);
605 }
606 // First write per position wins; a duplicate position is a no-op, not an error.
607 if p.slots.contains_key(&position) {
608 return Err(Rejected::DuplicatePosition);
609 }
610 p.data_bytes
611 }
612 };
613 // Per-message data cap, enforced uniformly across the first and subsequent chunks.
614 if buffered_for_id.saturating_add(chunk.data.len()) > self.limits.max_message_bytes {
615 return Err(Rejected::PerMessageBytes);
616 }
617
618 // Cost charged to the global budget: this slot's data + its fixed overhead. Saturating, so a
619 // pathological `slot_overhead` cannot wrap the budget.
620 let cost = chunk.data.len().saturating_add(self.limits.slot_overhead);
621 if self.buffered_cost.saturating_add(cost) > self.limits.max_total_buffered_cost {
622 return Err(Rejected::GlobalBudget);
623 }
624 Ok(cost)
625 }
626
627 /// The sole buffer mutation: insert a [`classify`]-approved `chunk` (charging `cost`), and if it
628 /// completes its message, take it out, refund its budget, tombstone the id, and return the
629 /// reassembled payload.
630 ///
631 /// [`classify`]: Self::classify
632 fn admit(&mut self, chunk: Chunk, cost: usize) -> Option<Bytes> {
633 let id = chunk.meta.id;
634 let [position, _total] = chunk.chunk;
635 let pending = self
636 .pending
637 .entry(id)
638 .or_insert_with(|| Pending::new(chunk.chunk[1], chunk.meta.ts_ms, chunk.meta.ttl_ms));
639 pending.data_bytes = pending.data_bytes.saturating_add(chunk.data.len());
640 pending.slots.insert(position, chunk.data);
641 self.buffered_cost = self.buffered_cost.saturating_add(cost);
642
643 if !pending.is_complete() {
644 return None;
645 }
646 let done = self.pending.remove(&id)?;
647 self.buffered_cost = self
648 .buffered_cost
649 .saturating_sub(done.cost(self.limits.slot_overhead));
650 // Tombstone the id until it would expire, so a later full retransmit is suppressed.
651 self.mark_completed(id, done.ts_ms.saturating_add(done.ttl_ms as u128));
652 Some(done.assemble())
653 }
654}
655
656/// Why a chunk was not admitted — a *value*, so [`MessageReassembler::classify`] stays a pure total
657/// function the shell can test and log uniformly, rather than scattering ad-hoc log strings.
658#[derive(Debug, Clone, Copy, PartialEq, Eq)]
659enum Rejected {
660 /// `ttl_ms` exceeds [`MAX_TTL_MS`].
661 TtlTooLarge,
662 /// Stamped further in the future than [`TS_OFFSET_TOLERANCE_MS`] allows.
663 FutureTimestamp,
664 /// Already past its `ts_ms + ttl_ms` expiry.
665 Expired,
666 /// `total == 0` or `position >= total`.
667 Malformed,
668 /// `total` exceeds [`ReassemblyLimits::max_chunks_per_message`].
669 TooManyChunks,
670 /// `data` exceeds [`ReassemblyLimits::max_chunk_data_len`].
671 ChunkTooLarge,
672 /// The message id is tombstoned (already delivered).
673 AlreadyCompleted,
674 /// A new id, but [`ReassemblyLimits::max_pending_messages`] is already reached.
675 PendingFull,
676 /// `total` disagrees with the in-flight message's.
677 TotalMismatch,
678 /// `ts_ms`/`ttl_ms` disagree with the in-flight message's (a different transmission).
679 MetadataMismatch,
680 /// This position is already buffered (a duplicate/retransmit).
681 DuplicatePosition,
682 /// Admitting would exceed the message's [`ReassemblyLimits::max_message_bytes`].
683 PerMessageBytes,
684 /// Admitting would exceed the global [`ReassemblyLimits::max_total_buffered_cost`].
685 GlobalBudget,
686}
687
688#[cfg(test)]
689mod test {
690 use super::*;
691
692 fn chunks_of(data: &Bytes, mtu: usize) -> Vec<Chunk> {
693 ChunkList::split(data, mtu).into()
694 }
695
696 /// Tiny limits so the admission rule can be exercised without giant synthetic payloads.
697 fn small_limits() -> ReassemblyLimits {
698 ReassemblyLimits {
699 max_pending_messages: 4,
700 max_chunk_data_len: 16,
701 max_message_bytes: 100,
702 max_chunks_per_message: 64,
703 max_total_buffered_cost: 256,
704 slot_overhead: 8,
705 max_completed_ids: 8,
706 }
707 }
708
709 #[test]
710 fn constrained_reassembly_limits_are_smaller_than_production() {
711 let production = ReassemblyLimits::production();
712 let constrained = ReassemblyLimits::constrained();
713
714 assert!(constrained.max_pending_messages < production.max_pending_messages);
715 assert!(constrained.max_message_bytes < production.max_message_bytes);
716 assert!(constrained.max_chunks_per_message < production.max_chunks_per_message);
717 assert!(constrained.max_total_buffered_cost < production.max_total_buffered_cost);
718 assert!(constrained.max_completed_ids < production.max_completed_ids);
719 assert_eq!(
720 constrained.max_chunk_data_len,
721 production.max_chunk_data_len
722 );
723 }
724
725 #[test]
726 fn test_data_chunks() {
727 let data = "helloworld".repeat(2).into();
728 let ret: Vec<Chunk> = ChunkList::split(&data, 32).into();
729 assert_eq!(ret.len(), 1);
730 assert_eq!(ret[ret.len() - 1].chunk, [0, 1]);
731
732 let data = "helloworld".repeat(1024).into();
733 let ret: Vec<Chunk> = ChunkList::split(&data, 32).into();
734 assert_eq!(ret.len(), 10 * 1024 / 32);
735 assert_eq!(ret[ret.len() - 1].chunk, [319, 320]);
736 }
737
738 #[test]
739 fn split_empty_yields_no_chunks() {
740 assert!(ChunkList::split(&Bytes::new(), 32).to_vec().is_empty());
741 }
742
743 #[test]
744 fn split_exact_multiple_all_full() {
745 let data: Bytes = vec![0u8; 64].into();
746 let chunks = ChunkList::split(&data, 32).to_vec();
747 assert_eq!(chunks.len(), 2);
748 assert!(chunks.iter().all(|c| c.data.len() == 32));
749 assert_eq!(chunks[0].chunk, [0, 2]);
750 assert_eq!(chunks[1].chunk, [1, 2]);
751 }
752
753 #[test]
754 fn split_non_multiple_last_is_remainder() {
755 let data: Bytes = vec![0u8; 70].into();
756 let chunks = ChunkList::split(&data, 32).to_vec();
757 assert_eq!(chunks.len(), 3);
758 assert_eq!(chunks[0].data.len(), 32);
759 assert_eq!(chunks[1].data.len(), 32);
760 assert_eq!(chunks[2].data.len(), 6);
761 }
762
763 #[test]
764 fn split_larger_than_data_is_single_chunk() {
765 let data: Bytes = vec![0u8; 10].into();
766 let chunks = ChunkList::split(&data, 1024).to_vec();
767 assert_eq!(chunks.len(), 1);
768 assert_eq!(chunks[0].chunk, [0, 1]);
769 }
770
771 #[test]
772 fn split_zero_size_is_clamped_to_one() {
773 let data: Bytes = vec![0u8; 4].into();
774 let chunks = ChunkList::split(&data, 0).to_vec();
775 assert_eq!(chunks.len(), 4);
776 assert!(chunks.iter().all(|c| c.data.len() == 1));
777 }
778
779 #[test]
780 fn split_chunks_share_one_message_id() {
781 let data: Bytes = vec![0u8; 100].into();
782 let chunks = ChunkList::split(&data, 32).to_vec();
783 let id = chunks[0].meta.id;
784 assert!(chunks.iter().all(|c| c.meta.id == id));
785 }
786
787 /// Cutting at any size and feeding the pieces back through the reassembler (in order) yields the
788 /// original bytes — across exact multiples, remainders, single-chunk, and one-byte cuts.
789 #[test]
790 fn split_then_reassemble_round_trips() {
791 for (len, size) in [
792 (1usize, 7usize),
793 (7, 7),
794 (8, 7),
795 (100, 7),
796 (1000, 64),
797 (5, 1),
798 ] {
799 let data: Bytes = (0..len).map(|i| i as u8).collect::<Vec<u8>>().into();
800 let mut r = MessageReassembler::new();
801 let mut out = None;
802 for c in ChunkList::split(&data, size) {
803 out = r.handle(c).or(out);
804 }
805 assert_eq!(out.unwrap(), data, "len={len} size={size}");
806 }
807 }
808
809 /// Test reserves with readable, distinct values (`whole < chunk`) so the two paths are easy to
810 /// tell apart in the assertions below.
811 fn reserves(whole: usize, chunk: usize, min_chunk_data: usize) -> WireReserves {
812 WireReserves {
813 whole,
814 chunk,
815 min_chunk_data,
816 }
817 }
818
819 #[test]
820 fn plan_whole_includes_whole_overhead() {
821 let r = reserves(10, 20, 1);
822 // Whole fits while payload + whole ≤ limit, up to and including the boundary.
823 assert_eq!(r.plan(0, 100), Some(Framing::Whole));
824 assert_eq!(r.plan(90, 100), Some(Framing::Whole));
825 // One past the boundary must chunk.
826 assert_eq!(r.plan(91, 100), Some(Framing::Chunked { chunk_size: 80 }));
827 }
828
829 /// The chunk size reserves the chunk overhead, so `chunk_size + chunk ≤ limit`: a wrapped chunk
830 /// can never exceed the negotiated limit.
831 #[test]
832 fn plan_chunk_size_reserves_overhead() {
833 let (limit, chunk_overhead) = (65536usize, 4096usize);
834 let Some(Framing::Chunked { chunk_size }) =
835 reserves(16, chunk_overhead, 16).plan(limit * 2, limit)
836 else {
837 panic!("expected chunked");
838 };
839 assert_eq!(chunk_size, limit - chunk_overhead);
840 assert!(chunk_size + chunk_overhead <= limit);
841 }
842
843 #[test]
844 fn plan_none_when_chunk_too_small() {
845 // A limit that cannot fit `chunk + min_chunk_data` is rejected outright, not split tiny.
846 assert_eq!(reserves(4, 10, 1).plan(100, 5), None); // below the overhead
847 assert_eq!(reserves(4, 10, 1).plan(100, 10), None); // == overhead, 0 data bytes
848 // limit just clears chunk + min: the smallest *allowed* cut.
849 assert_eq!(
850 reserves(4, 10, 1).plan(100, 11),
851 Some(Framing::Chunked { chunk_size: 1 })
852 );
853 // a realistic floor: min_chunk_data = 8 needs limit ≥ chunk + 8.
854 assert_eq!(reserves(4, 10, 8).plan(100, 17), None); // 17 < 10 + 8
855 assert_eq!(
856 reserves(4, 10, 8).plan(100, 18),
857 Some(Framing::Chunked { chunk_size: 8 })
858 );
859 }
860
861 #[test]
862 fn plan_is_total_on_overflow() {
863 // `payload_len + whole` overflows usize; must not panic, and (not a whole fit) falls through
864 // to the chunked decision rather than wrapping around.
865 assert_eq!(
866 reserves(10, 20, 1).plan(usize::MAX, 100),
867 Some(Framing::Chunked { chunk_size: 80 })
868 );
869 // overflow with a too-small limit still yields None, not a panic.
870 assert_eq!(reserves(10, 20, 1).plan(usize::MAX, 10), None);
871 }
872
873 #[test]
874 fn reassembles_in_order() {
875 let data: Bytes = "helloworld".repeat(1024).into();
876 let mut r = MessageReassembler::new();
877 let chunks = chunks_of(&data, 32);
878 let mut out = None;
879 for c in chunks {
880 out = r.handle(c).or(out);
881 }
882 assert_eq!(out.unwrap(), data);
883 assert_eq!(r.pending_count(), 0, "completed message is forgotten");
884 }
885
886 #[test]
887 fn reassembles_out_of_order() {
888 let data: Bytes = "helloworld".repeat(64).into();
889 let mut chunks = chunks_of(&data, 32);
890 chunks.reverse();
891 let mut r = MessageReassembler::new();
892 let mut out = None;
893 for c in chunks {
894 out = r.handle(c).or(out);
895 }
896 assert_eq!(out.unwrap(), data);
897 }
898
899 #[test]
900 fn full_retransmit_after_completion_is_not_redelivered() {
901 // A message that completes, then is *fully* retransmitted within its TTL window, must not be
902 // delivered a second time — the completed id is tombstoned.
903 let data: Bytes = "helloworld".repeat(64).into();
904 let chunks = chunks_of(&data, 32);
905 assert!(chunks.len() > 1, "need a multi-chunk message for this test");
906
907 let mut r = MessageReassembler::new();
908 let mut first = None;
909 for c in chunks.clone() {
910 first = r.handle(c).or(first);
911 }
912 assert_eq!(first.unwrap(), data, "first assembly delivers once");
913 assert_eq!(r.pending_count(), 0);
914
915 // Replay every chunk of the same message; none should re-open a pending entry or re-deliver.
916 for c in chunks {
917 assert!(
918 r.handle(c).is_none(),
919 "a retransmit of an already-completed message must be dropped"
920 );
921 }
922 assert_eq!(
923 r.pending_count(),
924 0,
925 "no pending re-opened by the retransmit"
926 );
927 }
928
929 #[test]
930 fn duplicate_chunk_does_not_break_reassembly() {
931 // Regression: arrival order [0, 1, 0] used to dedup-before-sort and never complete.
932 let data: Bytes = "helloworld".repeat(8).into(); // > 32 bytes => 3 chunks
933 let chunks = chunks_of(&data, 32);
934 assert!(chunks.len() >= 2);
935 let mut r = MessageReassembler::new();
936
937 // Feed every chunk, re-feeding chunk 0 in the middle as a duplicate.
938 assert!(r.handle(chunks[0].clone()).is_none());
939 for c in &chunks[1..] {
940 let _ = r.handle(chunks[0].clone()); // duplicate of position 0, repeatedly
941 if let Some(out) = r.handle(c.clone()) {
942 assert_eq!(out, data);
943 assert_eq!(r.pending_count(), 0);
944 return;
945 }
946 }
947 panic!("message never completed despite all chunks arriving");
948 }
949
950 #[test]
951 fn interleaved_messages_are_isolated() {
952 let d1: Bytes = "hello".repeat(64).into();
953 let d2: Bytes = "world".repeat(64).into();
954 let c1 = chunks_of(&d1, 32);
955 let c2 = chunks_of(&d2, 32);
956 let mut r = MessageReassembler::new();
957
958 // interleave the two messages
959 let (mut o1, mut o2) = (None, None);
960 for pair in c1.iter().zip(c2.iter()) {
961 o1 = r.handle(pair.0.clone()).or(o1);
962 o2 = r.handle(pair.1.clone()).or(o2);
963 }
964 // drain any tail (lengths may differ)
965 for c in c1.iter().chain(c2.iter()) {
966 let out = r.handle(c.clone());
967 o1 = out.clone().filter(|b| *b == d1).or(o1);
968 o2 = out.filter(|b| *b == d2).or(o2);
969 }
970 assert_eq!(o1.unwrap(), d1);
971 assert_eq!(o2.unwrap(), d2);
972 }
973
974 #[test]
975 fn incomplete_message_stays_pending() {
976 let data: Bytes = "helloworld".repeat(64).into();
977 let chunks = chunks_of(&data, 32);
978 let mut r = MessageReassembler::new();
979 for c in &chunks[..chunks.len() - 1] {
980 assert!(r.handle(c.clone()).is_none());
981 }
982 assert_eq!(r.pending_count(), 1);
983 let out = r.handle(chunks.last().unwrap().clone());
984 assert_eq!(out.unwrap(), data);
985 }
986
987 #[test]
988 fn malformed_chunks_are_dropped() {
989 let mut r = MessageReassembler::new();
990 // total == 0
991 assert!(r
992 .handle(Chunk {
993 chunk: [0, 0],
994 data: Bytes::from_static(b"x"),
995 meta: ChunkMeta::default(),
996 })
997 .is_none());
998 // position >= total
999 assert!(r
1000 .handle(Chunk {
1001 chunk: [5, 3],
1002 data: Bytes::from_static(b"x"),
1003 meta: ChunkMeta::default(),
1004 })
1005 .is_none());
1006 assert_eq!(r.pending_count(), 0);
1007 }
1008
1009 #[test]
1010 fn old_timestamp_is_dropped_without_panic() {
1011 // ts_ms < TS_OFFSET_TOLERANCE_MS would underflow a plain `u128` subtraction (no panic with
1012 // saturating arithmetic), and a chunk stamped at the epoch is already long expired — it must
1013 // be dropped, not delivered, even though it is a complete `total == 1` message.
1014 let mut r = MessageReassembler::new();
1015 let out = r.handle(Chunk {
1016 chunk: [0, 1],
1017 data: Bytes::from_static(b"ok"),
1018 meta: ChunkMeta {
1019 id: Uuid::new_v4(),
1020 ts_ms: 0,
1021 ttl_ms: DEFAULT_TTL_MS,
1022 },
1023 });
1024 assert!(out.is_none());
1025 assert_eq!(r.pending_count(), 0);
1026 }
1027
1028 #[test]
1029 fn expired_single_chunk_is_not_delivered() {
1030 // Regression: sweeping *other* pending entries before insertion let an already-expired
1031 // `total == 1` chunk be delivered immediately. It must be rejected up front.
1032 let mut r = MessageReassembler::new();
1033 let now = get_epoch_ms();
1034 let out = r.handle(Chunk {
1035 chunk: [0, 1],
1036 data: Bytes::from_static(b"x"),
1037 meta: ChunkMeta {
1038 id: Uuid::new_v4(),
1039 ts_ms: now.saturating_sub(1000),
1040 ttl_ms: 100, // expired 900ms ago
1041 },
1042 });
1043 assert!(out.is_none());
1044 assert_eq!(r.pending_count(), 0);
1045 }
1046
1047 #[test]
1048 fn oversize_chunk_data_is_rejected() {
1049 let limits = small_limits();
1050 let mut r = MessageReassembler::with_limits(limits);
1051 let data: Bytes = vec![0u8; limits.max_chunk_data_len + 1].into();
1052 let out = r.handle(Chunk {
1053 chunk: [0, 1],
1054 data,
1055 meta: ChunkMeta::default(),
1056 });
1057 assert!(out.is_none());
1058 assert_eq!(r.pending_count(), 0);
1059 assert_eq!(r.buffered_cost, 0);
1060 }
1061
1062 #[test]
1063 fn buffered_cost_returns_to_zero_after_completion() {
1064 let data: Bytes = "helloworld".repeat(100).into();
1065 let mut r = MessageReassembler::new();
1066 for c in ChunkList::split(&data, 32) {
1067 r.handle(c);
1068 }
1069 assert_eq!(r.pending_count(), 0);
1070 assert_eq!(r.buffered_cost, 0, "completing a message frees its budget");
1071 }
1072
1073 /// A single id advertising a huge `total` and streaming distinct positions cannot grow without
1074 /// bound: the per-message byte cap stops it.
1075 #[test]
1076 fn per_message_byte_cap_bounds_one_id() {
1077 let limits = small_limits();
1078 let mut r = MessageReassembler::with_limits(limits);
1079 let meta = ChunkMeta::default();
1080 let data: Bytes = vec![0u8; limits.max_chunk_data_len].into();
1081 // Within the slot cap, but its data far exceeds the per-message byte cap so it never fills.
1082 let total = limits.max_chunks_per_message;
1083
1084 let mut accepted = 0usize;
1085 for position in 0..50 {
1086 let before = r.pending.get(&meta.id).map(|p| p.slots.len()).unwrap_or(0);
1087 r.handle(Chunk {
1088 meta,
1089 chunk: [position, total],
1090 data: data.clone(),
1091 });
1092 let after = r.pending.get(&meta.id).map(|p| p.slots.len()).unwrap_or(0);
1093 if after > before {
1094 accepted += 1;
1095 }
1096 }
1097
1098 let pending = r.pending.get(&meta.id).expect("still pending");
1099 assert!(
1100 pending.data_bytes <= limits.max_message_bytes,
1101 "per-message buffered data must stay within the cap"
1102 );
1103 assert!(
1104 accepted < 50,
1105 "the cap must reject some chunks, got {accepted}"
1106 );
1107 assert_eq!(
1108 r.buffered_cost,
1109 pending.cost(limits.slot_overhead),
1110 "accounting stays exact"
1111 );
1112 }
1113
1114 /// Spreading the flood across many ids is bounded too: the global buffered-cost ceiling caps
1115 /// total memory regardless of how many ids are used.
1116 #[test]
1117 fn global_cost_cap_bounds_total() {
1118 let limits = small_limits();
1119 let mut r = MessageReassembler::with_limits(limits);
1120 // Each id contributes one slot of `max_chunk_data_len` data; keep them all pending.
1121 for _ in 0..(limits.max_pending_messages * 4) {
1122 r.handle(Chunk {
1123 chunk: [0, 2],
1124 data: vec![0u8; limits.max_chunk_data_len].into(),
1125 meta: ChunkMeta::default(),
1126 });
1127 }
1128 assert!(
1129 r.buffered_cost <= limits.max_total_buffered_cost,
1130 "global buffered cost {} exceeded cap {}",
1131 r.buffered_cost,
1132 limits.max_total_buffered_cost
1133 );
1134 }
1135
1136 #[test]
1137 fn future_timestamp_is_dropped() {
1138 let mut r = MessageReassembler::new();
1139 let out = r.handle(Chunk {
1140 chunk: [0, 1],
1141 data: Bytes::from_static(b"x"),
1142 meta: ChunkMeta {
1143 id: Uuid::new_v4(),
1144 ts_ms: get_epoch_ms() + 10 * TS_OFFSET_TOLERANCE_MS,
1145 ttl_ms: DEFAULT_TTL_MS,
1146 },
1147 });
1148 assert!(out.is_none());
1149 }
1150
1151 #[test]
1152 fn expired_partial_messages_are_evicted() {
1153 let mut r = MessageReassembler::new();
1154 let now = get_epoch_ms();
1155 // a partial (1 of 2) message that is already expired
1156 r.handle(Chunk {
1157 chunk: [0, 2],
1158 data: Bytes::from_static(b"x"),
1159 meta: ChunkMeta {
1160 id: Uuid::new_v4(),
1161 ts_ms: now.saturating_sub(1000),
1162 ttl_ms: 100,
1163 },
1164 });
1165 // a fresh partial message triggers remove_expired, dropping the stale one
1166 r.handle(Chunk {
1167 chunk: [0, 2],
1168 data: Bytes::from_static(b"y"),
1169 meta: ChunkMeta {
1170 id: Uuid::new_v4(),
1171 ts_ms: now,
1172 ttl_ms: DEFAULT_TTL_MS,
1173 },
1174 });
1175 assert_eq!(r.pending_count(), 1, "only the fresh partial remains");
1176 }
1177
1178 #[test]
1179 fn pending_messages_are_capped() {
1180 let limits = small_limits();
1181 let mut r = MessageReassembler::with_limits(limits);
1182 // each is the first of two chunks => stays pending
1183 for _ in 0..(limits.max_pending_messages + 10) {
1184 r.handle(Chunk {
1185 chunk: [0, 2],
1186 data: Bytes::from_static(b"x"),
1187 meta: ChunkMeta::default(), // fresh id, fresh ts each time
1188 });
1189 }
1190 assert_eq!(r.pending_count(), limits.max_pending_messages);
1191 }
1192
1193 #[test]
1194 fn round_trip_reordered_with_duplicates() {
1195 let data: Bytes = "abcdefghij".repeat(500).into();
1196 let mut chunks = chunks_of(&data, 64);
1197 // reorder + inject duplicates mid-stream (not after the final chunk, which would just
1198 // start a fresh, TTL-evicted pending entry — a late retransmit, not a reassembly bug).
1199 chunks.reverse();
1200 let dup = chunks[chunks.len() / 2].clone();
1201 chunks.insert(1, dup.clone());
1202 chunks.insert(chunks.len() / 3, dup);
1203
1204 let mut r = MessageReassembler::new();
1205 let mut out = None;
1206 for c in chunks {
1207 out = r.handle(c).or(out);
1208 }
1209 assert_eq!(out.unwrap(), data);
1210 assert_eq!(r.pending_count(), 0);
1211 }
1212
1213 /// A forged `total` larger than the per-message slot cap is rejected before it can allocate a
1214 /// huge slot map, even though each individual chunk's data is tiny.
1215 #[test]
1216 fn total_over_slot_cap_is_rejected() {
1217 let limits = small_limits();
1218 let mut r = MessageReassembler::with_limits(limits);
1219 let out = r.handle(Chunk {
1220 chunk: [0, limits.max_chunks_per_message + 1],
1221 data: Bytes::from_static(b"x"),
1222 meta: ChunkMeta::default(),
1223 });
1224 assert!(out.is_none());
1225 assert_eq!(r.pending_count(), 0);
1226 assert_eq!(r.buffered_cost, 0);
1227 }
1228
1229 /// Two chunks sharing an id/total but from different transmissions (different `ts_ms`/`ttl_ms`)
1230 /// must not be merged into one pending entry.
1231 #[test]
1232 fn mismatched_ts_or_ttl_for_same_id_is_rejected() {
1233 let mut r = MessageReassembler::new();
1234 let id = Uuid::new_v4();
1235 let now = get_epoch_ms();
1236 assert!(r
1237 .handle(Chunk {
1238 chunk: [0, 2],
1239 data: Bytes::from_static(b"a"),
1240 meta: ChunkMeta {
1241 id,
1242 ts_ms: now,
1243 ttl_ms: DEFAULT_TTL_MS
1244 },
1245 })
1246 .is_none());
1247 // Same id/total, different ts_ms → rejected (a chunk from another transmission).
1248 let out = r.handle(Chunk {
1249 chunk: [1, 2],
1250 data: Bytes::from_static(b"b"),
1251 meta: ChunkMeta {
1252 id,
1253 ts_ms: now + 1,
1254 ttl_ms: DEFAULT_TTL_MS,
1255 },
1256 });
1257 assert!(out.is_none(), "must not complete by mixing transmissions");
1258 let p = r.pending.get(&id).expect("first chunk still pending");
1259 assert_eq!(p.slots.len(), 1, "the mismatched chunk left no trace");
1260 }
1261
1262 /// Once a completed message's TTL elapses, its tombstone is evicted by the real
1263 /// `remove_expired_at` path (driven here by an injected clock, not by poking internal state),
1264 /// and a fresh message reusing the same id is then accepted rather than suppressed.
1265 #[test]
1266 fn tombstone_expires_then_id_is_reusable() {
1267 let mut r = MessageReassembler::new();
1268 let id = Uuid::new_v4();
1269 // A fixed base well above the future-skew tolerance, so timestamps are unambiguous.
1270 let base = 1_000_000u128;
1271 let ttl = 100u64;
1272 let one_chunk = |label: &'static [u8], ts_ms: u128, ttl_ms: u64| Chunk {
1273 chunk: [0, 1],
1274 data: Bytes::from_static(label),
1275 meta: ChunkMeta { id, ts_ms, ttl_ms },
1276 };
1277
1278 // Complete a 1-chunk message at t = base; its tombstone expires at base + ttl.
1279 let first = r.handle_at(one_chunk(b"first", base, ttl), base);
1280 assert_eq!(first.as_deref(), Some(&b"first"[..]));
1281 assert!(r.completed_ids.contains(&id), "tombstoned after completion");
1282
1283 // A full retransmit *within* the TTL window (t = base + ttl/2) is suppressed.
1284 let dup = r.handle_at(one_chunk(b"first", base, ttl), base + (ttl as u128) / 2);
1285 assert!(
1286 dup.is_none(),
1287 "post-completion retransmit suppressed within TTL"
1288 );
1289 assert!(
1290 r.completed_ids.contains(&id),
1291 "tombstone still live within TTL"
1292 );
1293
1294 // Past the tombstone's expiry (t = base + ttl + 1), a brand-new message reusing the id is
1295 // delivered: `remove_expired_at` evicts the now-expired tombstone before classify runs.
1296 let later = base + ttl as u128 + 1;
1297 let reused = r.handle_at(one_chunk(b"second", later, ttl), later);
1298 assert_eq!(
1299 reused.as_deref(),
1300 Some(&b"second"[..]),
1301 "id reusable after its tombstone expired via remove_expired_at"
1302 );
1303 }
1304}