Skip to main content

rings_core/chunk/
reassembly.rs

1use std::collections::BTreeMap;
2use std::collections::HashMap;
3use std::collections::HashSet;
4use std::collections::VecDeque;
5use std::sync::atomic::AtomicUsize;
6use std::sync::atomic::Ordering;
7use std::sync::Arc;
8
9use bytes::Bytes;
10use uuid::Uuid;
11
12use super::Chunk;
13use super::ReassemblyLimits;
14use crate::consts::MAX_TTL_MS;
15use crate::consts::TS_OFFSET_TOLERANCE_MS;
16use crate::fair_admission::try_reserve_atomic;
17use crate::utils::get_epoch_ms;
18
19/// One message being reassembled: the chunks seen so far, keyed by position.
20pub(super) struct Pending {
21    /// total number of chunks the message claims (from `chunk[1]`).
22    total: usize,
23    /// received positions -> bytes. A `BTreeMap` dedups by position (first write wins) and keeps
24    /// the data ordered, so assembly is a single in-order concat.
25    pub(super) slots: BTreeMap<usize, Bytes>,
26    /// running sum of buffered data bytes, so the per-message cap is O(1) to check.
27    pub(super) data_bytes: usize,
28    /// creation time / ttl of the first chunk seen, used for TTL eviction.
29    ts_ms: u128,
30    ttl_ms: u64,
31    /// This logical transmission already produced its one peer-attributable failure.
32    failure_charged: bool,
33    /// Local capacity rejected at least one chunk, so later incompletion is not peer evidence.
34    local_capacity_rejected: bool,
35    /// Every retained chunk was bound to an authenticated peer at ingress.
36    peer_attributable: bool,
37}
38
39impl Pending {
40    fn new(total: usize, ts_ms: u128, ttl_ms: u64, peer_attributable: bool) -> Self {
41        Self {
42            total,
43            slots: BTreeMap::new(),
44            data_bytes: 0,
45            ts_ms,
46            ttl_ms,
47            failure_charged: false,
48            local_capacity_rejected: false,
49            peer_attributable,
50        }
51    }
52
53    /// Complete iff every position has arrived. Each inserted position is unique (map key) and in
54    /// `0..total`, so `slots.len() == total` iff the present set is exactly `{0..total-1}`.
55    fn is_complete(&self) -> bool {
56        self.slots.len() == self.total
57    }
58
59    /// Buffered cost charged to the global budget: data bytes plus `slot_overhead` per slot.
60    /// Saturating arithmetic, so adversarial limit values can never overflow/wrap the budget -
61    /// an overflowing cost simply saturates to `usize::MAX` and is rejected as over-budget.
62    pub(super) fn cost(&self, slot_overhead: usize) -> usize {
63        self.slots
64            .len()
65            .saturating_mul(slot_overhead)
66            .saturating_add(self.data_bytes)
67    }
68
69    fn assemble(self) -> Bytes {
70        self.slots.into_values().flatten().collect()
71    }
72}
73
74/// Stable identity of one logical transmission. A UUID may be reused after the
75/// prior transmission's TTL, so terminal evidence also includes its timestamp
76/// and TTL rather than blocking that UUID indefinitely.
77#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
78struct LogicalTransmission {
79    id: Uuid,
80    ts_ms: u128,
81    ttl_ms: u64,
82}
83
84impl LogicalTransmission {
85    const fn new(id: Uuid, ts_ms: u128, ttl_ms: u64) -> Self {
86        Self { id, ts_ms, ttl_ms }
87    }
88}
89
90/// Receiver side: **whole-message** reassembly for reliable data-channel `MessagePayload`
91/// fragments. Buffers a message's chunks keyed by id and yields the complete [`Bytes`] once every
92/// position has arrived (then forgets it).
93///
94/// Correct under duplicates / retransmits (first write per position wins *during* assembly,
95/// out-of-order arrival sorted), partial delivery (TTL eviction), and a message **fully
96/// retransmitted after it already completed**: a completed id is kept as a tombstone until it would
97/// expire, so a late re-send within the TTL window is dropped rather than re-assembled and delivered
98/// twice.
99///
100/// **Bounded against a hostile peer** by the [`ReassemblyLimits`] it is built with: every accepted
101/// chunk is validated and charged to both a per-peer pending-cost limit and a node-wide budget, so
102/// reassembly memory cannot grow without limit no matter how the load is shaped. Per-chunk data,
103/// per-message data, slot overhead, the pending/terminal id counts, and the completed-id tombstone
104/// set are all capped, and an already-expired chunk is rejected before it can be delivered or
105/// buffered.
106pub struct MessageReassembler {
107    pub(super) pending: HashMap<Uuid, Pending>,
108    /// Sum of `Pending::cost(..)` over this peer's `pending` entries.
109    pub(super) buffered_cost: usize,
110    /// Tombstones for ids that have already been delivered, each paired with its expiry
111    /// (`ts_ms + ttl_ms`). A chunk for one of these is dropped, so a post-completion retransmit of a
112    /// whole message is not re-assembled and delivered again. `VecDeque` for FIFO/TTL eviction, the
113    /// `HashSet` for an O(1) membership check; the two are kept in lockstep.
114    completed: VecDeque<(Uuid, u128)>,
115    pub(super) completed_ids: HashSet<Uuid>,
116    /// Failed or expired logical transmissions retained until a bounded local horizon. Once a
117    /// transmission has produced a terminal failure, later chunks with the same UUID, timestamp,
118    /// and TTL are replays rather than a second failure or a successful delivery. New failures are
119    /// fail-open (not attributed) while this bounded set is full, so hostile UUID rotation cannot
120    /// make memory unbounded.
121    failed: VecDeque<(LogicalTransmission, u128)>,
122    failed_ids: HashSet<LogicalTransmission>,
123    /// When invalid-terminal tracking saturates, new invalid arrivals fail open and untracked
124    /// already-scored expiries remain replays until this horizon. This prevents a retained expiry
125    /// from being charged again merely because an older tombstone drains first.
126    failure_tracking_saturated_until: u128,
127    /// Transmissions rejected by local capacity before any pending state was retained. Their ids
128    /// are blocked until expiry, preventing a later tail from becoming an incomplete message that
129    /// is incorrectly attributed to the peer.
130    capacity_rejected: VecDeque<(Uuid, u128)>,
131    capacity_rejected_ids: HashSet<Uuid>,
132    /// When the bounded capacity-id set is full, all new ids are locally rejected until every
133    /// untracked rejection that extended this horizon must itself be stale. Existing pending ids
134    /// can still make progress, so this cannot hide their genuine expiry failures.
135    capacity_tracking_saturated_until: u128,
136    /// The bounds enforced on every incoming chunk.
137    limits: ReassemblyLimits,
138    budget: Arc<ReassemblyBudget>,
139}
140
141/// Node-wide retained chunk cost shared by every per-peer reassembler.
142pub(crate) struct ReassemblyBudget {
143    pub(super) buffered_cost: AtomicUsize,
144    limit: usize,
145}
146
147impl ReassemblyBudget {
148    pub(crate) fn new(limits: ReassemblyLimits) -> Self {
149        Self {
150            buffered_cost: AtomicUsize::new(0),
151            limit: limits.normalized().max_total_buffered_cost,
152        }
153    }
154
155    fn try_reserve(&self, cost: usize) -> bool {
156        try_reserve_atomic(&self.buffered_cost, cost, self.limit)
157    }
158
159    fn release(&self, cost: usize) {
160        if self
161            .buffered_cost
162            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
163                current.checked_sub(cost)
164            })
165            .is_err()
166        {
167            tracing::error!(cost, "reassembly budget release exceeded retained cost");
168        }
169    }
170
171    #[cfg(all(test, feature = "dummy", not(target_family = "wasm")))]
172    pub(crate) fn buffered_cost_for_test(&self) -> usize {
173        self.buffered_cost.load(Ordering::Acquire)
174    }
175}
176
177/// Completed bytes that remain charged to the node budget until core admission takes ownership.
178pub(crate) struct RetainedReassembly {
179    bytes: Bytes,
180    budget: Arc<ReassemblyBudget>,
181    cost: usize,
182}
183
184/// Result of applying one chunk to a reassembler.
185pub(crate) enum ReassemblyOutcome {
186    /// The chunk was admitted but the message is not complete yet.
187    Incomplete,
188    /// The chunk completed a message whose output remains budget-charged.
189    Complete(RetainedReassembly),
190    /// The chunk was rejected without mutating retained reassembly state.
191    Rejected(ReassemblyRejection),
192}
193
194/// Whether a rejected chunk is evidence about the remote peer or local state.
195#[derive(Clone, Copy, Debug, Eq, PartialEq)]
196pub(crate) enum ReassemblyRejection {
197    /// The wire shape, timestamp, metadata, or size is remotely invalid.
198    Invalid,
199    /// Local per-peer or node-wide reassembly capacity was exhausted.
200    Capacity,
201    /// The chunk is stale or repeats a position or a message already accepted.
202    Replay,
203}
204
205impl RetainedReassembly {
206    fn into_bytes(mut self) -> Bytes {
207        self.budget.release(self.cost);
208        self.cost = 0;
209        std::mem::take(&mut self.bytes)
210    }
211}
212
213impl AsRef<[u8]> for RetainedReassembly {
214    fn as_ref(&self) -> &[u8] {
215        &self.bytes
216    }
217}
218
219impl Drop for RetainedReassembly {
220    fn drop(&mut self) {
221        self.budget.release(self.cost);
222    }
223}
224
225impl Default for MessageReassembler {
226    fn default() -> Self {
227        Self::with_limits(ReassemblyLimits::production())
228    }
229}
230
231impl MessageReassembler {
232    /// Empty reassembler with [`ReassemblyLimits::production`] bounds.
233    pub fn new() -> Self {
234        Self::default()
235    }
236
237    /// Empty reassembler enforcing the given `limits`. Tests use this with small limits to exercise
238    /// the admission rule without giant synthetic payloads.
239    pub fn with_limits(limits: ReassemblyLimits) -> Self {
240        let budget = Arc::new(ReassemblyBudget::new(limits));
241        Self::with_limits_and_budget(limits, budget)
242    }
243
244    /// Empty per-peer reassembler charged to a node-wide shared budget.
245    pub(crate) fn with_limits_and_budget(
246        limits: ReassemblyLimits,
247        budget: Arc<ReassemblyBudget>,
248    ) -> Self {
249        Self {
250            pending: HashMap::new(),
251            buffered_cost: 0,
252            completed: VecDeque::new(),
253            completed_ids: HashSet::new(),
254            failed: VecDeque::new(),
255            failed_ids: HashSet::new(),
256            failure_tracking_saturated_until: 0,
257            capacity_rejected: VecDeque::new(),
258            capacity_rejected_ids: HashSet::new(),
259            capacity_tracking_saturated_until: 0,
260            // Clamp nonsensical caps so a caller cannot disable an invariant (e.g. a `0` cap).
261            limits: limits.normalized(),
262            budget,
263        }
264    }
265
266    /// Record `id` as delivered so a later full retransmit (within the TTL window) is suppressed,
267    /// dropping the oldest tombstone if the cap is reached. `expiry` is the message's `ts_ms +
268    /// ttl_ms` - after it, a retransmit is rejected by the expiry check anyway, so the tombstone
269    /// can go.
270    fn mark_completed(&mut self, id: Uuid, expiry: u128) {
271        if self.completed_ids.insert(id) {
272            self.completed.push_back((id, expiry));
273        }
274        while self.completed.len() > self.limits.max_completed_ids {
275            if let Some((old, _)) = self.completed.pop_front() {
276                self.completed_ids.remove(&old);
277            }
278        }
279    }
280
281    /// Number of messages currently being reassembled (incomplete).
282    pub fn pending_count(&self) -> usize {
283        self.pending.len()
284    }
285
286    /// Drop messages whose TTL has elapsed, returning their cost to the budget, and evict
287    /// completed-id tombstones that have likewise expired (a retransmit past its expiry is rejected
288    /// anyway).
289    pub fn remove_expired(&mut self) {
290        let _ = self.remove_expired_at(get_epoch_ms());
291    }
292
293    /// Return whether any incomplete logical message is still retained.
294    pub(crate) fn has_pending(&self) -> bool {
295        !self.pending.is_empty()
296    }
297
298    /// Drop close-time state that cannot produce a future peer-attributable failure.
299    ///
300    /// No more chunks can arrive after the owning mailbox closes, so completed
301    /// and rejected-id tombstones have no purpose. Incomplete messages are
302    /// retained only when their original authenticated ingress can still yield
303    /// one failure at TTL; all other buffers release their shared budget now.
304    pub(crate) fn prepare_for_close(&mut self) -> bool {
305        let buffered_cost = &mut self.buffered_cost;
306        let budget = &self.budget;
307        let slot_overhead = self.limits.slot_overhead;
308        self.pending.retain(|_, pending| {
309            let retained = pending.peer_attributable
310                && !(pending.failure_charged || pending.local_capacity_rejected);
311            if !retained {
312                let cost = pending.cost(slot_overhead);
313                *buffered_cost = buffered_cost.saturating_sub(cost);
314                budget.release(cost);
315            }
316            retained
317        });
318        self.clear_terminal_history();
319        !self.pending.is_empty()
320    }
321
322    /// Release every close-time pending buffer when no timer can deliver TTL cleanup.
323    pub(crate) fn discard_after_close_timer_failure(&mut self) {
324        for pending in self.pending.values() {
325            self.budget.release(pending.cost(self.limits.slot_overhead));
326        }
327        self.pending.clear();
328        self.buffered_cost = 0;
329        self.clear_terminal_history();
330    }
331
332    fn clear_terminal_history(&mut self) {
333        self.completed.clear();
334        self.completed_ids.clear();
335        self.failed.clear();
336        self.failed_ids.clear();
337        self.failure_tracking_saturated_until = 0;
338        self.capacity_rejected.clear();
339        self.capacity_rejected_ids.clear();
340        self.capacity_tracking_saturated_until = 0;
341    }
342
343    /// [`remove_expired`](Self::remove_expired) with the clock injected (tests pass a controlled
344    /// `now` to drive the real eviction logic).
345    pub(crate) fn remove_expired_at(&mut self, now: u128) -> usize {
346        self.evict_expired_terminal_history(now);
347        let mut expired_count = 0_usize;
348        let mut expired_transmissions = Vec::new();
349        let buffered_cost = &mut self.buffered_cost;
350        let budget = &self.budget;
351        let slot_overhead = self.limits.slot_overhead;
352        self.pending.retain(|id, p| {
353            let alive = p.ts_ms.saturating_add(p.ttl_ms as u128) > now;
354            if !alive {
355                let cost = p.cost(slot_overhead);
356                *buffered_cost = buffered_cost.saturating_sub(cost);
357                budget.release(cost);
358                expired_transmissions.push(LogicalTransmission::new(*id, p.ts_ms, p.ttl_ms));
359                if !(p.failure_charged || p.local_capacity_rejected) && p.peer_attributable {
360                    expired_count = expired_count.saturating_add(1);
361                }
362            }
363            alive
364        });
365        // Keep a bounded terminal witness after removing the pending buffer. This distinguishes a
366        // late chunk for an already-scored expiry from a first expired-on-arrival chunk, which is
367        // invalid evidence. At capacity `mark_failed_transmission` fails open for reputation by
368        // classifying the event as Replay without growing memory. Its saturation horizon keeps
369        // that classification stable if an older tombstone drains first; after the bounded
370        // horizon, exact-once history is intentionally forgotten with the tombstones.
371        for transmission in expired_transmissions {
372            if !self.mark_failed_transmission(transmission, now) {
373                // This expiry has already contributed to `expired_count`. Keep its untracked
374                // witness horizon alive even when a prior saturation interval was already active.
375                self.extend_failure_tracking_saturation(now);
376            }
377        }
378        expired_count
379    }
380
381    fn evict_expired_terminal_history(&mut self, now: u128) {
382        // Evict every expired tombstone, not just a leading run: completion order need not equal
383        // expiry order, so a `retain` is correct where front-popping would leave an out-of-order
384        // early-expiry entry behind a still-live front.
385        let completed_ids = &mut self.completed_ids;
386        self.completed.retain(|&(id, expiry)| {
387            let alive = expiry > now;
388            if !alive {
389                completed_ids.remove(&id);
390            }
391            alive
392        });
393        let failed_ids = &mut self.failed_ids;
394        self.failed.retain(|&(transmission, expiry)| {
395            let alive = expiry > now;
396            if !alive {
397                failed_ids.remove(&transmission);
398            }
399            alive
400        });
401        let capacity_rejected_ids = &mut self.capacity_rejected_ids;
402        self.capacity_rejected.retain(|&(id, expiry)| {
403            let alive = expiry > now;
404            if !alive {
405                capacity_rejected_ids.remove(&id);
406            }
407            alive
408        });
409    }
410
411    /// Forget a message (e.g. after it has been delivered), returning its cost to the budget.
412    pub fn remove(&mut self, id: Uuid) {
413        if let Some(p) = self.pending.remove(&id) {
414            let cost = p.cost(self.limits.slot_overhead);
415            self.buffered_cost = self.buffered_cost.saturating_sub(cost);
416            self.budget.release(cost);
417        }
418    }
419
420    /// Accept one chunk. Returns the fully reassembled payload when this chunk completes its
421    /// message (which is then forgotten), otherwise `None`.
422    ///
423    /// Imperative shell over a functional core: expire stale state, ask the pure `classify` for an
424    /// admission verdict, and apply it. Accepted data mutation stays in `admit`; the shell records
425    /// only bounded terminal/capacity evidence for rejected chunks so local loss cannot become a
426    /// peer failure and one logical id cannot produce conflicting terminal outcomes.
427    pub fn handle(&mut self, chunk: Chunk) -> Option<Bytes> {
428        self.handle_at(chunk, get_epoch_ms())
429    }
430
431    /// Accept one chunk while retaining the completed output's node-wide budget charge.
432    #[cfg(test)]
433    pub(crate) fn handle_retained(&mut self, chunk: Chunk) -> Option<RetainedReassembly> {
434        match self.handle_retained_outcome(chunk) {
435            ReassemblyOutcome::Complete(bytes) => Some(bytes),
436            ReassemblyOutcome::Incomplete | ReassemblyOutcome::Rejected(_) => None,
437        }
438    }
439
440    /// Accept one chunk while preserving incomplete, complete, and rejected states.
441    #[cfg(test)]
442    pub(crate) fn handle_retained_outcome(&mut self, chunk: Chunk) -> ReassemblyOutcome {
443        self.handle_retained_at(chunk, get_epoch_ms()).0
444    }
445
446    #[cfg(test)]
447    pub(crate) fn handle_retained_outcome_at(
448        &mut self,
449        chunk: Chunk,
450        now: u128,
451    ) -> ReassemblyOutcome {
452        self.handle_retained_at(chunk, now).0
453    }
454
455    /// Accept one chunk and also report how many older incomplete logical messages expired before
456    /// admission. The runtime adapter uses the count for one peer failure per expired message.
457    pub(crate) fn handle_retained_outcome_with_expiry(
458        &mut self,
459        chunk: Chunk,
460        peer_attributable: bool,
461    ) -> (ReassemblyOutcome, usize) {
462        self.handle_retained_at_with_attribution(chunk, get_epoch_ms(), peer_attributable)
463    }
464
465    /// [`handle`](Self::handle) with the clock injected, so tests drive expiry/admission against a
466    /// controlled `now` through the real production path instead of poking internal state.
467    pub(super) fn handle_at(&mut self, chunk: Chunk, now: u128) -> Option<Bytes> {
468        match self.handle_retained_at(chunk, now).0 {
469            ReassemblyOutcome::Complete(bytes) => Some(bytes.into_bytes()),
470            ReassemblyOutcome::Incomplete | ReassemblyOutcome::Rejected(_) => None,
471        }
472    }
473
474    fn handle_retained_at(&mut self, chunk: Chunk, now: u128) -> (ReassemblyOutcome, usize) {
475        self.handle_retained_at_with_attribution(chunk, now, true)
476    }
477
478    fn handle_retained_at_with_attribution(
479        &mut self,
480        chunk: Chunk,
481        now: u128,
482        peer_attributable: bool,
483    ) -> (ReassemblyOutcome, usize) {
484        // Reclaim expired pending entries and tombstones first, before classify reads them, so
485        // invalid traffic still frees memory and an expired tombstone cannot suppress a fresh
486        // message that reuses its id after the TTL window.
487        let expired = self.remove_expired_at(now);
488        let outcome = match self.classify(&chunk, now) {
489            Ok(cost) => self.admit(chunk, cost, peer_attributable),
490            Err(reason) => {
491                tracing::debug!(?reason, id = ?chunk.meta.id, "reassembler dropped chunk");
492                let rejection = match reason.rejection() {
493                    ReassemblyRejection::Invalid => {
494                        if self.mark_logical_failure(&chunk, now) {
495                            ReassemblyRejection::Invalid
496                        } else {
497                            ReassemblyRejection::Replay
498                        }
499                    }
500                    ReassemblyRejection::Capacity => {
501                        self.mark_pending_capacity_rejection(&chunk);
502                        ReassemblyRejection::Capacity
503                    }
504                    ReassemblyRejection::Replay => ReassemblyRejection::Replay,
505                };
506                ReassemblyOutcome::Rejected(rejection)
507            }
508        };
509        (outcome, expired)
510    }
511
512    /// The pure admission rule: `(state, chunk, now) -> Ok(cost) | Err(reason)`. Borrows `&self`,
513    /// mutates nothing, does no I/O. On success it returns the buffered cost [`admit`] must charge;
514    /// on failure a typed [`Rejected`] reason. Validating the existing pending entry here, before
515    /// accepted-data mutation, is what keeps buffer accounting exact. The surrounding shell may
516    /// retain bounded failure/capacity evidence after this pure verdict.
517    ///
518    /// [`admit`]: Self::admit
519    fn classify(&self, chunk: &Chunk, now: u128) -> std::result::Result<usize, Rejected> {
520        let meta = &chunk.meta;
521        let transmission = LogicalTransmission::new(meta.id, meta.ts_ms, meta.ttl_ms);
522        if self
523            .pending
524            .get(&meta.id)
525            .is_some_and(|pending| pending.failure_charged)
526            || self.failed_ids.contains(&transmission)
527        {
528            return Err(Rejected::AlreadyFailed);
529        }
530        if self.capacity_rejected_ids.contains(&meta.id) {
531            return Err(Rejected::CapacityRejectedId);
532        }
533        if meta.ttl_ms > MAX_TTL_MS {
534            return Err(Rejected::TtlTooLarge);
535        }
536        // `saturating_sub` avoids the `u128` underflow a forged `ts_ms < TS_OFFSET_TOLERANCE_MS`
537        // would cause; `saturating_add` avoids overflow on a forged ttl.
538        if meta.ts_ms.saturating_sub(TS_OFFSET_TOLERANCE_MS) > now {
539            return Err(Rejected::FutureTimestamp);
540        }
541        // Reject an already-expired chunk up front, so a stale `total == 1` is never delivered.
542        if meta.ts_ms.saturating_add(meta.ttl_ms as u128) <= now {
543            return Err(Rejected::Expired);
544        }
545
546        let [position, total] = chunk.chunk;
547        // A real message has at least one chunk and every position in `0..total`.
548        if total == 0 || position >= total {
549            return Err(Rejected::Malformed);
550        }
551        // Cap the slot count: a forged `total` is refused before it can allocate a huge `BTreeMap`.
552        if total > self.limits.max_chunks_per_message {
553            return Err(Rejected::TooManyChunks);
554        }
555        // One chunk cannot exceed one data-channel message.
556        if chunk.data.len() > self.limits.max_chunk_data_len {
557            return Err(Rejected::ChunkTooLarge);
558        }
559        // Already delivered: drop a post-completion retransmit (expired tombstones were swept).
560        if self.completed_ids.contains(&meta.id) {
561            return Err(Rejected::AlreadyCompleted);
562        }
563
564        // Bytes already buffered for this id (`0` for a new message). Used for the per-message cap
565        // below, which must hold for the first chunk too, not only once a pending entry exists, or a
566        // caller-supplied `max_chunk_data_len > max_message_bytes` could admit an oversized chunk.
567        let buffered_for_id = match self.pending.get(&meta.id) {
568            None if self.capacity_tracking_saturated_until > now => {
569                return Err(Rejected::CapacityTrackingFull);
570            }
571            // A new id: admit only if there is room for another concurrent message.
572            None if self.pending.len() >= self.limits.max_pending_messages => {
573                return Err(Rejected::PendingFull);
574            }
575            None => 0,
576            Some(p) => {
577                // A chunk of an in-flight message must agree on its shape and provenance.
578                if p.total != total {
579                    return Err(Rejected::TotalMismatch);
580                }
581                // Chunks of one message share id+ts+ttl; a same-id chunk from a different
582                // transmission must not be merged in (it would skew expiry/tombstone behaviour).
583                if p.ts_ms != meta.ts_ms || p.ttl_ms != meta.ttl_ms {
584                    return Err(Rejected::MetadataMismatch);
585                }
586                // First write per position wins; a duplicate position is a no-op, not an error.
587                if let Some(existing) = p.slots.get(&position) {
588                    return if existing == &chunk.data {
589                        Err(Rejected::DuplicatePosition)
590                    } else {
591                        Err(Rejected::ConflictingPosition)
592                    };
593                }
594                p.data_bytes
595            }
596        };
597        // Per-message data cap, enforced uniformly across the first and subsequent chunks.
598        if buffered_for_id.saturating_add(chunk.data.len()) > self.limits.max_message_bytes {
599            return Err(Rejected::PerMessageBytes);
600        }
601
602        // Cost charged to both the peer and node budgets: this slot's data plus its fixed overhead.
603        // Saturating arithmetic keeps a pathological `slot_overhead` from wrapping either limit.
604        let cost = chunk.data.len().saturating_add(self.limits.slot_overhead);
605        if self.buffered_cost.saturating_add(cost) > self.limits.max_peer_buffered_cost() {
606            return Err(Rejected::PeerBudget);
607        }
608        Ok(cost)
609    }
610
611    fn mark_logical_failure(&mut self, chunk: &Chunk, now: u128) -> bool {
612        let meta = &chunk.meta;
613        if let Some(pending) = self.pending.get_mut(&meta.id) {
614            if pending.failure_charged {
615                return false;
616            }
617            pending.failure_charged = true;
618            return true;
619        }
620
621        self.mark_failed_transmission(
622            LogicalTransmission::new(meta.id, meta.ts_ms, meta.ttl_ms),
623            now,
624        )
625    }
626
627    fn mark_failed_transmission(&mut self, transmission: LogicalTransmission, now: u128) -> bool {
628        if self.failed_ids.contains(&transmission) || self.failure_tracking_saturated_until > now {
629            return false;
630        }
631        if self.failed_ids.len() >= self.limits.max_completed_ids {
632            self.extend_failure_tracking_saturation(now);
633            return false;
634        }
635        // Invalid timestamps/TTLs must not pin terminal state longer than the protocol maximum.
636        // Using a local horizon also gives a well-defined reuse point for a malformed identity.
637        let expiry = now.saturating_add(MAX_TTL_MS as u128);
638        self.failed_ids.insert(transmission);
639        self.failed.push_back((transmission, expiry));
640        true
641    }
642
643    fn extend_failure_tracking_saturation(&mut self, now: u128) {
644        self.failure_tracking_saturated_until = self
645            .failure_tracking_saturated_until
646            .max(now.saturating_add(MAX_TTL_MS as u128));
647    }
648
649    fn mark_pending_capacity_rejection(&mut self, chunk: &Chunk) {
650        let meta = &chunk.meta;
651        if let Some(pending) = self.pending.get_mut(&meta.id) {
652            if pending.ts_ms == meta.ts_ms && pending.ttl_ms == meta.ttl_ms {
653                pending.local_capacity_rejected = true;
654                return;
655            }
656        }
657        self.mark_capacity_rejected_id(meta.id, meta.ts_ms, meta.ttl_ms);
658    }
659
660    fn mark_capacity_rejected_id(&mut self, id: Uuid, ts_ms: u128, ttl_ms: u64) {
661        let expiry = ts_ms.saturating_add(ttl_ms.min(MAX_TTL_MS) as u128);
662        if self.capacity_rejected_ids.contains(&id) {
663            return;
664        }
665        if self.capacity_rejected_ids.len() >= self.limits.max_completed_ids {
666            self.capacity_tracking_saturated_until =
667                self.capacity_tracking_saturated_until.max(expiry);
668            return;
669        }
670        self.capacity_rejected_ids.insert(id);
671        self.capacity_rejected.push_back((id, expiry));
672    }
673
674    /// The sole buffer mutation: insert a [`classify`]-approved `chunk` (charging `cost`), and if it
675    /// completes its message, take it out, refund its budget, tombstone the id, and return the
676    /// reassembled payload.
677    ///
678    /// [`classify`]: Self::classify
679    fn admit(&mut self, chunk: Chunk, cost: usize, peer_attributable: bool) -> ReassemblyOutcome {
680        if !self.budget.try_reserve(cost) {
681            self.mark_pending_capacity_rejection(&chunk);
682            tracing::debug!(
683                reason = ?Rejected::GlobalBudget,
684                id = ?chunk.meta.id,
685                "reassembler dropped chunk"
686            );
687            return ReassemblyOutcome::Rejected(ReassemblyRejection::Capacity);
688        }
689        let id = chunk.meta.id;
690        let [position, total] = chunk.chunk;
691        let mut pending = self.pending.remove(&id).unwrap_or_else(|| {
692            Pending::new(
693                total,
694                chunk.meta.ts_ms,
695                chunk.meta.ttl_ms,
696                peer_attributable,
697            )
698        });
699        pending.peer_attributable &= peer_attributable;
700        pending.data_bytes = pending.data_bytes.saturating_add(chunk.data.len());
701        pending.slots.insert(position, chunk.data);
702        self.buffered_cost = self.buffered_cost.saturating_add(cost);
703
704        if !pending.is_complete() {
705            self.pending.insert(id, pending);
706            #[cfg(all(test, feature = "dummy", not(target_family = "wasm")))]
707            crate::simulation::observe_reassembly_capacity(
708                self.budget.buffered_cost.load(Ordering::Acquire),
709                self.budget.limit,
710                self.buffered_cost,
711                self.limits.max_peer_buffered_cost(),
712                self.pending.len(),
713                self.limits.max_pending_messages,
714            );
715            return ReassemblyOutcome::Incomplete;
716        }
717        let output_cost = pending.data_bytes;
718        if !self.budget.try_reserve(output_cost) {
719            self.mark_capacity_rejected_id(id, pending.ts_ms, pending.ttl_ms);
720            let dropped_cost = pending.cost(self.limits.slot_overhead);
721            self.buffered_cost = self.buffered_cost.saturating_sub(dropped_cost);
722            self.budget.release(dropped_cost);
723            tracing::debug!(
724                reason = ?Rejected::GlobalBudget,
725                ?id,
726                output_cost,
727                "reassembler dropped completed message before output allocation"
728            );
729            return ReassemblyOutcome::Rejected(ReassemblyRejection::Capacity);
730        }
731        #[cfg(all(test, feature = "dummy", not(target_family = "wasm")))]
732        crate::simulation::observe_reassembly_capacity(
733            self.budget.buffered_cost.load(Ordering::Acquire),
734            self.budget.limit,
735            self.buffered_cost,
736            self.limits.max_peer_buffered_cost(),
737            self.pending.len().saturating_add(1),
738            self.limits.max_pending_messages,
739        );
740        let done = pending;
741        let done_cost = done.cost(self.limits.slot_overhead);
742        let expiry = done.ts_ms.saturating_add(done.ttl_ms as u128);
743        self.buffered_cost = self.buffered_cost.saturating_sub(done_cost);
744        let bytes = done.assemble();
745        self.budget.release(done_cost);
746        // Tombstone the id until it would expire, so a later full retransmit is suppressed.
747        self.mark_completed(id, expiry);
748        ReassemblyOutcome::Complete(RetainedReassembly {
749            bytes,
750            budget: self.budget.clone(),
751            cost: output_cost,
752        })
753    }
754}
755
756impl Drop for MessageReassembler {
757    fn drop(&mut self) {
758        self.budget.release(self.buffered_cost);
759    }
760}
761
762/// Why a chunk was not admitted - a value, so [`MessageReassembler::classify`] stays a pure total
763/// function the shell can test and log uniformly, rather than scattering ad-hoc log strings.
764#[derive(Debug, Clone, Copy, PartialEq, Eq)]
765enum Rejected {
766    /// `ttl_ms` exceeds [`MAX_TTL_MS`].
767    TtlTooLarge,
768    /// Stamped further in the future than [`TS_OFFSET_TOLERANCE_MS`] allows.
769    FutureTimestamp,
770    /// Already past its `ts_ms + ttl_ms` expiry.
771    Expired,
772    /// `total == 0` or `position >= total`.
773    Malformed,
774    /// `total` exceeds [`ReassemblyLimits::max_chunks_per_message`].
775    TooManyChunks,
776    /// `data` exceeds [`ReassemblyLimits::max_chunk_data_len`].
777    ChunkTooLarge,
778    /// The message id is tombstoned (already delivered).
779    AlreadyCompleted,
780    /// This in-flight logical transmission already produced its failure outcome.
781    AlreadyFailed,
782    /// This logical id was previously rejected by local capacity before retaining state.
783    CapacityRejectedId,
784    /// Bounded local capacity-history tracking is saturated, so new ids fail closed locally.
785    CapacityTrackingFull,
786    /// A new id, but [`ReassemblyLimits::max_pending_messages`] is already reached.
787    PendingFull,
788    /// `total` disagrees with the in-flight message's.
789    TotalMismatch,
790    /// `ts_ms`/`ttl_ms` disagree with the in-flight message's (a different transmission).
791    MetadataMismatch,
792    /// This position is already buffered (a duplicate/retransmit).
793    DuplicatePosition,
794    /// This position is buffered with different bytes.
795    ConflictingPosition,
796    /// Admitting would exceed the message's [`ReassemblyLimits::max_message_bytes`].
797    PerMessageBytes,
798    /// Admitting would exceed this peer's derived pending-cost allowance.
799    PeerBudget,
800    /// Admitting would exceed the global [`ReassemblyLimits::max_total_buffered_cost`].
801    GlobalBudget,
802}
803
804impl Rejected {
805    const fn rejection(self) -> ReassemblyRejection {
806        match self {
807            Self::AlreadyCompleted
808            | Self::AlreadyFailed
809            | Self::DuplicatePosition => ReassemblyRejection::Replay,
810            Self::CapacityRejectedId
811            | Self::CapacityTrackingFull
812            | Self::PendingFull
813            | Self::PeerBudget
814            | Self::GlobalBudget => ReassemblyRejection::Capacity,
815            Self::TtlTooLarge
816            | Self::FutureTimestamp
817            // An expired-on-arrival id has no retained expiry outcome. Treat it
818            // as invalid once; `mark_logical_failure` tombstones duplicate ids.
819            | Self::Expired
820            | Self::Malformed
821            | Self::TooManyChunks
822            | Self::ChunkTooLarge
823            | Self::TotalMismatch
824            | Self::MetadataMismatch
825            | Self::ConflictingPosition
826            | Self::PerMessageBytes => ReassemblyRejection::Invalid,
827        }
828    }
829}