Skip to main content

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}