# Bug audit — 2026-05-03 (mesh.rs)

Single-file deep read of `adapter/net/mesh.rs` (~8k LOC). Findings are real, demonstrable defects scoped to this file (not style or speculation about callers). Numbered sequentially within severity buckets.

## Status

- **Remaining High:** 3
- **Remaining Medium:** 4
- **Remaining Low / inconsistencies:** 2

## High

1. **`adapter/net/mesh.rs:3513-3536` (`spawn_heartbeat_loop`) — DashMap shard guard held across `.await`.** The heartbeat broadcast loop iterates `peers.iter()` and `await`s on `socket.send_to(&packet, peer_addr)` (twice per peer — heartbeat then pingwave) while still holding the iterator's `Ref` guard on the current shard. While the loop is mid-shard, every other task that touches the same shard blocks: `connect()` / `accept()` / `handle_routed_handshake` (`peers.insert`), `send_to_peer` / `process_local_packet` (`peers.get` / `peers.iter().find`), `send_routed`, `publish_to_peer`, `request_punch`, etc. Under N peers and any non-trivial `send_to` latency, a heartbeat tick blocks shard writers for the cumulative round-trip of N×2 sends. The same anti-pattern would be flagged by `clippy::await_holding_lock` if DashMap's guards were a `MutexGuard`. Fix: snapshot `(node_id, addr, Arc<NetSession>)` tuples into a `Vec` first, then await without holding the iterator.

2. **`adapter/net/mesh.rs:1828, 1904` (`accept` / `start` mutual exclusion) — `AcqRel` increment used where the comment relies on SeqCst total order.** The doc-comment at lines 1801-1820 explicitly argues correctness from "the SeqCst total order on these two atomics makes 'either accept observes start and bails, or start observes accept and refuses' mutually exclusive." But the code uses `accept_in_flight.fetch_add(1, AtOrd::AcqRel)` (and the matching `fetch_sub` in `AcceptGuard::drop`), so the counter increments are *not* part of the SC total order. Two-thread Dekker-style mutual exclusion needs both sides SC. On x86 the LOCK'd RMW happens to fully fence so the race is unobservable; on AArch64 / RISC-V (weakly ordered, atomic RMWs without explicit SC don't get the full bidirectional fence), `start()` can read the pre-increment 0 in `accept_in_flight` while `accept()` simultaneously reads `started == false`, allowing both to proceed and the dispatcher to race `handshake_responder` for the inbound msg1 — exactly the hazard the counter was added to prevent. Fix: change the `fetch_add` and the `AcceptGuard::drop` `fetch_sub` to `SeqCst`.

3. **`adapter/net/mesh.rs:2761-2791` (`handle_routed_handshake`) — Routed-handshake key rotation silently overwrites a live session.** The replay guard only fires for the *same* `remote_static_pub`; a routed msg1 with a *different* static for the same `peer_node_id` falls through and `peers.insert` overwrites the existing legitimate session. Comment says "let the initiator's matching-keys check resolve which" but this path is the responder side — the responder unilaterally swaps in attacker keys, after which the legitimate peer's subsequent AEAD packets (encrypted under the old session key) fail to verify and are silently dropped. The attacker only needs the PSK and a second valid Noise static of their own; the legitimate session is wedged with no recovery short of full re-handshake. The trusted-PSK threat model rationalises this only if PSK compromise is treated as "any node can DoS any other node's sessions" — which contradicts the rest of the auth surface (entity-ID TOFU pinning, signed capability announcements). Fix: gate the rotation path on a TOFU-verified `peer_entity_ids` match, or refuse rotation entirely while the existing session is still within idle / heartbeat window.

## Medium

4. **`adapter/net/mesh.rs:7154-7157` (`commit_reclassify_observations`) — Torn `(nat_class, reflex_addr)` snapshot when probes return no observation.**
    ```rust
    self.nat_class.store(class.as_u8(), Ordering::Release);
    if let Some(addr) = latest_reflex {
        self.reflex_addr.store(Some(Arc::new(addr)));
    }
    ```
    When every probe failed, `latest_reflex == None`. The code still updates `nat_class` (typically to `Unknown`) but leaves `reflex_addr` at its previous value. Subsequent `announce_capabilities_with` reads both under `traversal_publish_mu` expecting a coherent pair — but the pair the classifier just published is `(fresh class, stale reflex)`. The whole `traversal_publish_mu` invariant (mutex around the triple-write so multi-field readers see a consistent snapshot) is silently violated on this branch. Fix: also reset `reflex_addr` to `None` when `latest_reflex` is `None`, or skip the `nat_class` write entirely (matching the comment in `reclassify_nat` at line 7098-7104 which deliberately leaves classification unchanged on deadline-expired sweeps).

5. **`adapter/net/mesh.rs:4681-4683` (`authorize_subscribe`) — `TooManyChannels` rejects idempotent re-subscribes.** The cap-check `roster.channels_for_peer_count(from_node) >= ctx.max_channels_per_peer` runs before any "is this channel already on this peer's list" check. A peer sitting at the cap that retransmits / re-subscribes to a channel it already holds is rejected, even though `SubscriberRoster` is set-typed and the operation is a no-op. The rejection AckReason (`TooManyChannels`) is filtered out of the auth-failure budget at line 3985-3996, so it doesn't trip the throttle, but the legitimate re-subscribe still fails. Fix: short-circuit `(true, None)` when `roster.channels_for(from_node)` already contains `channel`, before the cap-check.

6. **`adapter/net/mesh.rs:2761-2791` (`handle_routed_handshake`) — `peers.get` → `peers.insert` is not atomic.** The replay-guard `peers.get(&peer_node_id)` read-guard is dropped before the subsequent `peers.insert`. Two concurrent routed handshakes for the same `peer_node_id` (e.g. a flaky peer that retried under a fresh ephemeral) can both pass the `existing.remote_static_pub` check and race the insert; the loser's `pending_handshakes` initiator state stays armed waiting for a msg2 that's now bound to the winner's session, until the per-call `handshake_timeout` fires. Fix: hold the decision and the insert under a single `peers.entry(peer_node_id)` write guard.

7. **`adapter/net/mesh.rs:5255-5261` (`publish_to_peer`) — Reliable flag not propagated to packet header.** Every other sender (`send_to_peer:3661`, `send_to_peer:3685`, `send_routed:3755`, `send_routed:3785`, `send_on_stream:6110`, `mod.rs:1016, 1063`) computes `if reliable { PacketFlags::RELIABLE } else { PacketFlags::NONE }` and threads it into the packet builder. `publish_to_peer` hard-codes `PacketFlags::NONE` and only feeds `reliable` into `open_stream_with`. Today the dispatch path doesn't inspect `flags.is_reliable()` (reliability is per-stream, set on open), so this is latent — but it's a per-call-site inconsistency that will silently bite when receiver-side code starts consulting the packet flag (the `proxy.rs` / `route.rs` / `router.rs` paths already do for `is_priority` / `is_control` — `is_reliable` is the obvious next addition). Fix: same `if reliable { … }` ternary as the other sender call-sites.

## Low / inconsistencies

8. **`adapter/net/mesh.rs:2952-2965` (`process_local_packet` migration loopback) — Unbounded synchronous self-bounce.** The in-place `pending: VecDeque` keeps draining as long as the handler emits self-bound follow-ups. Handler is "trusted", but a buggy handler (or attacker-influenced state inside a trusted handler) that always emits a self-bound message would spin the dispatch task synchronously, starving every other peer's packets. Fix: cap the loopback depth (e.g. 32) and `tracing::warn!` past it, or insert a `tokio::task::yield_now()` per turn so the receive loop can interleave.

9. **`adapter/net/mesh.rs:6357-6379` (`connect_via`) — `addr_to_node` not refreshed after a successful direct upgrade.** The doc comment notes `addr_to_node` is deliberately not written for relayed sessions (correct — `relay_addr` should keep mapping to the relay's own node_id). But after a successful `connect_direct → connect_via(peer_reflex, …)` the new session points directly at the peer; the dispatch fast path (`addr_to_node.get(&source) → peers.get(nid)`) misses on `peer_reflex` and falls back to a linear `peers.iter().find(|e| session_id == ...)` per packet for the upgraded session. Performance only, but it defeats the addr→nid index for exactly the sessions that benefit most from it. Fix: in the `connect_direct` `Ok` path (after `connect_on_direct_path` succeeds and the upgraded session is the live one), insert the `(peer_reflex, peer_node_id)` mapping. The relayed-session note in `connect_via` itself is still correct — the upgrade is a separate caller.
