Skip to main content

net/adapter/net/
session.rs

1//! Session and stream state management for Net.
2//!
3//! This module manages session state after Noise handshake completion,
4//! including per-stream state for multiplexing.
5
6use bytes::Bytes;
7use crossbeam_queue::SegQueue;
8use dashmap::DashMap;
9use std::net::SocketAddr;
10use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
11use std::sync::Arc;
12use std::time::Duration;
13
14use std::time::Instant;
15
16use crate::event::StoredEvent;
17
18use super::crypto::{PacketCipher, SessionKeys};
19use super::subnet::route_hop::SharedHopReplayWindow;
20// `SharedPacketPool` is intentionally absent — `NetSession` uses
21// only `SharedLocalPool` as the single TX-side AEAD source.
22use super::pool::SharedLocalPool;
23use super::reliability::{
24    create_reliability_mode, ReliabilityMode, ReliableStream, RetransmitDescriptor,
25};
26use super::stream::DEFAULT_STREAM_WINDOW_BYTES;
27use super::transport::ParsedPacket;
28
29/// TIME_WAIT-style quarantine window after `close_stream`. A
30/// `StreamWindow` grant that arrives for a stream closed within
31/// this window is dropped — protects a reopened stream from
32/// being credited by in-flight grants minted against the previous
33/// lifetime.
34///
35/// Sized to comfortably exceed grant RTT on LAN / typical mesh
36/// deployments. Callers that rapidly reopen the same `stream_id`
37/// will see a brief stall (the reopened stream won't receive
38/// grants until the quarantine expires) — an acceptable trade-off
39/// for correct credit accounting across lifetimes.
40pub const GRANT_QUARANTINE_WINDOW: Duration = Duration::from_secs(2);
41
42/// One gapped stream's proactive-tick report (STREAM_ACK_BATCHING
43/// R-4), produced by [`NetSession::collect_gap_reports`] under a
44/// single reliability lock per stream so the NACK and the SACK ranges
45/// describe the same received-range snapshot.
46#[derive(Debug, Clone)]
47pub struct GapReport {
48    /// Stream the gap is on.
49    pub stream_id: u64,
50    /// Legacy negative ack for the gap (always present — every gapped
51    /// stream emits one, capability-independent).
52    pub nack: super::protocol::NackPayload,
53    /// Cumulative ack (`next_expected`) captured in the same snapshot
54    /// as `ranges`, so the outgoing `StreamAckRanges` is internally
55    /// consistent (every range strictly above `ack_seq`).
56    pub ack_seq: u64,
57    /// Positive SACK ranges, newest-first. Empty when the peer does
58    /// not advertise the ack-ranges capability (`want_ranges = false`).
59    pub ranges: Vec<(u64, u64)>,
60}
61
62/// Session state after handshake completion.
63pub struct NetSession {
64    /// Session ID (derived from handshake)
65    session_id: u64,
66    /// Remote peer address
67    peer_addr: SocketAddr,
68    /// RX cipher (ChaCha20-Poly1305 with counter-based nonces)
69    rx_cipher: PacketCipher,
70    // No `tx_key` field: `thread_local_pool` is the only surface
71    // that holds the TX key on a live `NetSession`. Storing an
72    // extra copy here would re-open a cross-pool nonce-reuse
73    // hazard — independent counters under the same ChaCha20-
74    // Poly1305 key — and would only be read back through a
75    // `tx_key()` accessor whose only consumers are misuses (e.g.
76    // a fresh `PacketBuilder::new` that bypasses the
77    // thread-local pool's nonce sequencing).
78    /// Per-stream state
79    streams: DashMap<u64, StreamState>,
80    /// Last activity timestamp (for session timeout)
81    last_activity: AtomicU64,
82    /// Thread-local pool for zero-contention hot path. The single
83    /// authoritative source of TX-side AEAD encryptions for this
84    /// session — see the `tx_key` comment above for the
85    /// cross-pool nonce-reuse rationale.
86    thread_local_pool: SharedLocalPool,
87    /// Default reliability mode for new streams
88    default_reliable: bool,
89    /// Session is active
90    active: AtomicBool,
91    /// Monotonic generator for per-`StreamState` epochs. Each opened
92    /// stream captures a unique epoch at construction time so that
93    /// stale `Stream` handles or `TxSlotGuard`s from a previous
94    /// open/close cycle can't silently operate on a new stream that
95    /// reuses the same `stream_id`.
96    stream_epoch_counter: AtomicU64,
97    /// Stream IDs closed within the last `GRANT_QUARANTINE_WINDOW`.
98    /// Used to drop in-flight `StreamWindow` grants minted against a
99    /// previous lifetime of a `stream_id` so they can't credit a
100    /// subsequent reopen. Entries are inserted on `close_stream` and
101    /// lazily garbage-collected by `is_grant_quarantined` on read.
102    recently_closed: DashMap<u64, Instant>,
103    /// Monotonic sequence counter for subprotocol control packets
104    /// (grants, membership acks, etc.) that don't belong to a
105    /// user-opened stream. Using a separate counter keeps control
106    /// traffic out of the `streams` map, so a caller who opens a
107    /// stream with a numerically-equal id (e.g., `0x0B00`, the
108    /// `SUBPROTOCOL_STREAM_WINDOW` constant) can't have their
109    /// sequence space polluted by control packets.
110    control_tx_seq: AtomicU64,
111    /// Per-session cache of the resolved peer `NodeId`.
112    ///
113    /// Pre-fix [discovery-routing perf #108 in
114    /// `docs/internal/performance/net-discovery-routing-analysis.md`] the
115    /// inbound dispatcher's RPC hook ran the
116    /// `addr_to_node → peers.get → session_id-match → fallback
117    /// O(N) peer scan` resolution chain on **every** inbound RPC
118    /// packet. The session itself is stable — once we've resolved
119    /// `session → node_id` for an established session, that
120    /// mapping doesn't change.
121    ///
122    /// The cache uses `0` as the "unresolved" sentinel — real
123    /// `NodeId`s are non-zero in production (`0` is the test /
124    /// loopback sentinel that already gets rejected by the
125    /// dispatcher's `Some(from_node) else { drop }` guard).
126    /// `Relaxed` ordering is enough: a tear in the published
127    /// value would only manifest as a re-resolution on the next
128    /// packet (which then re-publishes the same value), and the
129    /// resolver itself is the source of truth.
130    cached_node_id: AtomicU64,
131    /// Key for MACing route-hop envelopes this node sends on this
132    /// edge. See [`Self::seal_route_hop`].
133    route_hop_tx_key: [u8; 32],
134    /// Key for verifying route-hop envelopes received on this edge.
135    route_hop_rx_key: [u8; 32],
136    /// This edge's outbound hop sequence — separate from the packet
137    /// AEAD counter by design.
138    route_hop_tx_seq: AtomicU64,
139    /// Sliding replay window over inbound hop sequences.
140    ///
141    /// Lock-free single-writer state, not a mutex: the production
142    /// protected-ingress path is single-consumer (one receive loop,
143    /// synchronous dispatch), so admission never contends there, and
144    /// the ordinary path pays no locking. A second concurrent caller
145    /// — only reachable by breaking that ownership rule — is refused
146    /// immediately and its packet dropped
147    /// ([`super::subnet::route_hop::RouteHopError::Contended`]).
148    route_hop_replay: SharedHopReplayWindow,
149}
150
151/// Sentinel `stream_id` used in the header of subprotocol control
152/// packets (credit grants, etc.). Chosen at the top of the u64
153/// range so it cannot collide with practical user-chosen ids or
154/// with the output of `stream_id_from_key`. The receiver dispatches
155/// these packets by `subprotocol_id`, not `stream_id`, so the
156/// sentinel is purely there to keep sender-side per-stream state
157/// clean.
158pub const CONTROL_STREAM_ID: u64 = u64::MAX;
159
160impl NetSession {
161    /// Create a new session from handshake results
162    pub fn new(
163        keys: SessionKeys,
164        peer_addr: SocketAddr,
165        pool_size: usize,
166        default_reliable: bool,
167    ) -> Self {
168        let rx_cipher = PacketCipher::new(&keys.rx_key, keys.session_id);
169
170        // Only `thread_local_pool` is constructed with the TX key.
171        // Independently constructing a `tx_cipher` and a
172        // `packet_pool` with the same key but independent counters
173        // would re-open a cross-pool nonce-reuse hazard — see the
174        // `tx_key` comment above. The data path uses
175        // `thread_local_pool` exclusively.
176        let thread_local_pool =
177            super::pool::shared_local_pool(pool_size, &keys.tx_key, keys.session_id);
178
179        // `tx_key` is consumed only by `shared_local_pool` above.
180        // Copying it into a struct field would be dead storage and
181        // a cross-pool footgun (see the `tx_key` comment on the
182        // struct above).
183        Self {
184            session_id: keys.session_id,
185            peer_addr,
186            rx_cipher,
187            streams: DashMap::new(),
188            last_activity: AtomicU64::new(current_timestamp()),
189            thread_local_pool,
190            default_reliable,
191            active: AtomicBool::new(true),
192            stream_epoch_counter: AtomicU64::new(1),
193            recently_closed: DashMap::new(),
194            control_tx_seq: AtomicU64::new(0),
195            cached_node_id: AtomicU64::new(0),
196            // Unlike `tx_key`, the route-hop keys ARE retained: a
197            // relay MACs every forwarded hop, and a MAC has no
198            // nonce-reuse hazard to route around — the sequence is an
199            // explicit transcript field, not derived counter state.
200            route_hop_tx_key: keys.route_hop_tx_key,
201            route_hop_rx_key: keys.route_hop_rx_key,
202            route_hop_tx_seq: AtomicU64::new(0),
203            route_hop_replay: SharedHopReplayWindow::new(),
204        }
205    }
206
207    /// Wrap `inner` in an authenticated route-hop envelope for this
208    /// edge, writing into a caller-owned buffer
209    /// (SUBNET_AUTH_PLAN.md D6).
210    ///
211    /// The sequence is this edge's own, independent of the packet
212    /// AEAD counter, so hop accounting can never disturb the
213    /// end-to-end session being carried.
214    ///
215    /// This is the form the forwarding path uses: the buffer belongs
216    /// to the forwarder and is reused across packets, so relaying does
217    /// not allocate. Size it with
218    /// [`route_hop::sealed_len`](super::subnet::route_hop::sealed_len).
219    ///
220    /// A too-small buffer is refused *before* a sequence is taken —
221    /// burning one on a local sizing mistake would open a gap in this
222    /// edge's sequence space for no reason.
223    pub fn seal_route_hop_into(
224        &self,
225        out: &mut [u8],
226        header: &super::route::RoutingHeader,
227        inner: &[u8],
228    ) -> Result<usize, super::subnet::route_hop::RouteHopError> {
229        if out.len() < super::subnet::route_hop::sealed_len(inner.len()) {
230            return Err(super::subnet::route_hop::RouteHopError::BufferTooSmall);
231        }
232        let seq = self.route_hop_tx_seq.fetch_add(1, Ordering::Relaxed);
233        super::subnet::route_hop::seal_into(
234            out,
235            &self.route_hop_tx_key,
236            self.session_id,
237            seq,
238            header,
239            inner,
240        )
241    }
242
243    /// Allocating form of [`Self::seal_route_hop_into`], for callers
244    /// off the forwarding path.
245    pub fn seal_route_hop(&self, header: &super::route::RoutingHeader, inner: &[u8]) -> Vec<u8> {
246        let seq = self.route_hop_tx_seq.fetch_add(1, Ordering::Relaxed);
247        super::subnet::route_hop::seal(&self.route_hop_tx_key, self.session_id, seq, header, inner)
248    }
249
250    /// Verify an inbound route-hop envelope and admit its sequence
251    /// exactly once.
252    ///
253    /// Returns the opened hop on success. A bad tag is rejected before
254    /// the replay window is touched, so a forged packet cannot burn a
255    /// sequence slot the legitimate peer still needs.
256    pub fn open_route_hop<'a>(
257        &self,
258        buf: &'a [u8],
259    ) -> Result<super::subnet::route_hop::OpenedHop<'a>, super::subnet::route_hop::RouteHopError>
260    {
261        let opened = super::subnet::route_hop::open(&self.route_hop_rx_key, buf)?;
262        self.route_hop_replay.admit(opened.hop_sequence)?;
263        Ok(opened)
264    }
265
266    /// Read the cached peer `NodeId` resolution. Returns `None`
267    /// until the dispatcher's first resolution call publishes a
268    /// value via [`Self::cache_node_id`]. See the field doc for
269    /// the perf rationale.
270    #[inline]
271    pub fn cached_node_id(&self) -> Option<u64> {
272        match self.cached_node_id.load(Ordering::Relaxed) {
273            0 => None,
274            n => Some(n),
275        }
276    }
277
278    /// Publish the resolved peer `NodeId` for subsequent calls.
279    /// Idempotent — concurrent first-resolution callers all write
280    /// the same value (the resolver is deterministic for a given
281    /// `session_id`), so a `store` over an existing identical value
282    /// is correct. Callers should pass non-zero `node_id`; `0` is
283    /// the reserved "unresolved" sentinel and is a no-op.
284    #[inline]
285    pub fn cache_node_id(&self, node_id: u64) {
286        if node_id != 0 {
287            self.cached_node_id.store(node_id, Ordering::Relaxed);
288        }
289    }
290
291    /// Allocate the next sequence number for a subprotocol control
292    /// packet. Uses a session-level counter separate from any
293    /// user stream's sequence space — see `CONTROL_STREAM_ID`.
294    #[inline]
295    pub fn next_control_tx_seq(&self) -> u64 {
296        self.control_tx_seq.fetch_add(1, Ordering::Relaxed)
297    }
298
299    /// Allocate a unique epoch for a freshly-opened stream.
300    ///
301    /// Monotonic per session — a stream closed and reopened gets a
302    /// **new** epoch, which is how stale `Stream` handles and
303    /// `TxSlotGuard`s are prevented from operating on a different
304    /// lifetime of the same `stream_id`.
305    #[inline]
306    fn next_stream_epoch(&self) -> u64 {
307        self.stream_epoch_counter.fetch_add(1, Ordering::Relaxed)
308    }
309
310    /// Get the session ID
311    #[inline]
312    pub fn session_id(&self) -> u64 {
313        self.session_id
314    }
315
316    /// Get the peer address
317    #[inline]
318    pub fn peer_addr(&self) -> SocketAddr {
319        self.peer_addr
320    }
321
322    // No `tx_key()` accessor exists — it would be a public
323    // footgun with no legitimate callers. Any caller using
324    // `session.tx_key()` to construct a fresh `PacketBuilder`
325    // would re-introduce a cross-pool nonce-reuse hazard
326    // (independent counters under the same ChaCha20-Poly1305 key).
327    // All TX-side AEAD operations flow through `thread_local_pool`
328    // via `build_heartbeat` and the normal `send_*` paths.
329
330    /// Get the RX cipher
331    #[inline]
332    pub fn rx_cipher(&self) -> &PacketCipher {
333        &self.rx_cipher
334    }
335
336    /// Get or create stream state
337    pub fn get_or_create_stream(
338        &self,
339        stream_id: u64,
340    ) -> dashmap::mapref::one::RefMut<'_, u64, StreamState> {
341        self.streams
342            .entry(stream_id)
343            .or_insert_with(|| StreamState::new(self.default_reliable))
344    }
345
346    /// Like [`Self::get_or_create_stream`], but the receiver-side stream
347    /// is created reliable when the arriving packet is `RELIABLE`-flagged
348    /// — the sender's reliability is a property of the traffic, not of
349    /// the receiver's `default_reliable`. Without this the auto-created
350    /// receive stream is `FireAndForget` and never builds a NACK, so a
351    /// reliable sender's lost packets are unrecoverable. Only affects the
352    /// reliability mode at first-touch (creation); an existing stream
353    /// keeps its mode.
354    pub fn get_or_create_stream_for_packet(
355        &self,
356        stream_id: u64,
357        reliable: bool,
358    ) -> dashmap::mapref::one::RefMut<'_, u64, StreamState> {
359        self.streams
360            .entry(stream_id)
361            .or_insert_with(|| StreamState::new(reliable))
362    }
363
364    /// Collect retransmit descriptors for every reliable stream whose
365    /// oldest unacked packet has exceeded its RTO. Drives the timeout
366    /// backstop (STREAM_RETRANSMIT D-4) that recovers tail loss — the
367    /// last packets dropped, with no later arrival to trigger a
368    /// receiver NACK. Each call advances the per-packet retry clock, so
369    /// a descriptor isn't re-emitted until another RTO elapses, and a
370    /// packet past `max_retries` is dropped from the window.
371    pub fn collect_timed_out_retransmits(&self) -> Vec<Arc<RetransmitDescriptor>> {
372        let mut out = Vec::new();
373        for entry in self.streams.iter() {
374            let mut due = entry.value().with_reliability(|r| r.get_timed_out());
375            out.append(&mut due);
376        }
377        out
378    }
379
380    /// Collect the per-stream gap report for every stream that
381    /// currently has a gap (H-4 + STREAM_ACK_BATCHING R-4), in ONE
382    /// walk taking each stream's reliability lock exactly once.
383    ///
384    /// The proactive retransmit tick needs two things for a gapped
385    /// stream — the legacy NACK and (for capable peers) the positive
386    /// SACK ranges — and both derive from the same received-range
387    /// index (`build_nack` and `build_ack_ranges` are non-empty under
388    /// the identical `has_gaps()` condition). Snapshotting them under
389    /// one lock keeps a tick's NACK and SACK for a stream mutually
390    /// consistent and halves the tick's per-stream lock/DashMap cost
391    /// versus the old two-walk shape (`collect_gap_nacks` +
392    /// `collect_ack_ranges`).
393    ///
394    /// `ranges` is only built when `want_ranges` (the peer advertises
395    /// the ack-ranges capability); otherwise it is left empty so a
396    /// non-advertising peer pays nothing for the SACK build. Streams
397    /// without gaps contribute nothing — the grant's piggybacked
398    /// `ack_seq` already covers the contiguous case.
399    pub fn collect_gap_reports(&self, want_ranges: bool, max_ranges: usize) -> Vec<GapReport> {
400        let mut out = Vec::new();
401        for entry in self.streams.iter() {
402            let report = entry.value().with_reliability(|r| {
403                r.build_nack().map(|nack| {
404                    let ranges = if want_ranges {
405                        r.build_ack_ranges(max_ranges)
406                    } else {
407                        Vec::new()
408                    };
409                    (nack, r.rx_ack_seq(), ranges)
410                })
411            });
412            if let Some((nack, ack_seq, ranges)) = report {
413                out.push(GapReport {
414                    stream_id: *entry.key(),
415                    nack,
416                    ack_seq,
417                    ranges,
418                });
419            }
420        }
421        out
422    }
423
424    /// Take-and-clear the "given up" flag across all streams, returning
425    /// the ids of streams whose reliable layer exhausted retransmits on
426    /// some packet (H-3). The caller signals a reset to the peer so the
427    /// receiver fails fast instead of stalling to a timeout.
428    pub fn take_failed_stream_ids(&self) -> Vec<u64> {
429        let mut out = Vec::new();
430        for entry in self.streams.iter() {
431            if entry.value().with_reliability(|r| r.take_failed()) {
432                out.push(*entry.key());
433            }
434        }
435        out
436    }
437
438    /// Look up stream state without creating it. Returns `None` if the
439    /// stream was never opened or has been closed.
440    pub fn try_stream(
441        &self,
442        stream_id: u64,
443    ) -> Option<dashmap::mapref::one::Ref<'_, u64, StreamState>> {
444        self.streams.get(&stream_id)
445    }
446
447    /// Try to acquire `bytes` of send credit on `stream_id` with RAII
448    /// refund semantics.
449    ///
450    /// Returns:
451    ///   * [`TxAdmit::Acquired`] with a [`TxSlotGuard`] that refunds
452    ///     `bytes` back to `tx_credit_remaining` when dropped —
453    ///     including on async cancellation, panic, and early return —
454    ///     unless the caller invokes [`TxSlotGuard::commit`] to
455    ///     suppress the refund after a successful socket send. This
456    ///     is the cure for the credit-leak that a plain "decrement /
457    ///     await / maybe-refund" shape would hit when the sending
458    ///     future is dropped mid-`.await` (e.g., `tokio::select!`
459    ///     cancel).
460    ///   * [`TxAdmit::WindowFull`] if `tx_credit_remaining` is below
461    ///     `bytes`. `backpressure_events` has already been bumped.
462    ///   * [`TxAdmit::StreamClosed`] if the stream isn't registered
463    ///     (never opened, closed, or idle-evicted).
464    pub fn try_acquire_tx_credit_guard(self: &Arc<Self>, stream_id: u64, bytes: u32) -> TxAdmit {
465        self.try_acquire_tx_credit_inner(stream_id, None, bytes)
466    }
467
468    /// Like [`Self::try_acquire_tx_credit_guard`], but additionally
469    /// rejects the admission if the live `StreamState`'s epoch
470    /// differs from `expected_epoch`.
471    ///
472    /// Use from the typed-handle `send_on_stream` path so a handle
473    /// held across a close+reopen cycle doesn't admit against the new
474    /// stream's state.
475    pub fn try_acquire_tx_credit_matching_epoch(
476        self: &Arc<Self>,
477        stream_id: u64,
478        expected_epoch: u64,
479        bytes: u32,
480    ) -> TxAdmit {
481        self.try_acquire_tx_credit_inner(stream_id, Some(expected_epoch), bytes)
482    }
483
484    #[expect(
485        clippy::expect_used,
486        reason = "seq is set Some on every code path that reaches the Acquired branch; the if-admitted flow guarantees this"
487    )]
488    fn try_acquire_tx_credit_inner(
489        self: &Arc<Self>,
490        stream_id: u64,
491        expected_epoch: Option<u64>,
492        bytes: u32,
493    ) -> TxAdmit {
494        // Look up the stream and do admission + sequence allocation
495        // under ONE DashMap lookup. Splitting these into two lookups
496        // would allow a close+reopen race in between — credit would
497        // debit the old state while the sequence came from the new
498        // state, cross-contaminating accounting across lifetimes and
499        // defeating the epoch guard.
500        //
501        // Capture the state's epoch so the guard's Drop knows whether
502        // the stream has been reopened in the interim (naive refund
503        // would credit back bytes on the fresh state, which never
504        // saw this acquire).
505        //
506        // Release the DashMap ref before returning so the guard's
507        // Drop doesn't deadlock trying to re-acquire it.
508        let (admitted, epoch, seq) = match self.streams.get(&stream_id) {
509            None => return TxAdmit::StreamClosed,
510            Some(state) => {
511                // Cache `epoch` once — pre-fix [perf #42 in
512                // `docs/internal/performance/net-perf-analysis.md`] the field
513                // was read twice through the `Ref`, once for the
514                // epoch-mismatch check and once on the return tuple.
515                // Trivial field access today but the cache also makes
516                // it obvious that both checks observe the same
517                // snapshot (rather than reading mid-mutation between
518                // the two reads — a defensive read against a future
519                // change that makes `epoch` mutable under `&self`).
520                let current_epoch = state.epoch();
521                if let Some(expected) = expected_epoch {
522                    if current_epoch != expected {
523                        // The handle is stale: the stream was closed
524                        // and reopened since the handle was issued.
525                        // Surface this as StreamClosed so the caller
526                        // maps it to `StreamError::NotConnected`.
527                        return TxAdmit::StreamClosed;
528                    }
529                }
530                let admitted = state.try_acquire_tx_credit(bytes);
531                // Only consume a sequence if admission succeeded —
532                // otherwise we'd waste sequence numbers on rejected
533                // sends.
534                let seq = if admitted {
535                    Some(state.next_tx_seq())
536                } else {
537                    None
538                };
539                (admitted, current_epoch, seq)
540            }
541        };
542        if !admitted {
543            return TxAdmit::WindowFull;
544        }
545        TxAdmit::Acquired {
546            guard: TxSlotGuard {
547                session: Arc::clone(self),
548                stream_id,
549                epoch,
550                bytes,
551                active: true,
552            },
553            seq: seq.expect("seq is Some when admitted is true"),
554        }
555    }
556
557    /// Roll back a TX sequence allocated by
558    /// [`Self::try_acquire_tx_credit_matching_epoch`] when the packet it
559    /// was minted for never reached the wire (scheduler/socket
560    /// backpressure after the seq was consumed). Guarded by `epoch` so a
561    /// close+reopen race can't roll back a sequence on a fresh stream
562    /// state that never issued it — the exact discipline
563    /// [`TxSlotGuard::drop`] uses for the byte-credit refund.
564    ///
565    /// Returns `true` if the sequence was reclaimed (it was the most-
566    /// recently-issued seq and no concurrent send raced ahead), leaving
567    /// no receiver-visible gap; `false` otherwise.
568    pub fn try_rollback_tx_seq(self: &Arc<Self>, stream_id: u64, epoch: u64, seq: u64) -> bool {
569        if let Some(state) = self.try_stream(stream_id) {
570            if state.epoch() == epoch {
571                return state.try_rollback_tx_seq(seq);
572            }
573        }
574        false
575    }
576}
577
578/// Outcome of [`NetSession::try_acquire_tx_credit_matching_epoch`].
579#[derive(Debug)]
580pub enum TxAdmit {
581    /// Admission succeeded; the guard holds the credit until dropped
582    /// or committed. `seq` was allocated under the same DashMap
583    /// lookup as the credit acquire — credit and sequence are
584    /// guaranteed to belong to the same `StreamState` lifetime.
585    Acquired {
586        /// RAII credit holder.
587        guard: TxSlotGuard,
588        /// Sequence number for this send, allocated atomically with
589        /// the admission decision.
590        seq: u64,
591    },
592    /// `tx_credit_remaining` was below the requested bytes. The
593    /// `backpressure_events` counter was incremented as a side effect.
594    WindowFull,
595    /// The stream isn't currently open on this session.
596    StreamClosed,
597}
598
599/// RAII guard holding a byte credit acquired from a stream's
600/// `tx_credit_remaining`.
601///
602/// On `Drop` without a preceding [`Self::commit`], the guard re-looks
603/// up the stream and refunds the credit — the intended slot never
604/// made it onto the wire (socket send cancelled, early return,
605/// panic). After a successful socket send the caller must invoke
606/// `commit()` so the bytes stay consumed; the receiver will replenish
607/// them via a `StreamWindow` grant.
608///
609/// If the stream was closed and reopened before the guard drops, the
610/// refund is suppressed — the credit belonged to a state that no
611/// longer exists.
612pub struct TxSlotGuard {
613    session: Arc<NetSession>,
614    stream_id: u64,
615    /// Epoch of the `StreamState` that admitted this guard.
616    epoch: u64,
617    /// Byte credit this guard holds. Refunded on `Drop` unless
618    /// [`Self::commit`] has cleared `active` first.
619    bytes: u32,
620    active: bool,
621}
622
623impl std::fmt::Debug for TxSlotGuard {
624    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
625        f.debug_struct("TxSlotGuard")
626            .field("stream_id", &format_args!("{:#x}", self.stream_id))
627            .field("epoch", &self.epoch)
628            .field("bytes", &self.bytes)
629            .field("active", &self.active)
630            .finish()
631    }
632}
633
634impl TxSlotGuard {
635    /// Which stream this guard is holding credit on.
636    #[inline]
637    pub fn stream_id(&self) -> u64 {
638        self.stream_id
639    }
640
641    /// Bytes of credit this guard holds.
642    #[inline]
643    pub fn bytes(&self) -> u32 {
644        self.bytes
645    }
646
647    /// Mark the send as committed. The guard's Drop will NOT refund —
648    /// the bytes are now the receiver's to credit back via a
649    /// `StreamWindow` grant.
650    #[inline]
651    pub fn commit(mut self) {
652        self.active = false;
653    }
654
655    /// Consume the guard without refunding. Used by tests that want
656    /// to simulate a leaked slot; production code should prefer
657    /// `commit`.
658    #[doc(hidden)]
659    pub fn forget(mut self) {
660        self.active = false;
661    }
662}
663
664impl Drop for TxSlotGuard {
665    fn drop(&mut self) {
666        if !self.active {
667            return;
668        }
669        if let Some(state) = self.session.try_stream(self.stream_id) {
670            // Only refund if the live state is the same state that
671            // admitted us. After a close+reopen the new state has a
672            // different epoch — refunding would spuriously credit
673            // bytes on a slot we never acquired.
674            if state.epoch() == self.epoch {
675                state.refund_tx_credit(self.bytes);
676            }
677        }
678    }
679}
680
681impl NetSession {
682    /// Open a stream with an explicit reliability mode and fair-scheduler
683    /// weight.
684    ///
685    /// Idempotent: if the stream already exists, this is a no-op and the
686    /// caller's config is **ignored with a warning log** — the first open
687    /// wins. Callers that want to change a stream's config must close +
688    /// re-open it.
689    pub fn open_stream_with(&self, stream_id: u64, reliable: bool, fairness_weight: u8) -> u64 {
690        // Inherit `DEFAULT_STREAM_WINDOW_BYTES` so callers that go
691        // through this convenience wrapper (notably `publish_to_peer`)
692        // pick up v2 backpressure by default. Callers that want the
693        // v1-style unbounded-queue behavior use `open_stream_full`
694        // with `tx_window = 0` explicitly.
695        self.open_stream_full(
696            stream_id,
697            reliable,
698            fairness_weight,
699            DEFAULT_STREAM_WINDOW_BYTES,
700        )
701    }
702
703    /// Extended open that also sets the per-stream TX window for
704    /// backpressure. `tx_window == 0` keeps the pre-backpressure
705    /// behavior (unbounded local queue).
706    ///
707    /// Returns the epoch of the live `StreamState` for `stream_id` —
708    /// either the fresh one created for a new stream, or the existing
709    /// one if the stream is already open (first-open-wins). Callers
710    /// embed this in their `Stream` handle so later sends can reject
711    /// stale handles after close+reopen.
712    pub fn open_stream_full(
713        &self,
714        stream_id: u64,
715        reliable: bool,
716        fairness_weight: u8,
717        tx_window: u32,
718    ) -> u64 {
719        // First-open-wins: warn when a caller's config disagrees
720        // with the live stream's. Shared by the read-probe hit and
721        // the lost-creation-race Occupied arm below so both
722        // occupied shapes keep the pre-§2.12 warning behavior.
723        fn warn_if_config_conflicts(
724            existing: &StreamState,
725            stream_id: u64,
726            reliable: bool,
727            fairness_weight: u8,
728            tx_window: u32,
729        ) {
730            if existing.reliable_mode() != reliable
731                || existing.fairness_weight() != fairness_weight.max(1)
732                || existing.tx_window() != tx_window
733            {
734                tracing::warn!(
735                    stream_id = format!("{:#x}", stream_id),
736                    existing_reliable = existing.reliable_mode(),
737                    new_reliable = reliable,
738                    existing_weight = existing.fairness_weight(),
739                    new_weight = fairness_weight,
740                    existing_tx_window = existing.tx_window(),
741                    new_tx_window = tx_window,
742                    "open_stream: ignoring conflicting config; first open wins"
743                );
744            }
745        }
746
747        // PERF_AUDIT §2.12 — read-only fast path. `publish_to_peer`
748        // calls `open_stream_with` on every publish, but the stream
749        // is almost always already open after the first call —
750        // pre-fix this took a DashMap write `entry()` lock per
751        // publish just to land in the Occupied arm and return the
752        // existing epoch. The `get` probe holds a read lock so
753        // concurrent opens on other streams don't contend.
754        if let Some(existing_ref) = self.streams.get(&stream_id) {
755            let existing = existing_ref.value();
756            warn_if_config_conflicts(existing, stream_id, reliable, fairness_weight, tx_window);
757            return existing.epoch();
758        }
759        // Slow path: stream missing. Take the write lock and
760        // either create the entry or pick up a concurrent
761        // creator's epoch on the race.
762        use dashmap::mapref::entry::Entry;
763        match self.streams.entry(stream_id) {
764            Entry::Occupied(existing) => {
765                // Lost the creation race to a concurrent opener —
766                // same first-open-wins semantics (and the same
767                // conflict warning) as the read-probe hit above.
768                let existing = existing.get();
769                warn_if_config_conflicts(existing, stream_id, reliable, fairness_weight, tx_window);
770                existing.epoch()
771            }
772            Entry::Vacant(v) => {
773                let epoch = self.next_stream_epoch();
774                v.insert(StreamState::new_full_with_epoch(
775                    reliable,
776                    fairness_weight,
777                    tx_window,
778                    epoch,
779                ));
780                epoch
781            }
782        }
783    }
784
785    /// Close a stream: mark it inactive and remove its state.
786    ///
787    /// Idempotent — closing a non-existent stream is a no-op. After
788    /// close, a subsequent `open_stream_with` creates a fresh stream.
789    ///
790    /// Also records `stream_id` in the grant-quarantine set so that
791    /// any `StreamWindow` grant still in flight from a peer who was
792    /// communicating with the just-closed lifetime is dropped rather
793    /// than spuriously crediting a later reopen — see
794    /// `GRANT_QUARANTINE_WINDOW` and [`Self::is_grant_quarantined`].
795    pub fn close_stream(&self, stream_id: u64) {
796        if let Some((_, state)) = self.streams.remove(&stream_id) {
797            state.deactivate();
798            self.recently_closed.insert(stream_id, Instant::now());
799        }
800    }
801
802    /// Whether a `StreamWindow` grant for `stream_id` should be
803    /// dropped because the stream was closed within
804    /// `GRANT_QUARANTINE_WINDOW`. Lazily garbage-collects expired
805    /// entries on call.
806    pub fn is_grant_quarantined(&self, stream_id: u64) -> bool {
807        let elapsed = match self.recently_closed.get(&stream_id) {
808            Some(entry) => entry.value().elapsed(),
809            None => return false,
810        };
811        if elapsed < GRANT_QUARANTINE_WINDOW {
812            return true;
813        }
814        // Entry is past the window — clean it up so the map doesn't
815        // grow with stale ids.
816        self.recently_closed.remove(&stream_id);
817        false
818    }
819
820    /// Remove streams whose `last_activity` is older than `max_idle`,
821    /// keeping the active count at or below `max_streams` by LRU-evicting
822    /// the oldest if still over cap. Returns the number of streams
823    /// evicted. Called from the session owner's heartbeat loop.
824    pub fn evict_idle_streams(
825        &self,
826        max_idle: Duration,
827        max_streams: usize,
828        reason_tag: &'static str,
829    ) -> usize {
830        let mut evicted = 0;
831        let now = current_timestamp();
832        let max_idle_ns = u64::try_from(max_idle.as_nanos()).unwrap_or(u64::MAX);
833
834        // Pass 1: drop idle streams.
835        let idle: Vec<u64> = self
836            .streams
837            .iter()
838            .filter(|e| now.saturating_sub(e.value().last_activity_ns()) > max_idle_ns)
839            .map(|e| *e.key())
840            .collect();
841        for sid in idle {
842            if let Some((_, state)) = self.streams.remove(&sid) {
843                state.deactivate();
844                self.recently_closed.insert(sid, Instant::now());
845                evicted += 1;
846                tracing::debug!(
847                    stream_id = format!("{:#x}", sid),
848                    reason = reason_tag,
849                    "stream evicted: idle timeout"
850                );
851            }
852        }
853
854        // Pass 2: if still over the cap, LRU-evict the oldest.
855        //
856        // The (key, last_activity) pair is captured in the same
857        // iteration that selects the victim, then `remove_if`
858        // re-checks the activity stamp atomically before
859        // removing. If a concurrent `open_stream_full` reused the
860        // same `stream_id` slot or `touch`-ed it between selection
861        // and removal, the stamp differs and we skip the eviction
862        // for this round (it'll be re-evaluated on the next sweep
863        // if the cap is still exceeded). Pre-fix the iter then
864        // remove pair was non-atomic, so a freshly-opened stream
865        // could be torn down in the gap between selection and
866        // removal — observed as "stream just opened, immediately
867        // closed" in production logs.
868        while self.streams.len() > max_streams {
869            let oldest = self
870                .streams
871                .iter()
872                .min_by_key(|e| e.value().last_activity_ns())
873                .map(|e| (*e.key(), e.value().last_activity_ns()));
874            match oldest {
875                Some((sid, expected_activity_ns)) => {
876                    let removed = self
877                        .streams
878                        .remove_if(&sid, |_, v| v.last_activity_ns() == expected_activity_ns);
879                    match removed {
880                        Some((_, state)) => {
881                            state.deactivate();
882                            self.recently_closed.insert(sid, Instant::now());
883                            evicted += 1;
884                            tracing::warn!(
885                                stream_id = format!("{:#x}", sid),
886                                reason = "cap_exceeded",
887                                total_streams = self.streams.len(),
888                                max_streams = max_streams,
889                                "stream evicted: max_streams cap"
890                            );
891                        }
892                        None => {
893                            // The stream was touched / replaced
894                            // between selection and removal. Pick a
895                            // new victim on the next loop iteration.
896                            // Bail if the cap is no longer exceeded,
897                            // otherwise the loop terminates anyway.
898                            continue;
899                        }
900                    }
901                }
902                None => break,
903            }
904        }
905
906        // Piggyback on this idle-stream sweep: drop any
907        // `recently_closed` entry whose insertion time is past
908        // `GRANT_QUARANTINE_WINDOW`. Without this sweep,
909        // `recently_closed` would only get GC'd by
910        // `is_grant_quarantined`, which is called only when an
911        // inbound `StreamWindow` grant arrives for that exact
912        // `stream_id`. A long-lived peer that opens/closes many
913        // distinct stream IDs (e.g., one short-lived stream per
914        // RPC) and never receives a late grant for each closed
915        // stream would accumulate one entry per closed stream
916        // forever — N streams/sec → ~N×T entries after T seconds,
917        // unbounded. The sweep itself is bounded by the existing
918        // eviction cadence so there's no extra wakeup cost.
919        self.recently_closed
920            .retain(|_, inserted_at| inserted_at.elapsed() < GRANT_QUARANTINE_WINDOW);
921
922        evicted
923    }
924
925    /// Get stream state (read-only)
926    pub fn get_stream(
927        &self,
928        stream_id: u64,
929    ) -> Option<dashmap::mapref::one::Ref<'_, u64, StreamState>> {
930        self.streams.get(&stream_id)
931    }
932
933    /// Get the thread-local pool for zero-contention packet building
934    #[inline]
935    pub fn thread_local_pool(&self) -> &SharedLocalPool {
936        &self.thread_local_pool
937    }
938
939    /// Build an AEAD-authenticated heartbeat packet for this session.
940    ///
941    /// Routes through `thread_local_pool` so the heartbeat shares
942    /// its TX counter with data-path packets — heartbeats and data
943    /// interleave cleanly on the wire, and the receiver's replay
944    /// window admits them in either order.
945    ///
946    /// Wrapping heartbeat construction in this method removes the
947    /// surface that would otherwise let callers build heartbeats
948    /// with a fresh `PacketBuilder::new(&[0u8; 32], session_id)`,
949    /// which (a) would use the wrong key so the receiver's AEAD
950    /// verify would reject every heartbeat, and (b) would reuse
951    /// counter=0 across successive heartbeats so the replay window
952    /// would reject every heartbeat after the first.
953    #[inline]
954    pub fn build_heartbeat(&self) -> Bytes {
955        self.thread_local_pool.get().build_heartbeat()
956    }
957
958    /// Verify an inbound heartbeat's AEAD tag against this session's
959    /// RX cipher, commit the counter into the replay window, and
960    /// refresh `last_activity`. Returns `true` if the packet was
961    /// accepted; the session is mutated only on success.
962    ///
963    /// Verify and touch are fused into a single call so callers
964    /// cannot get the order wrong (verify-then-touch, never the
965    /// reverse) or forget to touch (which would defeat session
966    /// idle-timeout for legitimate heartbeats).
967    ///
968    /// Source-address validation (legacy adapter: 1:1 source per
969    /// session) and any post-accept observation (mesh:
970    /// `failure_detector.heartbeat`) remain the caller's
971    /// responsibility — those policies vary by adapter and don't
972    /// belong inside the helper.
973    ///
974    /// Heartbeats MUST decrypt the AEAD tag rather than be fast-
975    /// pathed through to `failure_detector.heartbeat` and
976    /// `session.touch()` based on `is_heartbeat()` alone — without
977    /// the decrypt step, an off-path attacker who observed the
978    /// cleartext `session_id` and source UDP address could spoof
979    /// heartbeats indefinitely.
980    pub fn verify_and_touch_heartbeat(&self, parsed: &ParsedPacket) -> bool {
981        // A heartbeat encrypts an empty payload, so the on-wire
982        // ciphertext is exactly the 16-byte AEAD tag (see
983        // `PacketBuilder::build_heartbeat`). Reject any other
984        // length BEFORE invoking the cipher: the AEAD will
985        // catch a length mismatch on its own, but a cheap
986        // up-front check shortcuts a cleartext-flood attacker
987        // who sends short / empty / oversized packets to drain
988        // CPU on the decrypt path. ChaCha20-Poly1305 isn't
989        // hugely expensive per packet, but the gate is free
990        // and removes the cipher from the per-probe budget.
991        if parsed.payload.len() != super::protocol::TAG_SIZE {
992            return false;
993        }
994        let aad = parsed.header.aad();
995        let counter = u64::from_le_bytes(parsed.header.nonce[4..12].try_into().unwrap_or([0u8; 8]));
996        // Per crypto-session perf #129, route through the
997        // verify-only API: heartbeats encrypt an empty plaintext
998        // to a 16-byte Poly1305 tag, and the legacy
999        // `decrypt(...).is_err()` materialized that empty
1000        // plaintext into a fresh `Vec<u8>` per call only to drop
1001        // it. `verify` runs the AEAD tag check without producing
1002        // a plaintext buffer.
1003        if self
1004            .rx_cipher
1005            .verify(counter, &aad, &parsed.payload)
1006            .is_err()
1007        {
1008            return false;
1009        }
1010        // Per crypto-session perf #132: single-lock admit replaces
1011        // the legacy `is_valid_rx_counter` (pre-verify) +
1012        // `update_rx_counter` (post-verify) two-step. Heartbeat
1013        // replays now pay the AEAD verify before being rejected at
1014        // admit, but the AEAD verify on a 16-byte heartbeat is the
1015        // cheapest case of ChaCha20-Poly1305 and the saved Mutex
1016        // op per non-replay heartbeat (which dominates the rate at
1017        // healthy steady state) is the actual hot path.
1018        if !self.rx_cipher.try_admit_rx_counter(counter) {
1019            return false;
1020        }
1021        self.touch();
1022        true
1023    }
1024
1025    /// Update last activity timestamp
1026    #[inline]
1027    pub fn touch(&self) {
1028        self.last_activity
1029            .store(current_timestamp(), Ordering::Release);
1030    }
1031
1032    /// Nanoseconds since epoch of the last activity. Useful for
1033    /// tests / diagnostics that need to observe whether `touch`
1034    /// has been called.
1035    #[inline]
1036    pub fn last_activity_ns(&self) -> u64 {
1037        self.last_activity.load(Ordering::Acquire)
1038    }
1039
1040    /// Check if session has timed out
1041    #[inline]
1042    pub fn is_timed_out(&self, timeout: Duration) -> bool {
1043        let last = self.last_activity.load(Ordering::Acquire);
1044        let now = current_timestamp();
1045        let timeout_ns = u64::try_from(timeout.as_nanos()).unwrap_or(u64::MAX);
1046        now.saturating_sub(last) > timeout_ns
1047    }
1048
1049    /// Check if session is active
1050    #[inline]
1051    pub fn is_active(&self) -> bool {
1052        self.active.load(Ordering::Acquire)
1053    }
1054
1055    /// Deactivate the session
1056    #[inline]
1057    pub fn deactivate(&self) {
1058        self.active.store(false, Ordering::Release);
1059    }
1060
1061    /// Get all stream IDs
1062    pub fn stream_ids(&self) -> Vec<u64> {
1063        self.streams.iter().map(|r| *r.key()).collect()
1064    }
1065
1066    /// Get the number of streams
1067    pub fn stream_count(&self) -> usize {
1068        self.streams.len()
1069    }
1070
1071    /// `true` if any application stream is currently open on this
1072    /// session. Control-plane traffic rides a separate sequence space
1073    /// and is not counted. Used by the NAT-traversal direct-path
1074    /// upgrade's busy gate (`NAT_TRAVERSAL_V2_PLAN.md` C3): a session
1075    /// carrying live streams must not be swapped out from under them.
1076    pub fn has_open_streams(&self) -> bool {
1077        !self.streams.is_empty()
1078    }
1079
1080    /// `true` if any stream on this session has unacked in-flight
1081    /// reliable data (a non-empty retransmit window). Walks the live
1082    /// streams and short-circuits on the first with pending packets.
1083    /// Companion to [`Self::has_open_streams`] for the upgrade busy
1084    /// gate — swapping the session would drop this in-flight data with
1085    /// no retransmit on the new session.
1086    pub fn has_unacked(&self) -> bool {
1087        self.streams
1088            .iter()
1089            .any(|entry| entry.value().with_reliability(|r| r.has_pending()))
1090    }
1091}
1092
1093impl std::fmt::Debug for NetSession {
1094    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1095        f.debug_struct("NetSession")
1096            .field("session_id", &format!("{:016x}", self.session_id))
1097            .field("peer_addr", &self.peer_addr)
1098            .field("stream_count", &self.streams.len())
1099            .field("active", &self.active.load(Ordering::Relaxed))
1100            .finish()
1101    }
1102}
1103
1104/// Per-stream state for multiplexing.
1105pub struct StreamState {
1106    /// Next sequence number to send
1107    tx_seq: AtomicU64,
1108    /// Last received sequence number
1109    rx_seq: AtomicU64,
1110    /// Reliability mode for this stream
1111    reliability: parking_lot::Mutex<Box<dyn ReliabilityMode>>,
1112    /// Inbound event queue (for poll_shard)
1113    inbound: SegQueue<StoredEvent>,
1114    /// Stream is active
1115    active: AtomicBool,
1116    /// Nanoseconds since epoch of the last activity (send or receive).
1117    /// Used by the session's idle-eviction sweep.
1118    last_activity: AtomicU64,
1119    /// Reliability mode this stream was created with. Stored so
1120    /// `open_stream` can warn when a caller re-opens with a different
1121    /// config (config is immutable for the stream's lifetime).
1122    reliable_mode: bool,
1123    /// Fair-scheduler quantum multiplier (1 = equal share).
1124    fairness_weight: u8,
1125    /// Configured initial credit window in **bytes** for this stream's
1126    /// send path. `0` disables backpressure entirely (v1 "unbounded"
1127    /// escape hatch). Non-zero: `tx_credit_remaining` starts here and
1128    /// is decremented on each socket send.
1129    tx_window: u32,
1130    /// Bytes of send credit the sender may still use on this stream
1131    /// before `send_on_stream` returns `StreamError::Backpressure`.
1132    /// Decremented on each socket send (atomic CAS). Recomputed
1133    /// authoritatively from `tx_bytes_sent - max_consumed_seen` on
1134    /// every inbound `StreamWindow` grant. When `tx_window == 0`,
1135    /// admission short-circuits and this counter is not consulted.
1136    tx_credit_remaining: AtomicU32,
1137    /// Cumulative bytes this sender has committed to the wire on
1138    /// this stream, across all lifetime credit acquisitions. Bumped
1139    /// when `try_acquire_tx_credit` admits; rolled back when a
1140    /// guard drops without commit (refund). The grant handler
1141    /// reconciles `tx_credit_remaining` against this and
1142    /// `max_consumed_seen`, so lost grants self-heal on the next
1143    /// grant arrival.
1144    tx_bytes_sent: AtomicU64,
1145    /// Highest `total_consumed` observed from the receiver on this
1146    /// stream. Monotonic — out-of-order / duplicate grants are
1147    /// ignored. Updated under CAS to protect the monotonicity
1148    /// invariant against concurrent grant-dispatch tasks.
1149    max_consumed_seen: AtomicU64,
1150    /// Number of `send_on_stream` calls that returned
1151    /// `StreamError::Backpressure` since this stream opened.
1152    backpressure_events: AtomicU64,
1153    /// Cumulative `StreamWindow` grants received on this stream
1154    /// (sender side). Does not count bytes — counts grant packets.
1155    credit_grants_received: AtomicU64,
1156    /// Cumulative `StreamWindow` grants emitted on this stream
1157    /// (receiver side). Counts grant packets, not bytes.
1158    credit_grants_sent: AtomicU64,
1159    /// Receive-side credit bookkeeping. See [`RxCreditState`].
1160    rx_credit: RxCreditState,
1161    /// Monotonic epoch issued by the owning `NetSession` at open time.
1162    /// Close + reopen of the same `stream_id` produces a fresh
1163    /// `StreamState` with a new epoch; stale `Stream` handles and
1164    /// `TxSlotGuard`s must fail an equality check against this value
1165    /// before acting on the state.
1166    ///
1167    /// `0` is the "no epoch recorded" sentinel for legacy paths
1168    /// (`get_or_create_stream`, `send_to_peer` / `send_routed`) that
1169    /// don't go through the typed handle API.
1170    epoch: u64,
1171}
1172
1173/// Receive-side credit bookkeeping for the v2 round-trip window.
1174///
1175/// Tracks how much credit this receiver has extended to the sender
1176/// vs how much it has "consumed" (accepted off the wire).
1177///
1178/// **Accounting cadence:** this is receive-time accounting, NOT
1179/// application-drain accounting. Every accepted packet calls
1180/// [`Self::on_bytes_consumed`] from
1181/// the dispatch loop (`mesh.rs::process_local_packet`), which
1182/// bumps both `consumed` and `granted` by the on-wire byte
1183/// count. The "outstanding" credit (`granted - consumed`)
1184/// therefore stays pinned at the initial window — every byte
1185/// received is paired with a matching grant.
1186///
1187/// This shape exists to close the v1 io::Error-on-full-kernel-
1188/// buffer gap (a single serial sender used to run
1189/// `Transport(io::Error)` into a full kernel buffer). Per-stream
1190/// kernel-buffer protection comes from the round-trip grant
1191/// loop; per-application throttling comes from a separate
1192/// mechanism (per-shard queue-depth limits).
1193///
1194/// An earlier version of this docstring described a
1195/// threshold-emit pattern ("when outstanding dips below half
1196/// the window, a grant is emitted"). That description didn't
1197/// match the implementation and contradicted the v2 design
1198/// goal — it has been superseded by the description above.
1199///
1200/// `window_bytes` is the per-grant chunk size — also the size of the
1201/// sender's implicit initial window at open time. `0` disables
1202/// receive-side bookkeeping entirely (matches the "unbounded" sender
1203/// escape hatch).
1204pub struct RxCreditState {
1205    /// Total credit granted to the sender since stream open, including
1206    /// the implicit initial window. Saturating u64 — 2^64 bytes is
1207    /// ~18 exabytes, no realistic workload wraps.
1208    granted: AtomicU64,
1209    /// Total inbound bytes this receiver has accepted. Incremented on
1210    /// the receive path as packets land on this stream. Invariant:
1211    /// `consumed <= granted` (unless the sender overshoots the initial
1212    /// window before the first grant — recoverable transient).
1213    consumed: AtomicU64,
1214    /// Per-grant chunk size (bytes). Equal to the sender's initial
1215    /// window at open time. Used by the caller to size grant emission
1216    /// — see [`Self::on_bytes_consumed`]. `0` disables emission
1217    /// (the v1 unbounded escape hatch).
1218    window_bytes: u32,
1219}
1220
1221impl RxCreditState {
1222    fn new(window_bytes: u32) -> Self {
1223        Self {
1224            // Prime `granted` with the implicit initial window —
1225            // matches the sender's starting `tx_credit_remaining`, so
1226            // the first `on_bytes_consumed` calls reduce "outstanding"
1227            // rather than go negative.
1228            granted: AtomicU64::new(window_bytes as u64),
1229            consumed: AtomicU64::new(0),
1230            window_bytes,
1231        }
1232    }
1233
1234    /// Bytes of credit outstanding — what the sender believes it can
1235    /// still send before hitting backpressure, from this receiver's
1236    /// local view.
1237    #[inline]
1238    pub fn outstanding(&self) -> u64 {
1239        // Read `consumed` first, then `granted`. Paired with the
1240        // publication order in `on_bytes_consumed` (granted first,
1241        // then consumed), this guarantees `granted >= consumed`:
1242        // if our `consumed` load observes a writer's increment, the
1243        // writer's earlier `granted` increment is already visible to
1244        // our subsequent `granted` load. Pre-fix the loads ran in
1245        // the opposite order and `saturating_sub` masked transient
1246        // `consumed > granted` to zero, surfacing a false "no
1247        // outstanding bytes" reading to metrics during contention.
1248        let c = self.consumed.load(Ordering::Acquire);
1249        let g = self.granted.load(Ordering::Acquire);
1250        g.saturating_sub(c)
1251    }
1252
1253    /// Total bytes consumed since stream open.
1254    #[inline]
1255    pub fn consumed(&self) -> u64 {
1256        self.consumed.load(Ordering::Acquire)
1257    }
1258
1259    /// Total bytes granted (including the implicit initial window).
1260    #[inline]
1261    pub fn granted(&self) -> u64 {
1262        self.granted.load(Ordering::Acquire)
1263    }
1264
1265    /// Per-grant chunk size this receiver extends.
1266    #[inline]
1267    pub fn window_bytes(&self) -> u32 {
1268        self.window_bytes
1269    }
1270
1271    /// Record `bytes` consumed off the wire and return the receiver's
1272    /// new cumulative consumed-byte count, which the caller ships as
1273    /// the `total_consumed` field of an authoritative `StreamWindow`
1274    /// grant. Returns `None` when receive-side bookkeeping is
1275    /// disabled (`window_bytes == 0`).
1276    ///
1277    /// Authoritative grants are self-healing: each grant carries the
1278    /// receiver's full picture, so a single lost grant is reconciled
1279    /// by the next one. That's what keeps the sender's credit from
1280    /// permanently draining when data packets OR grants are dropped
1281    /// on the wire. One grant per inbound packet is the simplest
1282    /// cadence; on lossy links the receiver may emit more frequently,
1283    /// and a future enhancement can batch grants without changing
1284    /// the wire format.
1285    pub fn on_bytes_consumed(&self, bytes: u64) -> Option<u64> {
1286        if self.window_bytes == 0 {
1287            return None;
1288        }
1289        // The v2 design intentionally accounts at receive time
1290        // (not application-drain time) — see `mesh.rs:3110-3135`
1291        // ("Accounting runs at receive time (not drain time); this
1292        // closes the v1 gap where a single serial sender ran
1293        // `Transport(io::Error)` into a full kernel buffer"). The
1294        // credit window is for kernel-buffer protection, not
1295        // application-side throttling; the latter is provided by
1296        // per-shard queue-depth limits.
1297        //
1298        // Every call mints a matching grant of `bytes`, returning
1299        // the running cumulative consumed count for the caller to
1300        // ship as `total_consumed` in an authoritative
1301        // `StreamWindow` packet.
1302        //
1303        // Order matters: bump `granted` BEFORE `consumed` so a
1304        // concurrent `outstanding()` reader that observes the new
1305        // `consumed` is guaranteed to see the matching `granted`
1306        // bump as well. With the opposite order, the reader's
1307        // computation `granted - consumed` could transiently see
1308        // `consumed > granted` (saturated to zero), surfacing a
1309        // false "window drained" snapshot to metrics under
1310        // contention.
1311        self.granted.fetch_add(bytes, Ordering::AcqRel);
1312        let new_consumed = self.consumed.fetch_add(bytes, Ordering::AcqRel) + bytes;
1313        Some(new_consumed)
1314    }
1315}
1316
1317impl std::fmt::Debug for RxCreditState {
1318    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1319        f.debug_struct("RxCreditState")
1320            .field("granted", &self.granted.load(Ordering::Relaxed))
1321            .field("consumed", &self.consumed.load(Ordering::Relaxed))
1322            .field("window_bytes", &self.window_bytes)
1323            .finish()
1324    }
1325}
1326
1327impl StreamState {
1328    /// Create a new stream state
1329    pub fn new(reliable: bool) -> Self {
1330        Self::new_with_weight(reliable, 1)
1331    }
1332
1333    /// Create a new stream state with a fair-scheduler weight.
1334    ///
1335    /// Uses [`DEFAULT_STREAM_WINDOW_BYTES`] for the initial credit
1336    /// window — auto-created receive-side streams (via
1337    /// `get_or_create_stream`) inherit the default so
1338    /// `RxCreditState` can mint grants on threshold crossings.
1339    /// Callers that need a specific window go through
1340    /// [`Self::new_full`].
1341    pub fn new_with_weight(reliable: bool, fairness_weight: u8) -> Self {
1342        Self::new_full(reliable, fairness_weight, DEFAULT_STREAM_WINDOW_BYTES)
1343    }
1344
1345    /// Create a new stream state with full config (weight + tx window).
1346    /// Epoch defaults to `0` (the "no epoch" sentinel used by legacy
1347    /// auto-create paths); sessions that go through `open_stream_full`
1348    /// allocate a fresh epoch via [`Self::new_full_with_epoch`].
1349    pub fn new_full(reliable: bool, fairness_weight: u8, tx_window: u32) -> Self {
1350        Self::new_full_with_epoch(reliable, fairness_weight, tx_window, 0)
1351    }
1352
1353    /// Create a new stream state with a caller-supplied epoch.
1354    ///
1355    /// Sessions call this via `open_stream_full` with a monotonic
1356    /// epoch; stale `Stream` handles / `TxSlotGuard`s from a prior
1357    /// close/reopen cycle will fail the epoch check against the new
1358    /// state.
1359    pub fn new_full_with_epoch(
1360        reliable: bool,
1361        fairness_weight: u8,
1362        tx_window: u32,
1363        epoch: u64,
1364    ) -> Self {
1365        // Size the retransmit window to the tx-credit window so the
1366        // sender can never have more packets in flight than it can
1367        // retransmit (H-1). Cheap: `pending` grows on demand, so a large
1368        // window costs no up-front memory.
1369        let max_pending = ReliableStream::max_pending_for_window(tx_window);
1370        Self {
1371            tx_seq: AtomicU64::new(0),
1372            rx_seq: AtomicU64::new(0),
1373            reliability: parking_lot::Mutex::new(create_reliability_mode(reliable, max_pending)),
1374            inbound: SegQueue::new(),
1375            active: AtomicBool::new(true),
1376            last_activity: AtomicU64::new(current_timestamp()),
1377            reliable_mode: reliable,
1378            fairness_weight: fairness_weight.max(1),
1379            tx_window,
1380            // Implicit initial window: the sender starts with full
1381            // credit so the first send doesn't eat a handshake round
1382            // trip.
1383            tx_credit_remaining: AtomicU32::new(tx_window),
1384            tx_bytes_sent: AtomicU64::new(0),
1385            max_consumed_seen: AtomicU64::new(0),
1386            backpressure_events: AtomicU64::new(0),
1387            credit_grants_received: AtomicU64::new(0),
1388            credit_grants_sent: AtomicU64::new(0),
1389            rx_credit: RxCreditState::new(tx_window),
1390            epoch,
1391        }
1392    }
1393
1394    /// Refresh last-activity timestamp. Called on every send and on
1395    /// every receive that lands packets/events into the stream.
1396    #[inline]
1397    pub fn touch(&self) {
1398        self.last_activity
1399            .store(current_timestamp(), Ordering::Release);
1400    }
1401
1402    /// Nanoseconds since epoch of the last activity.
1403    #[inline]
1404    pub fn last_activity_ns(&self) -> u64 {
1405        self.last_activity.load(Ordering::Acquire)
1406    }
1407
1408    /// Reliability mode this stream was created with.
1409    #[inline]
1410    pub fn reliable_mode(&self) -> bool {
1411        self.reliable_mode
1412    }
1413
1414    /// Fair-scheduler weight for this stream.
1415    #[inline]
1416    pub fn fairness_weight(&self) -> u8 {
1417        self.fairness_weight
1418    }
1419
1420    /// Monotonic per-session epoch captured at construction time.
1421    /// `0` means "no epoch recorded" (legacy auto-create path).
1422    #[inline]
1423    pub fn epoch(&self) -> u64 {
1424        self.epoch
1425    }
1426
1427    /// Configured initial credit window in bytes. `0` means "no limit"
1428    /// — backpressure is disabled for this stream (v1 escape hatch).
1429    #[inline]
1430    pub fn tx_window(&self) -> u32 {
1431        self.tx_window
1432    }
1433
1434    /// Current remaining send credit in bytes. Approaches `0` as the
1435    /// sender pushes packets without a corresponding receiver grant;
1436    /// the next acquire at `0` returns Backpressure.
1437    #[inline]
1438    pub fn tx_credit_remaining(&self) -> u32 {
1439        self.tx_credit_remaining.load(Ordering::Acquire)
1440    }
1441
1442    /// Cumulative number of Backpressure rejections since the stream opened.
1443    #[inline]
1444    pub fn backpressure_events(&self) -> u64 {
1445        self.backpressure_events.load(Ordering::Relaxed)
1446    }
1447
1448    /// Cumulative `StreamWindow` grants received on this stream.
1449    #[inline]
1450    pub fn credit_grants_received(&self) -> u64 {
1451        self.credit_grants_received.load(Ordering::Relaxed)
1452    }
1453
1454    /// Cumulative `StreamWindow` grants emitted on this stream.
1455    #[inline]
1456    pub fn credit_grants_sent(&self) -> u64 {
1457        self.credit_grants_sent.load(Ordering::Relaxed)
1458    }
1459
1460    /// Access the receive-side credit bookkeeping.
1461    #[inline]
1462    pub fn rx_credit(&self) -> &RxCreditState {
1463        &self.rx_credit
1464    }
1465
1466    /// Try to acquire `bytes` of send credit via a CAS loop.
1467    ///
1468    /// Returns `true` on success — `tx_credit_remaining` is
1469    /// decremented and `tx_bytes_sent` is bumped so the
1470    /// authoritative-grant reconciliation sees a consistent view.
1471    /// Returns `false` when remaining credit is below `bytes`;
1472    /// caller returns `StreamError::Backpressure` and the rejection
1473    /// counter bumps.
1474    ///
1475    /// `tx_window == 0` disables the check; all requests admit and
1476    /// the counter is not touched.
1477    pub fn try_acquire_tx_credit(&self, bytes: u32) -> bool {
1478        if self.tx_window == 0 {
1479            return true;
1480        }
1481        loop {
1482            let cur = self.tx_credit_remaining.load(Ordering::Acquire);
1483            if cur < bytes {
1484                self.backpressure_events.fetch_add(1, Ordering::Relaxed);
1485                return false;
1486            }
1487            if self
1488                .tx_credit_remaining
1489                .compare_exchange_weak(cur, cur - bytes, Ordering::AcqRel, Ordering::Acquire)
1490                .is_ok()
1491            {
1492                // Bump the committed-bytes counter only after the
1493                // CAS wins. The reverse order (bump then CAS) lets
1494                // a concurrent grant observe the bumped watermark,
1495                // mint credit up to the window, and then the
1496                // pending admission's CAS subtracts that credit —
1497                // net loss of one unit per grant-vs-admission race.
1498                // The narrow truncation window the audit highlighted
1499                // (#97) is self-healing via the next grant; the
1500                // window-invariant violation in the alternative
1501                // ordering is not.
1502                self.tx_bytes_sent
1503                    .fetch_add(bytes as u64, Ordering::Relaxed);
1504                return true;
1505            }
1506            // CAS lost — retry with the fresh value.
1507        }
1508    }
1509
1510    /// Refund `bytes` of send credit. Called by `TxSlotGuard::drop`
1511    /// when a previously acquired slot never made it to the wire
1512    /// (socket send cancelled, early return, etc.). Rolls back both
1513    /// `tx_credit_remaining` and the `tx_bytes_sent` bump recorded at
1514    /// admission — the bytes never left the sender, so neither
1515    /// counter should reflect them. No clamp at `tx_window`: grants
1516    /// may have pushed the counter past the initial window, and
1517    /// refunding those bytes back to a `tx_window` ceiling would
1518    /// strand legitimately-granted credit.
1519    pub fn refund_tx_credit(&self, bytes: u32) {
1520        if self.tx_window == 0 {
1521            return;
1522        }
1523        self.tx_credit_remaining
1524            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |v| {
1525                Some(v.saturating_add(bytes))
1526            })
1527            .ok();
1528        self.tx_bytes_sent
1529            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
1530                Some(v.saturating_sub(bytes as u64))
1531            })
1532            .ok();
1533    }
1534
1535    /// Attempt to roll back a TX sequence number that was allocated via
1536    /// [`Self::next_tx_seq`] but whose packet never reached the wire
1537    /// (e.g. the FairScheduler queue was full and `deliver_stream_packet`
1538    /// returned `Backpressure` *after* the seq was consumed). Unlike the
1539    /// byte credit — which `TxSlotGuard::drop` always refunds — the seq
1540    /// is a monotonic `fetch_add` counter, so a blind decrement is unsafe:
1541    /// a concurrent sender on the same stream may already have consumed
1542    /// `seq + 1`, and decrementing would re-issue that sender's sequence.
1543    ///
1544    /// We therefore roll back **only** via a CAS `seq + 1 -> seq`, which
1545    /// succeeds exactly when `seq` was the most-recently-issued sequence
1546    /// (the common case for the backpressure-on-the-last-flush scenario)
1547    /// and no other send has advanced the counter in between. Returns
1548    /// `true` if the rollback won the CAS (no gap left behind), `false`
1549    /// if another allocation raced ahead — in which case the gap is
1550    /// genuinely unavoidable and the reliable-stream retransmit/NACK
1551    /// machinery must recover it instead.
1552    pub fn try_rollback_tx_seq(&self, seq: u64) -> bool {
1553        self.tx_seq
1554            .compare_exchange(
1555                seq.wrapping_add(1),
1556                seq,
1557                Ordering::AcqRel,
1558                Ordering::Acquire,
1559            )
1560            .is_ok()
1561    }
1562
1563    /// Apply a receiver grant reporting the receiver's **absolute**
1564    /// cumulative consumed-byte count on this stream. Monotonic —
1565    /// grants arriving with `total_consumed` below the already-observed
1566    /// maximum are treated as stale duplicates and only bump the
1567    /// `credit_grants_received` counter. Self-healing: a single lost
1568    /// grant is reconciled by the next one because each grant carries
1569    /// the receiver's full accounting.
1570    ///
1571    /// Reconciliation adds the **delta** of newly-acknowledged bytes
1572    /// (`total_consumed - prev_max_consumed`) to `tx_credit_remaining`
1573    /// via `fetch_update`. The additive form composes atomically with
1574    /// the CAS in `try_acquire_tx_credit` and the `fetch_update` in
1575    /// `refund_tx_credit`: every operation preserves the invariant
1576    /// `remaining + (sent - max_consumed) == window` regardless of
1577    /// interleaving. An earlier `.store()`-based implementation
1578    /// recomputed from a racy snapshot of `tx_bytes_sent`, which could
1579    /// silently overwrite a concurrent acquire's CAS result.
1580    pub fn apply_authoritative_grant(&self, total_consumed: u64) {
1581        self.credit_grants_received.fetch_add(1, Ordering::Relaxed);
1582        if self.tx_window == 0 {
1583            return;
1584        }
1585        // Clamp `total_consumed` to the sender-side `tx_bytes_sent`
1586        // watermark before the CAS. Without this, a malformed or
1587        // hostile grant carrying `total_consumed = u64::MAX` advanced
1588        // `max_consumed_seen` to MAX, and every subsequent honest
1589        // grant tripped the `total_consumed <= prev` early-return —
1590        // the stream stalled forever. The clamp is safe under honest
1591        // operation (a receiver can't have consumed bytes the sender
1592        // hasn't committed) and acts as a safety bound
1593        // otherwise.
1594        let sent_watermark = self.tx_bytes_sent.load(Ordering::Acquire);
1595        let total_consumed = total_consumed.min(sent_watermark);
1596        // Monotonic CAS update — the value advanced by the successful
1597        // CAS is the amount of newly-acknowledged bytes.
1598        let mut prev = self.max_consumed_seen.load(Ordering::Acquire);
1599        let delta = loop {
1600            if total_consumed <= prev {
1601                return; // stale / duplicate grant — ignore
1602            }
1603            match self.max_consumed_seen.compare_exchange_weak(
1604                prev,
1605                total_consumed,
1606                Ordering::AcqRel,
1607                Ordering::Acquire,
1608            ) {
1609                Ok(_) => break total_consumed - prev,
1610                Err(current) => prev = current,
1611            }
1612        };
1613        // Under honest receiver accounting
1614        // (`total_consumed <= tx_bytes_sent`) the delta is bounded by
1615        // the outstanding window, so `saturating_add` is a no-op
1616        // against overflow and the final value naturally stays at or
1617        // below `tx_window`.
1618        //
1619        // A malformed or buggy grant can report `total_consumed`
1620        // above what the sender has actually committed, which would
1621        // otherwise mint credit past the window and let the sender
1622        // exceed its configured ceiling. The `min(self.tx_window)`
1623        // clamp caps credit at the configured window regardless of
1624        // the reported delta — a safety bound, not a correctness
1625        // requirement under honest operation.
1626        let grant_add = delta.min(u32::MAX as u64) as u32;
1627        let window = self.tx_window;
1628        self.tx_credit_remaining
1629            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |v| {
1630                Some(v.saturating_add(grant_add).min(window))
1631            })
1632            .ok();
1633    }
1634
1635    /// Cumulative bytes committed to the wire on this stream.
1636    /// Admission bumps it; uncommitted-guard drops roll it back.
1637    #[inline]
1638    pub fn tx_bytes_sent(&self) -> u64 {
1639        self.tx_bytes_sent.load(Ordering::Relaxed)
1640    }
1641
1642    /// Highest `total_consumed` this sender has observed from the
1643    /// receiver on this stream. Monotonic.
1644    #[inline]
1645    pub fn max_consumed_seen(&self) -> u64 {
1646        self.max_consumed_seen.load(Ordering::Acquire)
1647    }
1648
1649    /// Record that the receiver side has accepted `bytes` off the
1650    /// wire on this stream. Returns `Some(total_consumed)` — the
1651    /// receiver's new cumulative consumed count — so the caller can
1652    /// emit an authoritative `StreamWindow` grant. Returns `None`
1653    /// when receive-side bookkeeping is disabled (`window_bytes == 0`).
1654    pub fn on_bytes_consumed(&self, bytes: u64) -> Option<u64> {
1655        self.rx_credit.on_bytes_consumed(bytes)
1656    }
1657
1658    /// Increment the "grants emitted" counter. Called after a grant
1659    /// packet has been successfully handed to the socket send path.
1660    #[inline]
1661    pub fn note_grant_sent(&self) {
1662        self.credit_grants_sent.fetch_add(1, Ordering::Relaxed);
1663    }
1664
1665    /// Get and increment the TX sequence number. Refreshes `last_activity`.
1666    #[inline]
1667    pub fn next_tx_seq(&self) -> u64 {
1668        self.touch();
1669        self.tx_seq.fetch_add(1, Ordering::Relaxed)
1670    }
1671
1672    /// Get the current TX sequence number
1673    #[inline]
1674    pub fn current_tx_seq(&self) -> u64 {
1675        self.tx_seq.load(Ordering::Relaxed)
1676    }
1677
1678    /// Update the RX sequence number. Refreshes `last_activity`.
1679    #[inline]
1680    pub fn update_rx_seq(&self, seq: u64) {
1681        self.touch();
1682        self.rx_seq.fetch_max(seq, Ordering::Relaxed);
1683    }
1684
1685    /// Get the current RX sequence number
1686    #[inline]
1687    pub fn current_rx_seq(&self) -> u64 {
1688        self.rx_seq.load(Ordering::Relaxed)
1689    }
1690
1691    /// Access the reliability mode
1692    #[inline]
1693    pub fn with_reliability<F, R>(&self, f: F) -> R
1694    where
1695        F: FnOnce(&mut Box<dyn ReliabilityMode>) -> R,
1696    {
1697        let mut guard = self.reliability.lock();
1698        f(&mut guard)
1699    }
1700
1701    /// Push an event to the inbound queue
1702    #[inline]
1703    pub fn push_event(&self, event: StoredEvent) {
1704        self.inbound.push(event);
1705    }
1706
1707    /// Pop an event from the inbound queue
1708    #[inline]
1709    pub fn pop_event(&self) -> Option<StoredEvent> {
1710        self.inbound.pop()
1711    }
1712
1713    /// Get the number of pending inbound events
1714    #[inline]
1715    pub fn inbound_len(&self) -> usize {
1716        self.inbound.len()
1717    }
1718
1719    /// Check if stream is active
1720    #[inline]
1721    pub fn is_active(&self) -> bool {
1722        self.active.load(Ordering::Acquire)
1723    }
1724
1725    /// Deactivate the stream
1726    #[inline]
1727    pub fn deactivate(&self) {
1728        self.active.store(false, Ordering::Release);
1729    }
1730}
1731
1732impl std::fmt::Debug for StreamState {
1733    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1734        f.debug_struct("StreamState")
1735            .field("tx_seq", &self.tx_seq.load(Ordering::Relaxed))
1736            .field("rx_seq", &self.rx_seq.load(Ordering::Relaxed))
1737            .field("inbound_len", &self.inbound.len())
1738            .field("active", &self.active.load(Ordering::Relaxed))
1739            .finish()
1740    }
1741}
1742
1743/// Session manager for handling multiple sessions.
1744///
1745/// Currently supports single-peer operation, but designed for
1746/// future multi-peer extension.
1747pub struct SessionManager {
1748    /// Current session (single-peer mode)
1749    session: parking_lot::RwLock<Option<Arc<NetSession>>>,
1750    /// Session timeout
1751    timeout: Duration,
1752}
1753
1754impl SessionManager {
1755    /// Create a new session manager
1756    pub fn new(timeout: Duration) -> Self {
1757        Self {
1758            session: parking_lot::RwLock::new(None),
1759            timeout,
1760        }
1761    }
1762
1763    /// Set the current session
1764    pub fn set_session(&self, session: NetSession) {
1765        let mut guard = self.session.write();
1766        *guard = Some(Arc::new(session));
1767    }
1768
1769    /// Set the current session from an existing Arc
1770    pub fn set_session_arc(&self, session: Arc<NetSession>) {
1771        let mut guard = self.session.write();
1772        *guard = Some(session);
1773    }
1774
1775    /// Get the current session
1776    pub fn get_session(&self) -> Option<Arc<NetSession>> {
1777        self.session.read().clone()
1778    }
1779
1780    /// Clear the current session
1781    pub fn clear_session(&self) {
1782        let mut guard = self.session.write();
1783        if let Some(session) = guard.take() {
1784            session.deactivate();
1785        }
1786    }
1787
1788    /// Check if there's an active session
1789    pub fn has_session(&self) -> bool {
1790        self.session.read().is_some()
1791    }
1792
1793    /// Check session health and clean up if timed out
1794    pub fn check_session(&self) -> bool {
1795        let guard = self.session.read();
1796        if let Some(session) = guard.as_ref() {
1797            if session.is_timed_out(self.timeout) {
1798                drop(guard);
1799                self.clear_session();
1800                return false;
1801            }
1802            session.is_active()
1803        } else {
1804            false
1805        }
1806    }
1807}
1808
1809impl std::fmt::Debug for SessionManager {
1810    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1811        f.debug_struct("SessionManager")
1812            .field("has_session", &self.has_session())
1813            .field("timeout", &self.timeout)
1814            .finish()
1815    }
1816}
1817
1818use super::current_timestamp;
1819
1820#[cfg(test)]
1821mod heartbeat_api_drift_check {
1822    //! Tripwire for the heartbeat-unification invariant: every
1823    //! production-side caller in `mod.rs` and `mesh.rs` that
1824    //! constructs a heartbeat must go through
1825    //! [`NetSession::build_heartbeat`]. See
1826    //! `docs/internal/plans/HEARTBEAT_UNIFICATION_PLAN.md` Step 4.
1827    //!
1828    //! `PacketBuilder::new` is `pub(crate)` so the type system
1829    //! already forbids external callers. Within the crate,
1830    //! though, a future contributor could legitimately add a new
1831    //! production caller that reaches into the pool directly
1832    //! (`session.thread_local_pool().get().build_heartbeat()`)
1833    //! and bypass the session helper — that pattern was the bug
1834    //! shape behind #97/#106. This test counts the approved
1835    //! production call sites and fails if a new one appears
1836    //! without an explicit allowlist update, forcing the
1837    //! contributor to confirm the design choice.
1838    //!
1839    //! The test scans only the *production* prefixes of each
1840    //! file (everything before the first column-0
1841    //! `#[cfg(test)]`), which excludes the test modules where
1842    //! `PacketBuilder::new(&keys.tx_key, ...)` is the canonical
1843    //! way to build a heartbeat for a manually-constructed
1844    //! peer session.
1845
1846    /// Everything before the first column-0 `#[cfg(test)] mod`.
1847    ///
1848    /// Top-level test modules are tagged with a column-0 `#[cfg(test)]`
1849    /// immediately followed by `mod`. Nested `#[cfg(test)]` mods (indented
1850    /// inside an `impl` or inline `mod` block) are deliberately NOT cut here, so
1851    /// production code following a nested test mod is still checked. False
1852    /// positives from nested-test-mod content are unlikely because none of the
1853    /// nested test mods in this codebase reference `build_heartbeat`.
1854    ///
1855    /// **Scanned line by line, not by substring.** This used to search for the
1856    /// literal `"\n#[cfg(test)]\nmod "`, which silently fails on CRLF: the
1857    /// needle never matches, the whole file is treated as production, and the
1858    /// allowlist assertion then reports every TEST caller as a drifted
1859    /// production one. That is a confusing failure a long way from its cause —
1860    /// it cost a real debugging detour during OLB-2B.3c-pre, where an editor
1861    /// rewrote `mesh.rs` with CRLF and this guard blamed eight test call sites.
1862    /// `str::lines` strips a trailing `\r`, so a line-based scan cannot regress
1863    /// that way. Witnessed by `production_prefix_is_line_ending_agnostic`.
1864    fn production_prefix(src: &str) -> String {
1865        let mut prefix = String::with_capacity(src.len());
1866        let mut lines = src.lines().peekable();
1867        while let Some(line) = lines.next() {
1868            // Column 0 for BOTH lines, matching the original substring form:
1869            // `line == "#[cfg(test)]"` rejects an indented attribute, and
1870            // `starts_with("mod ")` rejects an indented or re-exported module.
1871            let opens_test_mod =
1872                line == "#[cfg(test)]" && lines.peek().is_some_and(|next| next.starts_with("mod "));
1873            if opens_test_mod {
1874                break;
1875            }
1876            prefix.push_str(line);
1877            prefix.push('\n');
1878        }
1879        prefix
1880    }
1881
1882    fn count_build_heartbeat_callers(src: &str) -> Vec<String> {
1883        src.lines()
1884            .filter(|line| {
1885                let trimmed = line.trim_start();
1886                // Skip comments / doc-comments.
1887                if trimmed.starts_with("//") {
1888                    return false;
1889                }
1890                line.contains(".build_heartbeat(")
1891            })
1892            .map(|line| line.trim().to_string())
1893            .collect()
1894    }
1895
1896    /// The prefix scan must not care about line endings.
1897    ///
1898    /// This is the regression that actually happened. `production_prefix`
1899    /// searched for the literal `"\n#[cfg(test)]\nmod "`; an editor rewrote
1900    /// `mesh.rs` with CRLF during OLB-2B.3c-pre, the needle stopped matching,
1901    /// the whole file was treated as production, and this guard reported eight
1902    /// TEST call sites as drifted production callers. The real change was a
1903    /// line ending, and the failure pointed at `build_heartbeat`.
1904    ///
1905    /// A guard whose false-positive mode is that confusing has to prove it
1906    /// cannot do that again. Both fixtures below carry the SAME code, so both
1907    /// must yield the same single production caller.
1908    #[test]
1909    fn production_prefix_is_line_ending_agnostic() {
1910        const SRC: &str = "\
1911fn production() {
1912    let a = session.build_heartbeat();
1913}
1914
1915#[cfg(test)]
1916mod tests {
1917    fn t() {
1918        let b = builder.build_heartbeat();
1919    }
1920}
1921";
1922        let lf = production_prefix(SRC);
1923        let crlf = production_prefix(&SRC.replace('\n', "\r\n"));
1924
1925        let expected = vec!["let a = session.build_heartbeat();".to_string()];
1926        assert_eq!(
1927            count_build_heartbeat_callers(&lf),
1928            expected,
1929            "LF: the test-module caller must be cut"
1930        );
1931        assert_eq!(
1932            count_build_heartbeat_callers(&crlf),
1933            expected,
1934            "CRLF: the same source with CRLF endings must cut the same test \
1935             module. Leaking `builder.build_heartbeat()` here means the prefix \
1936             scan is substring-based again, and the allowlist assertion will \
1937             blame test call sites for a line-ending change"
1938        );
1939    }
1940
1941    /// The cut is column-0-only, in both endings.
1942    ///
1943    /// Pinned because the line-based rewrite could easily have loosened it: a
1944    /// `trim()` on either line would start cutting at NESTED `#[cfg(test)] mod`
1945    /// blocks, silently shrinking the production surface this guard inspects.
1946    /// That failure is invisible — the assertion just stops seeing callers.
1947    #[test]
1948    fn production_prefix_cuts_only_column_zero_test_mods() {
1949        const SRC: &str = "\
1950impl Thing {
1951    #[cfg(test)]
1952    mod nested {
1953        fn t() {}
1954    }
1955}
1956
1957fn still_production() {
1958    let a = session.build_heartbeat();
1959}
1960";
1961        for (label, src) in [("LF", SRC.to_string()), ("CRLF", SRC.replace('\n', "\r\n"))] {
1962            let prod = production_prefix(&src);
1963            assert_eq!(
1964                count_build_heartbeat_callers(&prod),
1965                vec!["let a = session.build_heartbeat();".to_string()],
1966                "{label}: an INDENTED `#[cfg(test)] mod` must not cut the scan — \
1967                 production code after a nested test mod is still checked"
1968            );
1969        }
1970    }
1971
1972    #[test]
1973    fn mod_rs_production_callers_match_allowlist() {
1974        let prod = production_prefix(include_str!("mod.rs"));
1975        let callers = count_build_heartbeat_callers(&prod);
1976        // The only approved production caller in mod.rs:
1977        //   `let packet = session.build_heartbeat();`
1978        // inside `spawn_heartbeat`. Pre-fix this read
1979        //   `let packet = pooled.build_heartbeat();`
1980        // — that pattern is the regression we want to catch.
1981        let approved = ["let packet = session.build_heartbeat();"];
1982        assert_eq!(
1983            callers,
1984            approved.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
1985            "mod.rs production callers of `.build_heartbeat()` drifted from the \
1986             approved allowlist. If you intentionally added a new caller, route it \
1987             through `Session::build_heartbeat` and update this allowlist. \
1988             See docs/internal/plans/HEARTBEAT_UNIFICATION_PLAN.md."
1989        );
1990    }
1991
1992    #[test]
1993    fn mesh_rs_production_callers_match_allowlist() {
1994        let prod = production_prefix(include_str!("mesh.rs"));
1995        let callers = count_build_heartbeat_callers(&prod);
1996        let approved = ["let packet = session.build_heartbeat();"];
1997        assert_eq!(
1998            callers,
1999            approved.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
2000            "mesh.rs production callers of `.build_heartbeat()` drifted from the \
2001             approved allowlist. If you intentionally added a new caller, route it \
2002             through `Session::build_heartbeat` and update this allowlist. \
2003             See docs/internal/plans/HEARTBEAT_UNIFICATION_PLAN.md."
2004        );
2005    }
2006}
2007
2008#[cfg(test)]
2009mod tests {
2010    use super::*;
2011
2012    fn test_keys() -> SessionKeys {
2013        SessionKeys {
2014            tx_key: [0x42u8; 32],
2015            rx_key: [0x24u8; 32],
2016            session_id: 0x1234567890ABCDEF,
2017            // Zero-filled sentinel — this helper bypasses the Noise
2018            // handshake, so there is no real X25519 peer key to
2019            // surface. `MeshNode::peer_static_x25519` treats zeros
2020            // as "not available" and returns `None`.
2021            remote_static_pub: [0u8; 32],
2022            // Same story: no handshake hash to derive route-hop keys
2023            // from. Distinct constants rather than zeros so a test
2024            // that accidentally relied on tx == rx would fail.
2025            route_hop_tx_key: [0x51u8; 32],
2026            route_hop_rx_key: [0x15u8; 32],
2027        }
2028    }
2029
2030    /// A refused seal must not consume a hop sequence.
2031    ///
2032    /// `seal_route_hop_into` takes the next sequence with a
2033    /// `fetch_add`, which is not undoable. Checking capacity after
2034    /// taking it would burn a sequence number on a purely local sizing
2035    /// mistake, opening a gap in this edge's sequence space that the
2036    /// peer's replay window then has to absorb for no reason. The
2037    /// capacity check therefore runs first, and this pins that ordering
2038    /// by observing the sequence actually emitted.
2039    #[test]
2040    fn a_refused_seal_does_not_consume_a_hop_sequence() {
2041        use super::super::route::RoutingHeader;
2042        use super::super::subnet::route_hop::{parse_prefix, sealed_len, RouteHopError};
2043
2044        let session = NetSession::new(test_keys(), "127.0.0.1:9999".parse().unwrap(), 4, false);
2045        let header = RoutingHeader::new(0xDEAD_BEEF, 0x1234, 8);
2046        let inner = b"an inner packet the relay never looks inside";
2047        let needed = sealed_len(inner.len());
2048
2049        // Burn sequence 0 so the test is about the *next* one rather
2050        // than about a fresh counter reading zero either way.
2051        let mut ok_buf = vec![0u8; needed];
2052        session
2053            .seal_route_hop_into(&mut ok_buf, &header, inner)
2054            .expect("exact size fits");
2055        assert_eq!(sequence_of(&ok_buf), 0);
2056
2057        // Several refusals, each one byte short of enough.
2058        for short in [0usize, 1, needed - 1] {
2059            let mut tiny = vec![0u8; short];
2060            assert_eq!(
2061                session.seal_route_hop_into(&mut tiny, &header, inner),
2062                Err(RouteHopError::BufferTooSmall),
2063                "a {short}-byte buffer must be refused",
2064            );
2065        }
2066
2067        // The next accepted seal gets sequence 1, not 4.
2068        let mut next_buf = vec![0u8; needed];
2069        session
2070            .seal_route_hop_into(&mut next_buf, &header, inner)
2071            .expect("exact size fits");
2072        assert_eq!(
2073            sequence_of(&next_buf),
2074            1,
2075            "refused seals must not advance the hop sequence",
2076        );
2077
2078        fn sequence_of(buf: &[u8]) -> u64 {
2079            // The sequence sits in the prefix; read it back off the
2080            // wire rather than trusting an internal counter.
2081            parse_prefix(buf).expect("a sealed envelope parses");
2082            u64::from_le_bytes(buf[10..18].try_into().expect("8 bytes"))
2083        }
2084    }
2085
2086    #[test]
2087    fn test_session_creation() {
2088        let keys = test_keys();
2089        let peer_addr: SocketAddr = "127.0.0.1:9999".parse().unwrap();
2090
2091        let session = NetSession::new(keys.clone(), peer_addr, 4, false);
2092
2093        assert_eq!(session.session_id(), keys.session_id);
2094        assert_eq!(session.peer_addr(), peer_addr);
2095        assert!(session.is_active());
2096        assert_eq!(session.stream_count(), 0);
2097    }
2098
2099    /// Review GRANTLOSS: a batched grant datagram carries many streams'
2100    /// credit grants at once, and a lost one is NOT re-queued by the
2101    /// drainer. That is safe because grants are AUTHORITATIVE and
2102    /// cumulative — any later grant for the stream carries the full
2103    /// `total_consumed`, so a dropped grant is subsumed by the next one
2104    /// and the stream never wedges. This pins the per-stream property
2105    /// (a dropped batch is just N of these); the 30 s committed-flush
2106    /// budget is the backstop for the pathological "no later grant ever
2107    /// arrives" case.
2108    #[test]
2109    fn dropped_grant_recovers_via_next_authoritative_grant() {
2110        let st = StreamState::new_full(true, 1, 1000);
2111        assert_eq!(st.tx_credit_remaining(), 1000, "full initial window");
2112
2113        // Sender commits 600 bytes to the wire → credit falls, the
2114        // committed-bytes watermark rises.
2115        assert!(st.try_acquire_tx_credit(600));
2116        assert_eq!(st.tx_credit_remaining(), 400);
2117
2118        // The receiver consumed 300, then 600. Its first grant
2119        // (total_consumed = 300) rode a batched datagram that was
2120        // dropped — never applied. Only the second, cumulative grant
2121        // (total_consumed = 600) lands.
2122        st.apply_authoritative_grant(600);
2123
2124        // Credit fully recovers to the window despite the dropped
2125        // grant: the later authoritative grant conveyed the whole
2126        // cumulative amount. The stream is not wedged.
2127        assert_eq!(
2128            st.tx_credit_remaining(),
2129            1000,
2130            "dropped grant is subsumed by the next authoritative grant"
2131        );
2132        assert_eq!(
2133            st.credit_grants_received(),
2134            1,
2135            "only the second grant applied"
2136        );
2137
2138        // A late-arriving straggler of the dropped grant is an
2139        // idempotent no-op (monotonic `max_consumed_seen`) — it cannot
2140        // double-credit past the window.
2141        st.apply_authoritative_grant(300);
2142        assert_eq!(st.tx_credit_remaining(), 1000, "stale grant ignored");
2143    }
2144
2145    /// Review HORIZON: a stream is built from ONE `tx_window` that sizes
2146    /// BOTH the sender retransmit window (`max_pending`) AND the
2147    /// receiver reorder-acceptance horizon — [`Self::new_full_with_epoch`]
2148    /// feeds `tx_window` through [`ReliableStream::max_pending_for_window`],
2149    /// and `reorder_horizon` re-clamps that same value. This pins the
2150    /// exact acceptance boundary against the tx-window-derived budget,
2151    /// so that giving the receiver an INDEPENDENT rx window later fails
2152    /// HERE (a deliberate update) instead of silently shifting reorder
2153    /// acceptance / memory semantics.
2154    #[test]
2155    fn reorder_horizon_shares_the_tx_window_budget() {
2156        for tx_window in [0u32, 4096, 1 << 16, 1 << 24, u32::MAX] {
2157            let expected_horizon = (ReliableStream::max_pending_for_window(tx_window) as u64)
2158                .clamp(64, ReliableStream::MAX_REORDER_PACKETS);
2159            let st = StreamState::new_full(true, 1, tx_window);
2160            st.with_reliability(|r| {
2161                assert!(r.on_receive(0)); // next_expected = 1
2162                assert!(
2163                    r.on_receive(1 + expected_horizon),
2164                    "offset == horizon ({expected_horizon}) accepted (window={tx_window})"
2165                );
2166                assert!(
2167                    !r.on_receive(1 + expected_horizon + 1),
2168                    "offset horizon+1 rejected (window={tx_window})"
2169                );
2170            });
2171        }
2172    }
2173
2174    /// Pin discovery-routing perf #108: the per-session NodeId
2175    /// cache starts empty, accepts the first non-zero publish,
2176    /// and ignores the `0` sentinel.
2177    #[test]
2178    fn cached_node_id_returns_none_until_published_then_caches() {
2179        let keys = test_keys();
2180        let peer_addr: SocketAddr = "127.0.0.1:9999".parse().unwrap();
2181        let session = NetSession::new(keys, peer_addr, 4, false);
2182
2183        // Fresh session: no cached resolution.
2184        assert_eq!(session.cached_node_id(), None);
2185
2186        // `0` is the "unresolved" sentinel — callers passing it
2187        // should be a no-op so the cache stays empty.
2188        session.cache_node_id(0);
2189        assert_eq!(
2190            session.cached_node_id(),
2191            None,
2192            "0 is the unresolved sentinel; caching it must be a no-op",
2193        );
2194
2195        // First real publish lands.
2196        session.cache_node_id(0xDEAD_BEEF_CAFE_F00D);
2197        assert_eq!(session.cached_node_id(), Some(0xDEAD_BEEF_CAFE_F00D));
2198
2199        // Subsequent publish of the same value is a no-op (writes
2200        // are idempotent for a stable session — see field doc).
2201        session.cache_node_id(0xDEAD_BEEF_CAFE_F00D);
2202        assert_eq!(session.cached_node_id(), Some(0xDEAD_BEEF_CAFE_F00D));
2203    }
2204
2205    #[test]
2206    fn test_stream_state() {
2207        let stream = StreamState::new(false);
2208
2209        // TX sequence
2210        assert_eq!(stream.next_tx_seq(), 0);
2211        assert_eq!(stream.next_tx_seq(), 1);
2212        assert_eq!(stream.current_tx_seq(), 2);
2213
2214        // RX sequence
2215        stream.update_rx_seq(5);
2216        assert_eq!(stream.current_rx_seq(), 5);
2217        stream.update_rx_seq(3); // Lower value ignored
2218        assert_eq!(stream.current_rx_seq(), 5);
2219
2220        // Inbound queue
2221        let event = StoredEvent::from_value("1".into(), serde_json::json!({"test": 1}), 100, 0);
2222        stream.push_event(event);
2223        assert_eq!(stream.inbound_len(), 1);
2224
2225        let popped = stream.pop_event().unwrap();
2226        assert_eq!(popped.id, "1");
2227        assert_eq!(stream.inbound_len(), 0);
2228    }
2229
2230    #[test]
2231    fn test_session_streams() {
2232        let keys = test_keys();
2233        let peer_addr: SocketAddr = "127.0.0.1:9999".parse().unwrap();
2234
2235        let session = NetSession::new(keys, peer_addr, 4, false);
2236
2237        // Create streams
2238        {
2239            let stream = session.get_or_create_stream(0);
2240            assert_eq!(stream.next_tx_seq(), 0);
2241        }
2242
2243        {
2244            let stream = session.get_or_create_stream(1);
2245            assert_eq!(stream.next_tx_seq(), 0);
2246        }
2247
2248        assert_eq!(session.stream_count(), 2);
2249
2250        let ids = session.stream_ids();
2251        assert!(ids.contains(&0));
2252        assert!(ids.contains(&1));
2253    }
2254
2255    #[test]
2256    fn test_session_timeout() {
2257        let keys = test_keys();
2258        let peer_addr: SocketAddr = "127.0.0.1:9999".parse().unwrap();
2259
2260        let session = NetSession::new(keys, peer_addr, 4, false);
2261
2262        // Should not be timed out immediately
2263        assert!(!session.is_timed_out(Duration::from_secs(1)));
2264
2265        // Touch and verify
2266        session.touch();
2267        assert!(!session.is_timed_out(Duration::from_secs(1)));
2268    }
2269
2270    #[test]
2271    fn test_session_manager() {
2272        let manager = SessionManager::new(Duration::from_secs(30));
2273
2274        assert!(!manager.has_session());
2275
2276        let keys = test_keys();
2277        let peer_addr: SocketAddr = "127.0.0.1:9999".parse().unwrap();
2278        let session = NetSession::new(keys, peer_addr, 4, false);
2279
2280        manager.set_session(session);
2281        assert!(manager.has_session());
2282
2283        let retrieved = manager.get_session().unwrap();
2284        assert!(retrieved.is_active());
2285
2286        manager.clear_session();
2287        assert!(!manager.has_session());
2288    }
2289
2290    #[test]
2291    fn test_open_stream_with_idempotent() {
2292        let keys = test_keys();
2293        let peer_addr: SocketAddr = "127.0.0.1:9999".parse().unwrap();
2294        let session = NetSession::new(keys, peer_addr, 4, false);
2295
2296        // First open creates state.
2297        session.open_stream_with(42, true, 3);
2298        assert_eq!(session.stream_count(), 1);
2299        let state = session.get_stream(42).unwrap();
2300        assert!(state.reliable_mode());
2301        assert_eq!(state.fairness_weight(), 3);
2302        drop(state);
2303
2304        // Second open with matching config is a no-op.
2305        session.open_stream_with(42, true, 3);
2306        assert_eq!(session.stream_count(), 1);
2307
2308        // Second open with DIFFERENT config is also a no-op
2309        // (first open wins). We log a warning but don't mutate.
2310        session.open_stream_with(42, false, 7);
2311        let state = session.get_stream(42).unwrap();
2312        assert!(
2313            state.reliable_mode(),
2314            "first open wins — reliable still true"
2315        );
2316        assert_eq!(
2317            state.fairness_weight(),
2318            3,
2319            "first open wins — weight still 3"
2320        );
2321    }
2322
2323    /// PERF_AUDIT §2.12 regression: `open_stream_full` now probes
2324    /// with a read-only `get` before falling back to the write
2325    /// `entry()`. Two racers that both miss the probe must NOT
2326    /// duplicate the stream or observe different epochs — the
2327    /// `entry()` fallback serializes creation, and the loser picks
2328    /// up the winner's epoch. Hammer the race across many fresh
2329    /// stream ids; any TOCTOU between the probe and the entry
2330    /// surfaces as an epoch mismatch or a stream-count anomaly.
2331    #[test]
2332    fn open_stream_full_racing_creators_agree_on_one_epoch() {
2333        use std::sync::Barrier;
2334        use std::thread;
2335
2336        let keys = test_keys();
2337        let peer_addr: SocketAddr = "127.0.0.1:9999".parse().unwrap();
2338        let session = Arc::new(NetSession::new(keys, peer_addr, 4, false));
2339
2340        const RACERS: usize = 4;
2341        const ROUNDS: u64 = 200;
2342        for round in 0..ROUNDS {
2343            let stream_id = 0x1000 + round; // fresh id per round
2344            let barrier = Arc::new(Barrier::new(RACERS));
2345            let handles: Vec<_> = (0..RACERS)
2346                .map(|_| {
2347                    let session = Arc::clone(&session);
2348                    let barrier = Arc::clone(&barrier);
2349                    thread::spawn(move || {
2350                        barrier.wait();
2351                        session.open_stream_full(stream_id, true, 2, 0)
2352                    })
2353                })
2354                .collect();
2355            let epochs: Vec<u64> = handles.into_iter().map(|h| h.join().unwrap()).collect();
2356            assert!(
2357                epochs.windows(2).all(|w| w[0] == w[1]),
2358                "round {round}: all racers must observe the creation winner's epoch, got {epochs:?}"
2359            );
2360            let live = session
2361                .get_stream(stream_id)
2362                .expect("stream must exist after the race");
2363            assert_eq!(
2364                live.epoch(),
2365                epochs[0],
2366                "round {round}: live stream's epoch must match what the racers returned"
2367            );
2368        }
2369        assert_eq!(
2370            session.stream_count() as u64,
2371            ROUNDS,
2372            "exactly one stream per id — racing creators must never duplicate"
2373        );
2374    }
2375
2376    #[test]
2377    fn test_close_stream_removes_state() {
2378        let keys = test_keys();
2379        let peer_addr: SocketAddr = "127.0.0.1:9999".parse().unwrap();
2380        let session = NetSession::new(keys, peer_addr, 4, false);
2381
2382        session.open_stream_with(1, false, 1);
2383        session.open_stream_with(2, true, 2);
2384        assert_eq!(session.stream_count(), 2);
2385
2386        session.close_stream(1);
2387        assert_eq!(session.stream_count(), 1);
2388        assert!(session.get_stream(1).is_none());
2389        assert!(session.get_stream(2).is_some());
2390
2391        // Closing a non-existent stream is a no-op.
2392        session.close_stream(99);
2393        assert_eq!(session.stream_count(), 1);
2394
2395        // Re-open after close creates fresh state with new config.
2396        session.close_stream(2);
2397        session.open_stream_with(2, false, 5);
2398        let state = session.get_stream(2).unwrap();
2399        assert!(!state.reliable_mode());
2400        assert_eq!(state.fairness_weight(), 5);
2401    }
2402
2403    #[test]
2404    fn test_evict_idle_streams_timeout_and_cap() {
2405        let keys = test_keys();
2406        let peer_addr: SocketAddr = "127.0.0.1:9999".parse().unwrap();
2407        let session = NetSession::new(keys, peer_addr, 4, false);
2408
2409        // Open three streams; touch only one so the other two look idle.
2410        session.open_stream_with(1, false, 1);
2411        session.open_stream_with(2, false, 1);
2412        session.open_stream_with(3, false, 1);
2413        std::thread::sleep(Duration::from_millis(10));
2414        session.get_or_create_stream(2).touch();
2415
2416        // With a tight idle timeout, streams 1 and 3 should be evicted;
2417        // stream 2 was just touched so it survives.
2418        let evicted = session.evict_idle_streams(Duration::from_millis(5), usize::MAX, "test");
2419        assert_eq!(evicted, 2);
2420        assert_eq!(session.stream_count(), 1);
2421        assert!(session.get_stream(2).is_some());
2422
2423        // Cap eviction: open two more streams so we have 3, then cap at 1.
2424        session.open_stream_with(4, false, 1);
2425        session.open_stream_with(5, false, 1);
2426        assert_eq!(session.stream_count(), 3);
2427        let evicted = session.evict_idle_streams(Duration::from_nanos(u64::MAX), 1, "test");
2428        assert_eq!(evicted, 2);
2429        assert_eq!(session.stream_count(), 1);
2430    }
2431
2432    /// Regression for BUG_AUDIT_2026_04_30_CORE.md #105: pre-fix
2433    /// `recently_closed` only got garbage-collected by
2434    /// `is_grant_quarantined` on inbound `StreamWindow` grants.
2435    /// A peer churning short-lived streams without receiving a
2436    /// late grant for each accumulates one entry per closed
2437    /// stream forever. Post-fix `evict_idle_streams` also
2438    /// sweeps `recently_closed`, dropping entries past
2439    /// `GRANT_QUARANTINE_WINDOW`.
2440    #[test]
2441    fn evict_idle_streams_sweeps_recently_closed_past_quarantine_window() {
2442        let keys = test_keys();
2443        let peer_addr: SocketAddr = "127.0.0.1:9999".parse().unwrap();
2444        let session = NetSession::new(keys, peer_addr, 4, false);
2445
2446        // Manually pre-populate `recently_closed` with entries
2447        // whose timestamps are well past the quarantine window.
2448        let stale_inserted_at = Instant::now() - GRANT_QUARANTINE_WINDOW - Duration::from_secs(1);
2449        session.recently_closed.insert(0xAAAA, stale_inserted_at);
2450        session.recently_closed.insert(0xBBBB, stale_inserted_at);
2451
2452        // Add a fresh entry that should NOT be swept yet.
2453        session.recently_closed.insert(0xFEEDC0DE, Instant::now());
2454
2455        assert_eq!(session.recently_closed.len(), 3);
2456
2457        // Run the sweep. No streams to evict (we didn't open
2458        // any), but the recently_closed sweep should still fire.
2459        session.evict_idle_streams(Duration::from_millis(1), usize::MAX, "test");
2460
2461        // Stale entries dropped; fresh entry kept.
2462        assert!(
2463            !session.recently_closed.contains_key(&0xAAAA),
2464            "stale recently_closed entry past quarantine window must be swept"
2465        );
2466        assert!(
2467            !session.recently_closed.contains_key(&0xBBBB),
2468            "stale recently_closed entry past quarantine window must be swept"
2469        );
2470        assert!(
2471            session.recently_closed.contains_key(&0xFEEDC0DE),
2472            "fresh recently_closed entry within quarantine window must survive"
2473        );
2474    }
2475
2476    #[test]
2477    fn test_session_manager_arc_shares_touch_updates() {
2478        let manager = SessionManager::new(Duration::from_millis(50));
2479
2480        let keys = test_keys();
2481        let peer_addr: SocketAddr = "127.0.0.1:9999".parse().unwrap();
2482        let session = Arc::new(NetSession::new(keys, peer_addr, 4, false));
2483
2484        manager.set_session_arc(session.clone());
2485
2486        std::thread::sleep(Duration::from_millis(30));
2487        session.touch();
2488
2489        assert!(
2490            manager.check_session(),
2491            "session should be healthy because touch() updated the shared Arc"
2492        );
2493
2494        std::thread::sleep(Duration::from_millis(60));
2495        assert!(
2496            !manager.check_session(),
2497            "session should have timed out after 60ms with no touch"
2498        );
2499    }
2500
2501    #[test]
2502    fn test_stream_state_tx_credit_trips_backpressure() {
2503        // 100-byte window: two 40-byte acquires fit; third fails.
2504        let state = StreamState::new_full(false, 1, 100);
2505        assert!(state.try_acquire_tx_credit(40), "first acquire fits");
2506        assert!(state.try_acquire_tx_credit(40), "second acquire fits");
2507        assert!(
2508            !state.try_acquire_tx_credit(40),
2509            "third acquire must be refused — only 20 bytes remain"
2510        );
2511        assert_eq!(state.backpressure_events(), 1);
2512        assert_eq!(state.tx_credit_remaining(), 20);
2513    }
2514
2515    #[test]
2516    fn test_stream_state_refund_restores_credit() {
2517        let state = StreamState::new_full(false, 1, 100);
2518        assert!(state.try_acquire_tx_credit(80));
2519        assert!(
2520            !state.try_acquire_tx_credit(40),
2521            "window saturated after 80-byte acquire"
2522        );
2523
2524        // Refund simulates a cancelled send — credit flows back.
2525        state.refund_tx_credit(80);
2526        assert_eq!(state.tx_credit_remaining(), 100);
2527        assert!(state.try_acquire_tx_credit(100));
2528    }
2529
2530    #[test]
2531    fn test_stream_state_tx_window_zero_is_unbounded() {
2532        let state = StreamState::new_full(false, 1, 0);
2533        // `tx_window == 0` short-circuits — no admission check at all.
2534        for _ in 0..10_000 {
2535            assert!(state.try_acquire_tx_credit(1));
2536        }
2537        assert_eq!(state.backpressure_events(), 0);
2538    }
2539
2540    #[test]
2541    fn test_stream_state_refund_saturates_at_u32_max() {
2542        // Refund uses saturating u32 addition with no clamp at
2543        // `tx_window`: a refunded uncommitted guard must return
2544        // bytes to `tx_credit_remaining` without stranding any
2545        // credit, and a pathological caller must not wrap the
2546        // counter.
2547        let state = StreamState::new_full(false, 1, 100);
2548        // Manually push tx_credit_remaining near the top so we can
2549        // exercise the saturating edge.
2550        state
2551            .tx_credit_remaining
2552            .store(u32::MAX - 50, Ordering::Release);
2553        state.refund_tx_credit(1000);
2554        assert_eq!(state.tx_credit_remaining(), u32::MAX);
2555    }
2556
2557    #[test]
2558    fn test_authoritative_grant_recomputes_from_absolute_consumed() {
2559        // Commit 60 bytes, then apply an authoritative grant
2560        // reporting `total_consumed = 60`. Outstanding = 0, so the
2561        // sender's remaining credit returns to the full 100-byte
2562        // window — even though the grant didn't "add" anything.
2563        let state = StreamState::new_full(false, 1, 100);
2564        assert!(state.try_acquire_tx_credit(60));
2565        assert_eq!(state.tx_credit_remaining(), 40);
2566        assert_eq!(state.tx_bytes_sent(), 60);
2567
2568        state.apply_authoritative_grant(60);
2569        assert_eq!(state.tx_credit_remaining(), 100);
2570        assert_eq!(state.max_consumed_seen(), 60);
2571        assert_eq!(state.credit_grants_received(), 1);
2572    }
2573
2574    #[test]
2575    fn test_authoritative_grant_self_heals_lost_grants() {
2576        // Simulate a lost grant: sender commits 30 bytes, grant A
2577        // (total_consumed = 30) is "lost" — never applied. Sender
2578        // commits another 40 bytes (total = 70 on sender's side).
2579        // Grant B arrives with total_consumed = 70; sender
2580        // reconciles directly to remaining = 100 - (70 - 70) = 100,
2581        // fully recovering the credit that Grant A would have
2582        // refunded. This is the self-healing property.
2583        let state = StreamState::new_full(false, 1, 100);
2584        assert!(state.try_acquire_tx_credit(30));
2585        assert!(state.try_acquire_tx_credit(40));
2586        assert_eq!(state.tx_credit_remaining(), 30);
2587        assert_eq!(state.tx_bytes_sent(), 70);
2588
2589        state.apply_authoritative_grant(70); // Grant B — Grant A was dropped
2590        assert_eq!(state.tx_credit_remaining(), 100);
2591    }
2592
2593    #[test]
2594    fn test_authoritative_grant_monotonic_ignores_stale() {
2595        // Out-of-order grants: apply 60, then a stale grant of 40.
2596        // The stale one must be ignored — `max_consumed_seen` stays
2597        // at 60 and `tx_credit_remaining` is unchanged.
2598        let state = StreamState::new_full(false, 1, 100);
2599        assert!(state.try_acquire_tx_credit(80));
2600        state.apply_authoritative_grant(60);
2601        let remaining = state.tx_credit_remaining();
2602
2603        state.apply_authoritative_grant(40); // stale
2604        assert_eq!(state.max_consumed_seen(), 60);
2605        assert_eq!(state.tx_credit_remaining(), remaining);
2606    }
2607
2608    #[test]
2609    fn test_authoritative_grant_clamps_to_window_on_malformed_total_consumed() {
2610        // Regression: a malformed or buggy grant whose
2611        // `total_consumed` claims more bytes than the sender has
2612        // actually committed would otherwise mint credit above
2613        // `tx_window` — the sender could then exceed its configured
2614        // ceiling on subsequent admits.
2615        //
2616        // BUG_REPORT.md #12 (additional fix): we now clamp
2617        // `total_consumed.min(tx_bytes_sent)` *before* the CAS so
2618        // the malformed advancement isn't sticky either. Previously
2619        // a single malformed grant of `u64::MAX` advanced
2620        // `max_consumed_seen` to MAX, and every subsequent honest
2621        // grant tripped the `total_consumed <= prev` early-return
2622        // — the stream stalled forever. With the clamp, a
2623        // malformed `total_consumed` is bounded by the actual
2624        // sender-side watermark, so honest grants below the
2625        // (genuinely sent) watermark remain admittable.
2626        let state = StreamState::new_full(false, 1, 100);
2627        assert!(state.try_acquire_tx_credit(40));
2628        assert_eq!(state.tx_credit_remaining(), 60);
2629        assert_eq!(state.tx_bytes_sent(), 40);
2630
2631        // Malformed grant: reports 500 consumed against only 40 sent.
2632        state.apply_authoritative_grant(500);
2633
2634        assert_eq!(
2635            state.tx_credit_remaining(),
2636            100,
2637            "malformed grant must not push credit above tx_window",
2638        );
2639        // Per the #12 clamp, max_consumed_seen advances to the
2640        // sender-side watermark (40), NOT to the malformed value.
2641        assert_eq!(
2642            state.max_consumed_seen(),
2643            40,
2644            "max_consumed_seen must be clamped to tx_bytes_sent (#12)",
2645        );
2646        // A subsequent honest grant of 50 — but only after another
2647        // 50 bytes are actually sent (so tx_bytes_sent rises to 90).
2648        // The clamp keeps the watermark accurate.
2649        assert!(state.try_acquire_tx_credit(50));
2650        state.apply_authoritative_grant(70);
2651        assert_eq!(state.max_consumed_seen(), 70);
2652    }
2653
2654    /// Regression: BUG_REPORT.md #12 — a single malformed grant
2655    /// claiming `total_consumed = u64::MAX` used to permanently
2656    /// lock out future grants. The clamp prevents the stuck-state.
2657    #[test]
2658    fn test_authoritative_grant_u64_max_does_not_lock_out_future_grants() {
2659        let state = StreamState::new_full(false, 1, 100);
2660        assert!(state.try_acquire_tx_credit(40));
2661
2662        // Hostile grant: claims the receiver consumed every
2663        // representable byte. Pre-fix this would set
2664        // max_consumed_seen to u64::MAX and every subsequent
2665        // grant would early-return.
2666        state.apply_authoritative_grant(u64::MAX);
2667        assert_eq!(
2668            state.max_consumed_seen(),
2669            40,
2670            "max_consumed_seen must be clamped to tx_bytes_sent, \
2671             not advanced to u64::MAX"
2672        );
2673
2674        // Send more, then the receiver issues an honest grant.
2675        // Pre-fix: rejected as stale because 80 < u64::MAX.
2676        // Post-fix: accepted because max_consumed_seen is at 40.
2677        assert!(state.try_acquire_tx_credit(40));
2678        state.apply_authoritative_grant(80);
2679        assert_eq!(
2680            state.max_consumed_seen(),
2681            80,
2682            "honest grants must not be locked out by a prior malformed \
2683             u64::MAX grant (#12)"
2684        );
2685    }
2686
2687    #[test]
2688    fn test_authoritative_grant_does_not_clobber_concurrent_acquire() {
2689        // Regression: the earlier `.store()`-based reconciliation
2690        // computed `remaining = window - (sent - consumed)` from a
2691        // racy snapshot of `tx_bytes_sent` and then *overwrote*
2692        // `tx_credit_remaining`. A concurrent `try_acquire_tx_credit`
2693        // that had already CAS'd its debit but not yet bumped
2694        // `tx_bytes_sent` would have its debit silently undone — the
2695        // sender could then exceed its window.
2696        //
2697        // Hand-drive the interleaving by performing the first half of
2698        // an acquire (the CAS on `tx_credit_remaining`) before
2699        // applying the grant, and the second half (the bump of
2700        // `tx_bytes_sent`) after. If the invariant
2701        // `remaining + (sent - max_consumed) == window` still holds
2702        // at the end, the grant respected the in-flight acquire.
2703        let state = StreamState::new_full(false, 1, 100);
2704        // Commit 60 bytes up front so `tx_bytes_sent` is non-zero.
2705        assert!(state.try_acquire_tx_credit(60));
2706        assert_eq!(state.tx_credit_remaining(), 40);
2707        assert_eq!(state.tx_bytes_sent(), 60);
2708
2709        // Step 1 of a would-be `try_acquire_tx_credit(30)`: CAS the
2710        // credit debit. Defer the `tx_bytes_sent` bump to simulate a
2711        // thread that has stalled between the two atomic ops.
2712        state
2713            .tx_credit_remaining
2714            .compare_exchange(40, 10, Ordering::AcqRel, Ordering::Acquire)
2715            .expect("no contention in test harness");
2716
2717        // Grant arrives while the acquire is mid-flight: sees
2718        // `tx_bytes_sent = 60` (pre-bump), advances `max_consumed` to 60.
2719        state.apply_authoritative_grant(60);
2720
2721        // Step 2: finish the acquire by bumping `tx_bytes_sent`.
2722        state.tx_bytes_sent.fetch_add(30, Ordering::Relaxed);
2723
2724        let remaining = state.tx_credit_remaining() as u64;
2725        let sent = state.tx_bytes_sent();
2726        let consumed = state.max_consumed_seen();
2727        assert_eq!(
2728            remaining + (sent - consumed),
2729            100,
2730            "invariant violated: remaining={} sent={} consumed={} (grant clobbered the in-flight acquire)",
2731            remaining,
2732            sent,
2733            consumed,
2734        );
2735    }
2736
2737    #[test]
2738    fn test_authoritative_grant_invariant_under_thread_contention() {
2739        // Stress: many interleaved acquires and grants must preserve
2740        // the end-state invariant `remaining + (sent - consumed) == window`.
2741        // Each acquire takes 1 byte and each grant advances consumed
2742        // by 1; running both loops to completion on separate threads
2743        // exercises the ordering between the acquire's two-step
2744        // (CAS remaining, then bump sent) and the grant's credit
2745        // update.
2746        //
2747        // The granter mirrors honest receiver accounting by waiting
2748        // until the sender has actually committed `target` bytes
2749        // before reporting `total_consumed = target`. Without this
2750        // ordering the test would synthesize malformed grants that
2751        // report consumption ahead of sent bytes — the window clamp
2752        // would then strand the over-grant and fail the equality
2753        // check even though both operations are behaving correctly.
2754        use std::sync::atomic::AtomicBool;
2755        use std::sync::Arc;
2756        use std::thread;
2757
2758        const WINDOW: u32 = 64;
2759        const ITERATIONS: u64 = 2_000;
2760
2761        for _trial in 0..8 {
2762            let state = Arc::new(StreamState::new_full(false, 1, WINDOW));
2763            let go = Arc::new(AtomicBool::new(false));
2764
2765            let state_a = state.clone();
2766            let go_a = go.clone();
2767            let acquirer = thread::spawn(move || {
2768                while !go_a.load(Ordering::Acquire) {
2769                    std::hint::spin_loop();
2770                }
2771                for _ in 0..ITERATIONS {
2772                    while !state_a.try_acquire_tx_credit(1) {
2773                        std::hint::spin_loop();
2774                    }
2775                }
2776            });
2777
2778            let state_g = state.clone();
2779            let go_g = go.clone();
2780            let granter = thread::spawn(move || {
2781                while !go_g.load(Ordering::Acquire) {
2782                    std::hint::spin_loop();
2783                }
2784                for target in 1..=ITERATIONS {
2785                    while state_g.tx_bytes_sent() < target {
2786                        std::hint::spin_loop();
2787                    }
2788                    state_g.apply_authoritative_grant(target);
2789                }
2790            });
2791
2792            go.store(true, Ordering::Release);
2793            acquirer.join().unwrap();
2794            granter.join().unwrap();
2795
2796            let remaining = state.tx_credit_remaining() as u64;
2797            let sent = state.tx_bytes_sent();
2798            let consumed = state.max_consumed_seen();
2799            assert_eq!(sent, ITERATIONS);
2800            assert_eq!(consumed, ITERATIONS);
2801            assert_eq!(
2802                remaining + (sent - consumed),
2803                WINDOW as u64,
2804                "invariant violated after contention: remaining={} sent={} consumed={}",
2805                remaining,
2806                sent,
2807                consumed,
2808            );
2809        }
2810    }
2811
2812    #[test]
2813    fn test_rx_credit_emits_authoritative_total_consumed() {
2814        // Every `on_bytes_consumed` returns the receiver's running
2815        // cumulative consumed count, which the caller ships as the
2816        // `total_consumed` field of an authoritative grant. The
2817        // function bumps both `consumed` and `granted` by `bytes`
2818        // — receive-time accounting, see `RxCreditState` rustdoc
2819        // and BUG_AUDIT_2026_04_30_CORE.md.
2820        let state = StreamState::new_full(false, 1, 100);
2821        assert_eq!(state.on_bytes_consumed(60), Some(60));
2822        assert_eq!(state.on_bytes_consumed(14), Some(74));
2823        assert_eq!(state.on_bytes_consumed(1), Some(75));
2824    }
2825
2826    /// Regression: the v2 receive-time-accounting design
2827    /// keeps `outstanding = granted - consumed`
2828    /// pinned at the initial window size. The credit window is
2829    /// for kernel-buffer protection — application-side throttling
2830    /// is provided by per-shard queue-depth limits, not this
2831    /// counter. This test pins the invariant.
2832    #[test]
2833    fn rx_credit_outstanding_stays_at_window_under_receive_time_accounting() {
2834        let state = StreamState::new_full(false, 1, 100);
2835        let rx = state.rx_credit();
2836        // Initial: granted=100, consumed=0, outstanding=100.
2837        assert_eq!(rx.outstanding(), 100);
2838
2839        state.on_bytes_consumed(30);
2840        // Receive-time grant: granted=130, consumed=30. outstanding=100.
2841        assert_eq!(rx.outstanding(), 100);
2842
2843        state.on_bytes_consumed(70);
2844        // granted=200, consumed=100. outstanding=100.
2845        assert_eq!(rx.outstanding(), 100);
2846
2847        // The pre-fix audit framing claimed this was a bug; closer
2848        // inspection showed it's the documented v2 design. See
2849        // `RxCreditState` rustdoc + `mesh.rs:3110-3135`.
2850    }
2851
2852    /// Regression (#19): a TX sequence consumed for a packet that never
2853    /// reached the wire (scheduler/socket backpressure after the seq was
2854    /// allocated) must be reclaimable so the receiver sees no permanent
2855    /// gap. `try_rollback_tx_seq` rolls the counter back exactly when the
2856    /// seq was the most-recently-issued one.
2857    #[test]
2858    fn try_rollback_tx_seq_reclaims_last_issued_seq() {
2859        let state = StreamState::new_full(true, 1, 100);
2860
2861        // Consume two sequences (0 then 1).
2862        let s0 = state.next_tx_seq();
2863        let s1 = state.next_tx_seq();
2864        assert_eq!(s0, 0);
2865        assert_eq!(s1, 1);
2866
2867        // The packet for s1 hit backpressure and never went out. Roll it
2868        // back: the CAS `2 -> 1` wins because s1 was the last seq issued.
2869        assert!(
2870            state.try_rollback_tx_seq(s1),
2871            "rolling back the most-recent seq must succeed"
2872        );
2873
2874        // No gap: the next allocation re-uses the reclaimed value.
2875        assert_eq!(
2876            state.next_tx_seq(),
2877            s1,
2878            "after rollback the counter must re-issue the reclaimed seq, leaving no hole"
2879        );
2880    }
2881
2882    /// Regression (#19): if a *concurrent* sender on the same stream
2883    /// already consumed the next sequence, the rollback must NOT corrupt
2884    /// the counter — a blind decrement would re-issue the concurrent
2885    /// sender's seq as a duplicate. The CAS-guarded rollback fails
2886    /// cleanly and leaves the counter untouched.
2887    #[test]
2888    fn try_rollback_tx_seq_refuses_when_a_newer_seq_was_issued() {
2889        let state = StreamState::new_full(true, 1, 100);
2890
2891        let stale = state.next_tx_seq(); // 0 — this packet hit backpressure
2892        let newer = state.next_tx_seq(); // 1 — a concurrent send already took it
2893        assert_eq!(stale, 0);
2894        assert_eq!(newer, 1);
2895
2896        // Rolling back the stale seq must fail: the counter is at 2, not
2897        // `stale + 1`, so the CAS cannot win.
2898        assert!(
2899            !state.try_rollback_tx_seq(stale),
2900            "rollback must refuse once a newer seq has been issued"
2901        );
2902
2903        // Counter is intact — the next allocation is still 2, so the
2904        // concurrent sender's seq 1 is never re-issued as a duplicate.
2905        assert_eq!(state.next_tx_seq(), 2);
2906    }
2907
2908    /// The session-level wrapper is epoch-guarded: a rollback aimed at a
2909    /// different epoch (post close+reopen) is a no-op and never touches
2910    /// the live stream state, mirroring the credit-refund discipline in
2911    /// `TxSlotGuard::drop`.
2912    #[test]
2913    fn session_try_rollback_tx_seq_is_epoch_guarded() {
2914        let keys = test_keys();
2915        let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
2916        let session = Arc::new(NetSession::new(keys, addr, 4, false));
2917
2918        let stream_id = 0x55;
2919        // Consume a seq, then release the DashMap `RefMut` before the
2920        // session-level rollback re-locks the same map.
2921        let (epoch, seq) = {
2922            let state = session.get_or_create_stream(stream_id);
2923            (state.epoch(), state.next_tx_seq())
2924        };
2925
2926        // Wrong epoch: no-op, returns false, counter untouched.
2927        assert!(!session.try_rollback_tx_seq(stream_id, epoch.wrapping_add(1), seq));
2928        assert_eq!(session.try_stream(stream_id).unwrap().current_tx_seq(), 1);
2929
2930        // Correct epoch: reclaims the seq.
2931        assert!(session.try_rollback_tx_seq(stream_id, epoch, seq));
2932        assert_eq!(session.try_stream(stream_id).unwrap().current_tx_seq(), 0);
2933    }
2934
2935    /// Regression: `outstanding()` must never observe a transient
2936    /// `consumed > granted` inversion under contention.
2937    ///
2938    /// Pre-fix `on_bytes_consumed` bumped `consumed` before
2939    /// `granted`, while `outstanding()` loaded `granted` then
2940    /// `consumed`. A reader catching the in-flight window saw
2941    /// `granted` from before a writer's bump but `consumed` from
2942    /// after — `consumed > granted`, masked by `saturating_sub` to
2943    /// zero. With the writer-side priming `granted = window_bytes`,
2944    /// the post-fix invariant is `outstanding() >= window_bytes` at
2945    /// every instant: the publication order (granted first, then
2946    /// consumed) plus the matching reader order (consumed first,
2947    /// then granted) guarantees any observed `consumed` increment is
2948    /// paired with its `granted` increment by the time the reader
2949    /// loads `granted`.
2950    ///
2951    /// Setup: `window_bytes = K`, every writer call mints `K`
2952    /// matched bytes. Pre-fix the reader sees outstanding=0 mid-flight;
2953    /// post-fix the reader always sees outstanding >= K.
2954    #[test]
2955    fn rx_credit_outstanding_never_inverts_under_contention() {
2956        use std::sync::atomic::{AtomicBool, AtomicU64};
2957        use std::sync::Arc;
2958        use std::thread;
2959
2960        const WINDOW: u32 = 64;
2961        const WRITERS: usize = 6;
2962        const ITERATIONS: usize = 50_000;
2963
2964        let state = Arc::new(StreamState::new_full(false, 1, WINDOW));
2965        let stop = Arc::new(AtomicBool::new(false));
2966        let min_seen = Arc::new(AtomicU64::new(u64::MAX));
2967        let reader_loops = Arc::new(AtomicU64::new(0));
2968
2969        // Reader: spin on `outstanding()` and record the minimum
2970        // value observed. Stops as soon as the writers signal done.
2971        let reader = {
2972            let state = Arc::clone(&state);
2973            let stop = Arc::clone(&stop);
2974            let min_seen = Arc::clone(&min_seen);
2975            let reader_loops = Arc::clone(&reader_loops);
2976            thread::spawn(move || {
2977                while !stop.load(std::sync::atomic::Ordering::Relaxed) {
2978                    let v = state.rx_credit().outstanding();
2979                    let mut current = min_seen.load(std::sync::atomic::Ordering::Relaxed);
2980                    while v < current {
2981                        match min_seen.compare_exchange_weak(
2982                            current,
2983                            v,
2984                            std::sync::atomic::Ordering::Relaxed,
2985                            std::sync::atomic::Ordering::Relaxed,
2986                        ) {
2987                            Ok(_) => break,
2988                            Err(seen) => current = seen,
2989                        }
2990                    }
2991                    reader_loops.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2992                }
2993            })
2994        };
2995
2996        // Writers: pound `on_bytes_consumed(WINDOW)` so each call
2997        // moves both counters by exactly the priming window. With
2998        // K == WINDOW, any inversion of the publication order
2999        // surfaces as `consumed > granted` and saturates to zero.
3000        let writers: Vec<_> = (0..WRITERS)
3001            .map(|_| {
3002                let state = Arc::clone(&state);
3003                thread::spawn(move || {
3004                    for _ in 0..ITERATIONS {
3005                        let _ = state.on_bytes_consumed(WINDOW as u64);
3006                    }
3007                })
3008            })
3009            .collect();
3010
3011        for w in writers {
3012            w.join().unwrap();
3013        }
3014        stop.store(true, std::sync::atomic::Ordering::Relaxed);
3015        reader.join().unwrap();
3016
3017        // Sanity: the reader actually got CPU time. Without this,
3018        // the assertion below would silently pass on a single-core
3019        // / over-subscribed runner.
3020        assert!(
3021            reader_loops.load(std::sync::atomic::Ordering::Relaxed) > 1_000,
3022            "reader did not get enough CPU time to exercise the race",
3023        );
3024
3025        // The strong invariant: outstanding never drops below the
3026        // priming window. Pre-fix this falls to 0 under contention.
3027        let observed_min = min_seen.load(std::sync::atomic::Ordering::Relaxed);
3028        assert!(
3029            observed_min >= WINDOW as u64,
3030            "outstanding() inverted under contention: min observed = {} (must be >= {})",
3031            observed_min,
3032            WINDOW,
3033        );
3034    }
3035
3036    #[test]
3037    fn test_rx_credit_window_zero_disables_grants() {
3038        let state = StreamState::new_full(false, 1, 0);
3039        // No backpressure → no grants.
3040        assert_eq!(state.on_bytes_consumed(1_000_000), None);
3041    }
3042
3043    #[test]
3044    fn test_regression_reliable_duplicate_must_not_mint_grant() {
3045        // Regression: the mesh dispatcher (`process_local_packet`)
3046        // must gate `on_bytes_consumed` on the reliability layer's
3047        // `on_receive` return. Otherwise retransmissions / replays
3048        // of already-acked sequences on Reliable streams refund
3049        // sender credit through the grant path, inflating
3050        // `tx_credit_remaining` on the sender and distorting the
3051        // `backpressure_events` picture.
3052        //
3053        // This test exercises the primitives the dispatcher
3054        // composes. The dispatcher's gate itself is verified
3055        // implicitly by the three-node integration suite; this
3056        // primitive-level check is the tight loop that fails
3057        // fastest if the invariant regresses.
3058        let state = StreamState::new_full(true, 1, 100); // reliable
3059
3060        // First packet at seq=0: accepted → credit the bytes.
3061        assert!(
3062            state.with_reliability(|r| r.on_receive(0)),
3063            "new seq must be accepted"
3064        );
3065        assert_eq!(state.on_bytes_consumed(40), Some(40));
3066
3067        // Replay of seq=0: rejected. The dispatcher MUST NOT call
3068        // `on_bytes_consumed` in this branch. We document that
3069        // invariant by NOT calling it here — if the dispatcher
3070        // ever un-gates, the matching integration test would
3071        // observe inflated grants / distorted credit accounting.
3072        assert!(
3073            !state.with_reliability(|r| r.on_receive(0)),
3074            "duplicate seq must be rejected by the reliability layer"
3075        );
3076
3077        // Sanity: the rx-credit state reflects only the one
3078        // accepted packet — `granted = window_bytes + 40`,
3079        // `consumed = 40`.
3080        let rx = state.rx_credit();
3081        assert_eq!(rx.consumed(), 40);
3082        assert_eq!(rx.granted(), 100 + 40);
3083    }
3084
3085    fn session_with_stream(stream_id: u64, tx_window: u32) -> Arc<NetSession> {
3086        let session = Arc::new(NetSession::new(
3087            test_keys(),
3088            "127.0.0.1:9999".parse().unwrap(),
3089            4,
3090            false,
3091        ));
3092        session.open_stream_full(stream_id, false, 1, tx_window);
3093        session
3094    }
3095
3096    #[test]
3097    fn test_regression_tx_credit_guard_refunds_on_drop() {
3098        // Regression: without the RAII guard, `send_on_stream`'s
3099        // acquire-await-commit shape leaks credit if the send future
3100        // is dropped mid-`.await` (tokio::select! racing a shutdown,
3101        // caller abort, panic). Over many cancellations the window
3102        // would drift toward permanent exhaustion.
3103        //
3104        // Fix: `try_acquire_tx_credit_guard` returns a `TxSlotGuard`
3105        // that refunds the acquired bytes in its Drop impl — unless
3106        // the caller calls `commit()` first to signal a successful
3107        // wire send.
3108        let stream_id = 0x7u64;
3109        let session = session_with_stream(stream_id, 100);
3110
3111        let guard = match session.try_acquire_tx_credit_guard(stream_id, 100) {
3112            TxAdmit::Acquired { guard, .. } => guard,
3113            other => panic!("expected Acquired, got {:?}", other),
3114        };
3115        assert_eq!(
3116            session.try_stream(stream_id).unwrap().tx_credit_remaining(),
3117            0,
3118            "guard's acquire drained the window"
3119        );
3120        assert!(matches!(
3121            session.try_acquire_tx_credit_guard(stream_id, 1),
3122            TxAdmit::WindowFull
3123        ));
3124
3125        // Drop without commit → bytes flow back.
3126        drop(guard);
3127        assert_eq!(
3128            session.try_stream(stream_id).unwrap().tx_credit_remaining(),
3129            100,
3130            "dropping an uncommitted guard refunds the credit"
3131        );
3132        assert!(matches!(
3133            session.try_acquire_tx_credit_guard(stream_id, 50),
3134            TxAdmit::Acquired { .. }
3135        ));
3136    }
3137
3138    #[test]
3139    fn test_tx_credit_guard_commit_suppresses_refund() {
3140        // commit() marks the bytes as "gone on the wire" — Drop must
3141        // NOT refund them. The receiver is responsible for replenishing
3142        // via a StreamWindow grant.
3143        let stream_id = 0x17u64;
3144        let session = session_with_stream(stream_id, 100);
3145
3146        let guard = match session.try_acquire_tx_credit_guard(stream_id, 40) {
3147            TxAdmit::Acquired { guard, .. } => guard,
3148            other => panic!("expected Acquired, got {:?}", other),
3149        };
3150        guard.commit();
3151        assert_eq!(
3152            session.try_stream(stream_id).unwrap().tx_credit_remaining(),
3153            60,
3154            "committed bytes stay consumed"
3155        );
3156    }
3157
3158    #[test]
3159    fn test_tx_credit_guard_stream_closed_variant() {
3160        let session = session_with_stream(0x9, 100);
3161        session.close_stream(0x9);
3162        assert!(matches!(
3163            session.try_acquire_tx_credit_guard(0x9, 10),
3164            TxAdmit::StreamClosed
3165        ));
3166    }
3167
3168    #[test]
3169    fn test_tx_credit_guard_close_between_acquire_and_drop_no_panic() {
3170        // Scenario: caller acquires, another task closes, caller
3171        // drops. The Drop impl's `try_stream` lookup returns None →
3172        // no-op. Must not panic / resurrect state.
3173        let stream_id = 0xAu64;
3174        let session = session_with_stream(stream_id, 100);
3175        let guard = match session.try_acquire_tx_credit_guard(stream_id, 40) {
3176            TxAdmit::Acquired { guard, .. } => guard,
3177            other => panic!("expected Acquired, got {:?}", other),
3178        };
3179        session.close_stream(stream_id);
3180        assert!(session.try_stream(stream_id).is_none());
3181        drop(guard); // no-op (state is gone); must not panic
3182        assert!(session.try_stream(stream_id).is_none());
3183    }
3184
3185    #[test]
3186    fn test_tx_credit_guard_forget_leaves_credit_consumed() {
3187        // forget() is a test-only escape hatch simulating a leaked
3188        // slot — same effect as commit() but semantically labelled as
3189        // "don't refund because the bytes are lost, not sent."
3190        let session = session_with_stream(0xF, 100);
3191        let g = match session.try_acquire_tx_credit_guard(0xF, 40) {
3192            TxAdmit::Acquired { guard, .. } => guard,
3193            other => panic!("expected Acquired, got {:?}", other),
3194        };
3195        g.forget();
3196        assert_eq!(
3197            session.try_stream(0xF).unwrap().tx_credit_remaining(),
3198            60,
3199            "forget() skips the Drop refund"
3200        );
3201    }
3202
3203    #[test]
3204    fn test_regression_guard_drop_after_reopen_does_not_corrupt_new_stream() {
3205        // Regression: `TxSlotGuard::drop` must not refund credit onto
3206        // a fresh `StreamState` that never issued the guard. Epoch
3207        // check gates the refund.
3208        let sid = 0x42u64;
3209        let session = session_with_stream(sid, 100);
3210
3211        let g = match session.try_acquire_tx_credit_guard(sid, 60) {
3212            TxAdmit::Acquired { guard, .. } => guard,
3213            other => panic!("expected Acquired, got {:?}", other),
3214        };
3215        let first_epoch = g.epoch_for_test();
3216        assert_eq!(session.try_stream(sid).unwrap().tx_credit_remaining(), 40);
3217
3218        // Close + reopen → fresh state with a new epoch + full credit.
3219        session.close_stream(sid);
3220        session.open_stream_full(sid, false, 1, 100);
3221        let second_epoch = session.try_stream(sid).unwrap().epoch();
3222        assert_ne!(first_epoch, second_epoch, "reopen allocates a new epoch");
3223        assert_eq!(
3224            session.try_stream(sid).unwrap().tx_credit_remaining(),
3225            100,
3226            "fresh stream starts at full credit"
3227        );
3228
3229        // Drop the stale guard — must NOT inflate the new stream's
3230        // credit beyond its configured window.
3231        drop(g);
3232        assert_eq!(
3233            session.try_stream(sid).unwrap().tx_credit_remaining(),
3234            100,
3235            "stale guard must NOT refund onto the new stream's counter"
3236        );
3237    }
3238
3239    #[test]
3240    fn test_regression_acquire_with_expected_epoch_rejects_after_reopen() {
3241        let sid = 0x88u64;
3242        let session = session_with_stream(sid, 100);
3243        let original_epoch = session.try_stream(sid).unwrap().epoch();
3244
3245        session.close_stream(sid);
3246        session.open_stream_full(sid, false, 1, 100);
3247
3248        assert!(matches!(
3249            session.try_acquire_tx_credit_matching_epoch(sid, original_epoch, 10),
3250            TxAdmit::StreamClosed
3251        ));
3252        assert_eq!(
3253            session.try_stream(sid).unwrap().tx_credit_remaining(),
3254            100,
3255            "rejected acquire leaves new stream's credit untouched"
3256        );
3257
3258        let cur_epoch = session.try_stream(sid).unwrap().epoch();
3259        assert!(matches!(
3260            session.try_acquire_tx_credit_matching_epoch(sid, cur_epoch, 10),
3261            TxAdmit::Acquired { .. }
3262        ));
3263    }
3264
3265    #[test]
3266    fn test_regression_no_double_counting_grant_and_refund() {
3267        // Double-counting trap: if both a grant AND a successful-send
3268        // refund credit the window for the same bytes, every round
3269        // trip doubles effective capacity. The v2 invariant: commit()
3270        // suppresses the refund; only a grant replenishes committed
3271        // bytes.
3272        let stream_id = 0x100u64;
3273        let session = session_with_stream(stream_id, 200);
3274
3275        // Send: acquire 100 bytes, commit.
3276        let g = match session.try_acquire_tx_credit_guard(stream_id, 100) {
3277            TxAdmit::Acquired { guard, .. } => guard,
3278            other => panic!("expected Acquired, got {:?}", other),
3279        };
3280        g.commit();
3281        assert_eq!(
3282            session.try_stream(stream_id).unwrap().tx_credit_remaining(),
3283            100,
3284            "after commit, 100 bytes consumed against a 200-byte window"
3285        );
3286
3287        // Authoritative grant reporting total_consumed=100: the
3288        // receiver has accepted the 100 bytes we committed, so
3289        // outstanding = 0 and credit returns to the full window.
3290        session
3291            .try_stream(stream_id)
3292            .unwrap()
3293            .apply_authoritative_grant(100);
3294        assert_eq!(
3295            session.try_stream(stream_id).unwrap().tx_credit_remaining(),
3296            200,
3297            "grant restores committed credit exactly once"
3298        );
3299
3300        // CRITICAL: replaying the same grant (stale duplicate) is
3301        // ignored by the monotonic `max_consumed_seen` check. No
3302        // spurious inflation past the original window — the
3303        // authoritative-grant design makes double-counting
3304        // impossible even if the grant arrives multiple times.
3305        session
3306            .try_stream(stream_id)
3307            .unwrap()
3308            .apply_authoritative_grant(100);
3309        assert_eq!(
3310            session.try_stream(stream_id).unwrap().tx_credit_remaining(),
3311            200,
3312            "replaying a stale grant must not inflate credit",
3313        );
3314    }
3315
3316    #[test]
3317    fn test_regression_stale_grant_quarantined_after_close_reopen() {
3318        // Regression (P1): a `StreamWindow` grant keyed only by
3319        // stream_id could credit a reopened stream with credit
3320        // minted against the previous lifetime's `StreamState`.
3321        // Fix: `close_stream` stamps the stream_id into
3322        // `recently_closed`; `is_grant_quarantined` tells the
3323        // dispatcher to drop grants that arrive within
3324        // `GRANT_QUARANTINE_WINDOW`.
3325        let sid = 0x2077u64;
3326        let session = session_with_stream(sid, 100);
3327
3328        // Mid-flight: close the stream, reopen with the same id.
3329        session.close_stream(sid);
3330        session.open_stream_full(sid, false, 1, 100);
3331
3332        // An arriving grant for `sid` must be quarantined because
3333        // the original lifetime was closed inside the window.
3334        assert!(
3335            session.is_grant_quarantined(sid),
3336            "grants for recently-closed stream must be dropped"
3337        );
3338
3339        // The reopened stream's credit is untouched — we don't call
3340        // apply_authoritative_grant under quarantine.
3341        assert_eq!(session.try_stream(sid).unwrap().tx_credit_remaining(), 100);
3342    }
3343
3344    #[test]
3345    fn test_grant_quarantine_does_not_fire_without_close() {
3346        // Baseline: streams that were never closed aren't in the
3347        // quarantine set. Grants flow normally.
3348        let sid = 0x2099u64;
3349        let session = session_with_stream(sid, 100);
3350        assert!(!session.is_grant_quarantined(sid));
3351    }
3352
3353    #[test]
3354    fn test_regression_control_seq_isolated_from_user_stream() {
3355        // Regression: `spawn_stream_window_grant` used to draw the
3356        // grant packet's sequence from
3357        // `get_or_create_stream(SUBPROTOCOL_STREAM_WINDOW as u64)`,
3358        // so a user stream opened with the numerically-equal id
3359        // (0x0B00) would share sequence state with control traffic.
3360        //
3361        // Fix: grants ride on the `CONTROL_STREAM_ID` sentinel
3362        // (`u64::MAX`) with a dedicated session-level
3363        // `next_control_tx_seq` counter. This test verifies that
3364        // opening a user stream at the old-collision id leaves its
3365        // tx_seq untouched while control-seq advances independently.
3366        let session = Arc::new(NetSession::new(
3367            test_keys(),
3368            "127.0.0.1:9999".parse().unwrap(),
3369            4,
3370            false,
3371        ));
3372        let user_sid = 0x0B00u64; // the old collision target
3373        session.open_stream_full(user_sid, false, 1, 100);
3374        let user_tx_seq_before = session.try_stream(user_sid).unwrap().current_tx_seq();
3375
3376        // Burn some control-seq as though grants had gone out.
3377        let ctrl_a = session.next_control_tx_seq();
3378        let ctrl_b = session.next_control_tx_seq();
3379        let ctrl_c = session.next_control_tx_seq();
3380        assert_eq!((ctrl_a, ctrl_b, ctrl_c), (0, 1, 2));
3381
3382        // User stream's tx_seq must NOT have moved.
3383        assert_eq!(
3384            session.try_stream(user_sid).unwrap().current_tx_seq(),
3385            user_tx_seq_before,
3386        );
3387
3388        // Conversely, a user send on the same stream must not
3389        // advance the control-seq counter.
3390        session.try_stream(user_sid).unwrap().next_tx_seq();
3391        assert_eq!(session.next_control_tx_seq(), 3);
3392    }
3393
3394    #[test]
3395    fn test_regression_admit_and_seq_atomic_across_reopen_race() {
3396        // Regression (P2): `send_on_stream` used to acquire credit
3397        // and then re-look up the stream to fetch `next_tx_seq`.
3398        // A concurrent close+reopen between the two lookups would
3399        // debit credit on the old state while the sequence came
3400        // from the new state — crossing lifetimes and defeating
3401        // the epoch guard's safety.
3402        //
3403        // Fix: `try_acquire_tx_credit_*` now returns both the guard
3404        // and the sequence under one DashMap lookup. This test
3405        // verifies that the admitted sequence belongs to the same
3406        // `StreamState` as the one that was debited.
3407        let sid = 0x3141u64;
3408        let session = session_with_stream(sid, 100);
3409        let epoch_before = session.try_stream(sid).unwrap().epoch();
3410        let tx_seq_before = session.try_stream(sid).unwrap().current_tx_seq();
3411
3412        let (guard, seq) = match session.try_acquire_tx_credit_matching_epoch(sid, epoch_before, 40)
3413        {
3414            TxAdmit::Acquired { guard, seq } => (guard, seq),
3415            other => panic!("expected Acquired, got {:?}", other),
3416        };
3417        guard.commit();
3418
3419        // The sequence must come from the state that was debited —
3420        // i.e., the next `current_tx_seq` is one greater than the
3421        // value observed before, not zero (as it would be if the
3422        // seq had come from a fresh state after an intervening
3423        // reopen).
3424        let after = session.try_stream(sid).unwrap();
3425        assert_eq!(seq, tx_seq_before);
3426        assert_eq!(after.current_tx_seq(), tx_seq_before + 1);
3427        assert_eq!(after.epoch(), epoch_before);
3428        assert_eq!(after.tx_credit_remaining(), 60);
3429    }
3430
3431    impl TxSlotGuard {
3432        /// Test-only accessor for the captured epoch.
3433        fn epoch_for_test(&self) -> u64 {
3434            self.epoch
3435        }
3436    }
3437
3438    /// CR-12: pin that no `tx_key` method exists on `NetSession`.
3439    /// This is a source-string tripwire — if a future maintainer
3440    /// reintroduces the accessor, the test fires loudly. The hazard
3441    /// it gates (cross-pool nonce reuse) is dormant
3442    /// unless someone calls a `tx_key()` method, so a behavioural
3443    /// test would not catch the regression in time. We assemble
3444    /// the forbidden token at runtime so the test's OWN source
3445    /// doesn't contain the literal it scans for.
3446    #[test]
3447    fn cr12_tx_key_accessor_must_not_exist_on_net_session() {
3448        // Build the forbidden token at runtime: `fn` + space + `tx_key` + `(`.
3449        // The literal `fn tx_key(` shape is what we must NOT see in
3450        // a non-comment line in the source.
3451        let needle = format!("{} {}{}", "fn", "tx_key", "(");
3452
3453        let src = include_str!("session.rs");
3454        for line in src.lines() {
3455            let trimmed = line.trim_start();
3456            if trimmed.starts_with("//") {
3457                continue; // doc-comment / line comment
3458            }
3459            assert!(
3460                !trimmed.contains(&needle),
3461                "CR-12 regression: tx_key accessor reintroduced into session.rs:\n  {}",
3462                line
3463            );
3464        }
3465    }
3466
3467    /// Regression: `verify_and_touch_heartbeat` short-circuits
3468    /// any `parsed.payload.len() != TAG_SIZE` packet before
3469    /// invoking the cipher. AEAD decryption would catch the
3470    /// mismatch on its own, but the pre-check shortcuts a
3471    /// cleartext-flood attacker spamming undersized / oversized
3472    /// payloads to drain CPU on the decrypt path. The
3473    /// session must be unmutated on rejection — a flood that
3474    /// nudged `last_activity` would still be a side-channel for
3475    /// liveness inference.
3476    #[test]
3477    fn verify_and_touch_heartbeat_rejects_wrong_length_before_decrypt() {
3478        use super::super::protocol::{NetHeader, PacketFlags, TAG_SIZE};
3479        use bytes::Bytes;
3480
3481        let keys = test_keys();
3482        let peer_addr: SocketAddr = "127.0.0.1:9999".parse().unwrap();
3483        let session = NetSession::new(keys.clone(), peer_addr, 4, false);
3484
3485        // Capture last_activity before the spoof attempts so we
3486        // can assert no mutation.
3487        let baseline_activity = session.last_activity.load(Ordering::Acquire);
3488
3489        // Build a fake heartbeat header with a ciphertext that
3490        // ISN'T 16 bytes — the AEAD would reject this anyway,
3491        // but we want to assert the length gate fires first
3492        // (no cipher work, no last_activity nudge).
3493        let mut nonce = [0u8; 12];
3494        nonce[0..4].copy_from_slice(&crate::adapter::net::crypto::session_prefix_from_id(
3495            keys.session_id,
3496        ));
3497        nonce[4..12].copy_from_slice(&0u64.to_le_bytes());
3498
3499        let header = NetHeader::new(
3500            keys.session_id,
3501            0, // stream_id
3502            0, // sequence
3503            nonce,
3504            0, // payload_len
3505            0, // event_count
3506            PacketFlags::HEARTBEAT,
3507        );
3508
3509        for bad_len in [0usize, 1, TAG_SIZE - 1, TAG_SIZE + 1, 64] {
3510            let parsed = ParsedPacket {
3511                header,
3512                payload: Bytes::from(vec![0u8; bad_len]),
3513                source: peer_addr,
3514            };
3515            assert!(
3516                !session.verify_and_touch_heartbeat(&parsed),
3517                "wrong-length payload ({bad_len} bytes, expected {TAG_SIZE}) \
3518                 must be rejected before AEAD decrypt"
3519            );
3520            assert_eq!(
3521                session.last_activity.load(Ordering::Acquire),
3522                baseline_activity,
3523                "rejected heartbeat must not advance last_activity ({bad_len} bytes)"
3524            );
3525        }
3526    }
3527}