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}