refactor: improve networking layer with enhanced engine, muxer, and diagnostics

- Refactored connection engine with better state management
- Improved muxer with enhanced protocol handling
- Updated connection bridge and diagnostics
- Added new network constants and configuration options
- Enhanced session tracking and error handling
- Updated dependencies in Cargo.toml
This commit is contained in:
Kirill
2026-06-30 12:34:04 +07:00
parent 38dfd588c1
commit 18cad76e38
16 changed files with 1051 additions and 277 deletions
+11 -4
View File
@@ -40,10 +40,17 @@ impl NetworkConfig {
const BULK_WINDOW_SEGMENTS: usize = 128;
const LIGHT_WINDOW_SEGMENTS: usize = 32;
// How many MTU-sized packets the Tokio mpsc channel should hold.
// At MTU 1450 and 128 slots: ~185 KB per channel — enough to absorb
// ~15 ms of jitter at 100 Mbps without spawning backpressure tasks.
const CHANNEL_PACKETS: usize = 128;
// How many messages the Tokio mpsc channels hold.
//
// 🔥 ANTI-BUFFERBLOAT: this is the dominant app-layer queue on every
// tunnel leg. A single server→leg data message can be up to one read
// buffer (~180 KB), so 128 slots meant up to ~23 MB of in-flight data
// QUEUED per leg. After a speedtest that reservoir is full of data for
// streams the app already closed; the downlink wastes seconds draining
// it (observed: mux_dispatch no_stream ≫ ok, RTT → 1.3 s, tunnel "dies").
// 16 slots bounds the per-leg queue ~8× lower so it drains in ~1 s and
// RTT stays low, while still keeping the writer fed for full throughput.
const CHANNEL_PACKETS: usize = 16;
// Payload bytes per segment (no IP/TCP headers in the smoltcp buffer).
let seg = mtu.saturating_sub(40).max(512); // subtract typical IP+TCP overhead
+55 -11
View File
@@ -1,9 +1,13 @@
use std::sync::Arc;
use crate::net::connection::muxer::Muxer;
use crate::net::{NetworkConfig, BRIDGE_IDLE_TIMEOUT};
use crate::net::connection::muxer::{adaptive_write_timeout, Muxer};
use crate::net::{
NetworkConfig, BRIDGE_IDLE_TIMEOUT, BRIDGE_READ_CHUNK, BRIDGE_STREAM_WRITE_TIMEOUT,
STREAM_PAUSE_BUDGET, STREAM_PAUSE_RETRY,
};
use bytes::{Bytes, BytesMut};
use netrunner_logger::{debug, error, info, warn};
use std::time::Instant;
use tokio::net::UdpSocket;
use tokio::sync::mpsc;
use tokio::time::timeout;
@@ -34,7 +38,6 @@ pub(crate) async fn run_tcp_bridge<R, W>(
stream_id,
muxer: muxer.clone(),
};
let buf_size = NetworkConfig::global().tcp_buffer_size;
let token = CancellationToken::new();
// Upload: internet → tunnel.
@@ -45,10 +48,13 @@ pub(crate) async fn run_tcp_bridge<R, W>(
let token = token.clone();
async move {
let mut reader = reader;
let mut buf = BytesMut::with_capacity(buf_size);
// Read in ≤ BRIDGE_READ_CHUNK (one-frame) units so a single data
// message can't be huge. Combined with CHANNEL_PACKETS this byte-bounds
// the per-leg queue and keeps post-speedtest bufferbloat small.
let mut buf = BytesMut::with_capacity(BRIDGE_READ_CHUNK);
loop {
if buf.capacity() < 16384 {
buf.reserve(buf_size);
if buf.capacity() - buf.len() < BRIDGE_READ_CHUNK {
buf.reserve(BRIDGE_READ_CHUNK);
}
tokio::select! {
biased;
@@ -57,7 +63,31 @@ pub(crate) async fn run_tcp_bridge<R, W>(
Ok(0) | Err(_) => break,
Ok(_) => {
let data = buf.split().freeze();
if muxer.send_data_safe(stream_id, data, false).await.is_err() {
// 🔥 GRACEFUL PAUSE (anti-domino).
// send_data_safe already fails over between live legs;
// it only errors when EVERY leg is down. In that case we
// do NOT close the stream — we hold this chunk and retry
// while the engine reconnects, bounded by STREAM_PAUSE_BUDGET.
// Because we stop reading meanwhile, TCP back-pressure
// naturally pauses the source instead of dropping data.
let deadline = Instant::now() + STREAM_PAUSE_BUDGET;
let mut delivered = false;
loop {
if muxer.send_data_safe(stream_id, data.clone(), false).await.is_ok() {
delivered = true;
break;
}
if Instant::now() >= deadline {
warn!(stream_id, "Stream pause budget exceeded — no leg recovered, closing");
break;
}
tokio::select! {
biased;
_ = token.cancelled() => break,
_ = tokio::time::sleep(STREAM_PAUSE_RETRY) => {}
}
}
if !delivered {
break;
}
}
@@ -85,7 +115,11 @@ pub(crate) async fn run_tcp_bridge<R, W>(
None => break,
Some(data) => {
if data.is_empty() { continue; }
match timeout(crate::net::BRIDGE_STREAM_WRITE_TIMEOUT, writer.write_all(&data)).await {
// Adaptive: BRIDGE_STREAM_WRITE_TIMEOUT is the floor, but
// under high RTT we grant the slow local socket more drain
// time before declaring it stuck and closing the stream.
let write_timeout = adaptive_write_timeout(BRIDGE_STREAM_WRITE_TIMEOUT);
match timeout(write_timeout, writer.write_all(&data)).await {
Ok(Ok(_)) => {}
_ => break,
}
@@ -118,17 +152,27 @@ pub(crate) async fn run_udp_bridge(
};
let config = NetworkConfig::global();
let mut buf = vec![0u8; config.udp_buffer_size];
let dgram_cap = config.udp_buffer_size;
// 🔥 ZERO-COPY: receive directly into BytesMut spare capacity and hand the
// datagram downstream via split().freeze() (ownership transfer, no memcpy).
// Replaces `vec![0u8; N]` + `Bytes::copy_from_slice` (one full copy/datagram).
let mut buf = BytesMut::with_capacity(dgram_cap);
info!(stream_id, "🌉 UDP Bridge active");
loop {
// Guarantee room for a whole datagram so recv_buf never truncates it.
if buf.capacity() - buf.len() < dgram_cap {
buf.reserve(dgram_cap);
}
let select_res = timeout(BRIDGE_IDLE_TIMEOUT, async {
tokio::select! {
res = socket.recv(&mut buf) => {
res = socket.recv_buf(&mut buf) => {
match res {
Ok(n) if n > 0 => {
let data = Bytes::copy_from_slice(&buf[..n]);
// Ownership transfer: the just-received bytes are moved
// out with no copy; buf is left empty for the next reserve.
let data = buf.split().freeze();
if let Err(e) = muxer.send_data_safe(stream_id, data, true).await {
warn!(stream_id, "UDP Tunnel legs dead. Dropping packet: {}", e);
// 🔥 ФИКС: Опять же, не обрываем стрим из-за мертвого туннеля!
+95 -6
View File
@@ -378,7 +378,61 @@ impl ClientHandler {
let muxer_inner = muxer.clone();
tokio::spawn(async move {
while let Some(raw_frame) = rx_from_engine.recv().await {
// Per-socket upload backlog. When a stream's up_tx is momentarily full we
// stash the frame here and KEEP PROCESSING other streams — so one slow
// upload can no longer head-of-line-block the shared loop, and we never
// kill a healthy stream. Only a single stream sustaining more than
// UPLOAD_PENDING_CAP buffered frames triggers bounded back-pressure
// (a one-frame blocking send) to keep memory bounded.
const UPLOAD_PENDING_CAP: usize = 64;
let mut pending_upload: std::collections::HashMap<
u64,
std::collections::VecDeque<Bytes>,
> = std::collections::HashMap::new();
loop {
// Flush existing per-socket backlogs first (fully non-blocking).
if !pending_upload.is_empty() {
pending_upload.retain(|sid, q| {
match local_to_upload_tx.get(sid).map(|r| r.value().clone()) {
Some(up_tx) => {
while let Some(front) = q.pop_front() {
match up_tx.try_send(front) {
Ok(_) => {}
Err(mpsc::error::TrySendError::Full(p)) => {
q.push_front(p);
break;
}
Err(mpsc::error::TrySendError::Closed(_)) => {
q.clear();
break;
}
}
}
!q.is_empty() // keep the entry only if still backlogged
}
None => false, // socket gone — drop its backlog
}
});
}
// Wait for the next frame; while backlogged, also wake on a short timer
// to retry the flush as the uplink drains.
let raw_frame = if pending_upload.is_empty() {
match rx_from_engine.recv().await {
Some(f) => f,
None => break,
}
} else {
tokio::select! {
f = rx_from_engine.recv() => match f {
Some(f) => f,
None => break,
},
_ = tokio::time::sleep(std::time::Duration::from_millis(5)) => continue,
}
};
if let Ok(nrxp_frame) = RawCastAdapter::to_nrxp(raw_frame.clone()) {
let local_socket_id = raw_frame.socket_id;
let f_type = nrxp_frame.header.frame_type;
@@ -446,7 +500,11 @@ impl ClientHandler {
}
});
let (up_tx, mut up_rx) = mpsc::channel::<Bytes>(cap);
// Per-stream upload buffer, kept deeper than the (deliberately
// small, anti-bufferbloat) default so upload bursts — e.g. a
// speedtest over a slow uplink — are absorbed here and the shared
// rx_from_engine loop rarely has to apply back-pressure on it.
let (up_tx, mut up_rx) = mpsc::channel::<Bytes>(cap.max(32));
local_to_upload_tx.insert(local_socket_id, up_tx);
let m_clone = muxer_inner.clone();
@@ -469,13 +527,44 @@ impl ClientHandler {
.get(&local_socket_id)
.map(|r| r.value().clone())
{
// .send().await blocks the upload task when the muxer leg
// is saturated, creating back-pressure back to smoltcp
// (no drops → no unnecessary retransmits → lower jitter).
let _ = up_tx.send(payload).await;
// PER-STREAM, no shared-loop HOL: if this stream already has a
// backlog, queue behind it (preserve order). Otherwise try a
// non-blocking send; on full, START a backlog and keep serving
// OTHER streams. (Replaces the old 2 s blocking grace that
// stalled the whole loop and then killed healthy streams.)
if let Some(q) = pending_upload.get_mut(&local_socket_id) {
q.push_back(payload);
} else {
match up_tx.try_send(payload) {
Ok(_) => {}
Err(mpsc::error::TrySendError::Closed(_)) => {}
Err(mpsc::error::TrySendError::Full(p)) => {
let mut q = std::collections::VecDeque::with_capacity(16);
q.push_back(p);
pending_upload.insert(local_socket_id, q);
}
}
}
// Bounded back-pressure: only if THIS stream's backlog exceeds
// the cap (sustained uplink-bound overload) do we block on a
// single frame, so memory stays bounded. Never closes the
// stream; other streams were already flushed at the loop top.
let over_cap = pending_upload
.get(&local_socket_id)
.map_or(false, |q| q.len() > UPLOAD_PENDING_CAP);
if over_cap {
let front = pending_upload
.get_mut(&local_socket_id)
.and_then(|q| q.pop_front());
if let Some(front) = front {
let _ = up_tx.send(front).await;
}
}
}
}
FrameType::Close => {
pending_upload.remove(&local_socket_id);
if let Some(kv) = local_to_global.remove(&local_socket_id) {
let global_stream_id = kv.1;
+50 -20
View File
@@ -230,8 +230,9 @@ impl TunnelEngine {
for frame in frames {
if frame.header.frame_type == FrameType::Heartbeat {
let m = muxer.clone();
tokio::spawn(async move { m.record_pong(leg_id).await; });
// record_pong does no .await internally, so run it inline:
// a spawn+Arc-clone per PONG was pure scheduler churn.
muxer.record_pong(leg_id).await;
}
let _ = handler.handle(frame).await;
}
@@ -247,7 +248,6 @@ impl TunnelEngine {
let mut heartbeat = tokio::time::interval(HEALTH_CHECK_INTERVAL);
let mut pending_data: Option<MuxMessage> = None;
let interleave_chunk = TUNNEL_INTERLEAVE_CHUNK;
loop {
tokio::select! {
@@ -286,6 +286,12 @@ impl TunnelEngine {
_ = std::future::ready(()), if pending_data.is_some() => {
let mut msg = pending_data.take().unwrap();
// #4 Adaptive batch: under high RTT take a bigger interleave
// chunk so more frames coalesce into one write in
// handle_outbound (#3); at low RTT stay small for fairness.
let interleave_chunk = crate::net::connection::muxer::adaptive_batch_chunk(
TUNNEL_INTERLEAVE_CHUNK,
);
let chunk_size = std::cmp::min(msg.data.len(), interleave_chunk);
let chunk_data = msg.data.split_to(chunk_size);
@@ -439,23 +445,47 @@ impl TunnelEngine {
}
}
for pkt in packets {
let write_future = outbound.write_all(&pkt);
// 💡 ИЗМЕНЕНО: Увеличен таймаут отправки до 20 секунд для совместимости с агрессивным BBR
if let Err(_) =
tokio::time::timeout(std::time::Duration::from_secs(20), write_future).await
{
error!(stream_id, "🔥 Physical leg STUCK on write. Killing leg.");
// Increment counter; the call site in run() emits the full event
// with the correct leg_id since handle_outbound is a static fn.
crate::net::diagnostics::DIAG_COUNTERS
.tunnel_write_stalls
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
return Err(AppError::new(
ERR_INFRA_TIMEOUT,
"Таймаут отправки",
"Physical leg STUCK on write",
));
// Adaptive write deadline: floor of 20 s (BBR-friendly), but scales with
// the live RTT so a high-latency path (RTT > 2.5 s) doesn't trip a flat
// timeout on a leg that is slow rather than dead. Killing such a leg is
// what set off the leg-drop → stream-close cascade.
let write_timeout = crate::net::connection::muxer::adaptive_write_timeout(
std::time::Duration::from_secs(20),
);
// #3 Syscall batching (sendmmsg-analog for a TCP byte stream): when a Data
// message produced several MAX_FRAME_PAYLOAD frames, coalesce them into ONE
// contiguous buffer and issue a single write_all instead of N — fewer
// User→Kernel transitions under exactly the high-throughput conditions that
// were producing tunnel_write_stuck. The single-frame case (control/UDP and
// ≤16 KB payloads) keeps the zero-copy direct write with no extra copy.
let stuck = || -> AppError {
error!(stream_id, "🔥 Physical leg STUCK on write. Killing leg.");
// Increment counter; the call site in run() emits the full event
// with the correct leg_id since handle_outbound is a static fn.
crate::net::diagnostics::DIAG_COUNTERS
.tunnel_write_stalls
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
AppError::new(
ERR_INFRA_TIMEOUT,
"Таймаут отправки",
"Physical leg STUCK on write",
)
};
if packets.len() == 1 {
let write_future = outbound.write_all(&packets[0]);
if tokio::time::timeout(write_timeout, write_future).await.is_err() {
return Err(stuck());
}
} else if !packets.is_empty() {
let total: usize = packets.iter().map(|p| p.len()).sum();
let mut batch = BytesMut::with_capacity(total);
for pkt in &packets {
batch.extend_from_slice(pkt);
}
let write_future = outbound.write_all(&batch);
if tokio::time::timeout(write_timeout, write_future).await.is_err() {
return Err(stuck());
}
}
Ok(())
+235 -88
View File
@@ -1,15 +1,16 @@
use arc_swap::ArcSwap;
use bytes::Bytes;
use dashmap::DashMap;
use netrunner_logger::{info, instrument, trace, warn, AppError, ERR_INFRA_TIMEOUT};
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, RwLock};
use std::time::Instant;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::mpsc::Sender;
use tokio_util::sync::CancellationToken;
use crate::net::diagnostics::{self, DiagnosticsEvent, LegMetrics, TunnelMetrics, DIAG_COUNTERS};
use crate::net::{DISPATCH_TO_LOCAL_TIMEOUT, MAX_TUNNEL_LEGS};
use crate::net::{INITIAL_RTT_MS, MUXER_CONGESTION_WEIGHT};
use crate::net::INITIAL_RTT_MS;
use crate::nrxp::FrameType;
#[derive(Default, Debug)]
@@ -66,11 +67,43 @@ pub struct MuxMessage {
pub static GLOBAL_MIN_RTT: AtomicU32 = AtomicU32::new(INITIAL_RTT_MS);
/// Write timeout that scales with the observed network RTT.
///
/// On a healthy path (RTT ~50 ms) this stays at `floor`. When the path degrades
/// to multi-second RTT (the > 2500 ms peaks seen in production), a flat 20 s
/// timeout fires on a leg that is merely *slow*, not dead — and a killed leg
/// triggers the leg-drop → stream-close cascade ("domino effect"). Allowing
/// ~8 RTT of drain time (capped at 60 s) keeps slow-but-alive legs from being
/// evicted under high latency, while still reaping genuinely stuck sockets.
pub fn adaptive_write_timeout(floor: Duration) -> Duration {
let rtt_ms = GLOBAL_MIN_RTT.load(Ordering::Relaxed) as u64;
let scaled = Duration::from_millis(rtt_ms.saturating_mul(8));
scaled.clamp(floor, Duration::from_secs(60))
}
/// Interleave/batch chunk size that grows with RTT.
///
/// At low RTT keep the `base` (snappy, fair interleaving); under high RTT — where
/// the bandwidth-delay product is large — write bigger batches per pass so more
/// 16 KB frames coalesce into a single contiguous socket write (see
/// `handle_outbound`), cutting the number of `write()` syscalls under exactly the
/// conditions that were producing `tunnel_write_stuck`.
///
/// 1× at ≤250 ms, +1× per extra 250 ms of RTT, capped at 4×.
pub fn adaptive_batch_chunk(base: usize) -> usize {
let rtt_ms = GLOBAL_MIN_RTT.load(Ordering::Relaxed) as usize;
let factor = (1 + rtt_ms / 250).clamp(1, 4);
base.saturating_mul(factor)
}
#[derive(Clone)]
pub struct Muxer {
legs: Arc<DashMap<u32, MuxLeg>>,
// 🔥 ОПТИМИЗАЦИЯ: Lock-Free кэш для горячего пути
active_legs_cache: Arc<RwLock<Arc<Vec<MuxLeg>>>>,
// 🔥 ОПТИМИЗАЦИЯ: полностью lock-free кэш горячего пути.
// ArcSwap: чтение (load_full) — атомарный bump Arc без блокировок; запись
// (store) реже и тоже неблокирующая. Заменил RwLock<Arc<Vec>> — у которого
// чтение брало read-guard.
active_legs_cache: Arc<ArcSwap<Vec<MuxLeg>>>,
// Добавили CancellationToken для предотвращения утечек памяти (Зомби-задач)
streams: Arc<DashMap<u32, (Sender<Bytes>, Arc<StreamStats>, CancellationToken)>>,
@@ -78,24 +111,29 @@ pub struct Muxer {
pending_pings: Arc<DashMap<u32, Instant>>,
id_gen: Arc<IdGenerator>,
session_id: Arc<String>,
/// Rotating cursor for round-robin leg selection among similar-quality legs,
/// so a burst of new streams spreads across legs instead of all binding to
/// the single current-best one (thundering herd).
rr_counter: Arc<AtomicU32>,
}
impl Muxer {
pub fn new(is_client: bool, session_id: String) -> Self {
Self {
legs: Arc::new(DashMap::new()),
active_legs_cache: Arc::new(RwLock::new(Arc::new(Vec::new()))),
active_legs_cache: Arc::new(ArcSwap::from_pointee(Vec::new())),
streams: Arc::new(DashMap::new()),
stream_bindings: Arc::new(DashMap::new()),
id_gen: Arc::new(IdGenerator::new(is_client)),
pending_pings: Arc::new(DashMap::new()),
session_id: Arc::new(session_id),
rr_counter: Arc::new(AtomicU32::new(0)),
}
}
fn update_legs_cache(&self) {
let new_cache: Vec<MuxLeg> = self.legs.iter().map(|kv| kv.value().clone()).collect();
*self.active_legs_cache.write().unwrap() = Arc::new(new_cache);
self.active_legs_cache.store(Arc::new(new_cache));
}
pub fn add_leg(
@@ -126,6 +164,13 @@ impl Muxer {
);
}
/// Drop every stream→leg binding that points at `leg_id`. Stale bindings to a
/// removed leg force `select_leg` to re-balance each affected stream onto a
/// healthy leg on its next send, instead of repeatedly probing the dead one.
fn clear_bindings_for_leg(&self, leg_id: u32) {
self.stream_bindings.retain(|_, bound_leg| *bound_leg != leg_id);
}
pub fn remove_leg(&self, leg_id: u32, tx: &Sender<MuxMessage>) {
let should_remove = self
.legs
@@ -133,6 +178,9 @@ impl Muxer {
.map_or(false, |leg| leg.control_tx.same_channel(tx));
if should_remove {
self.legs.remove(&leg_id);
// Unbind streams BEFORE refreshing the cache so a concurrent
// select_leg never re-binds a stream to the leg we are evicting.
self.clear_bindings_for_leg(leg_id);
self.update_legs_cache();
info!(
leg_id,
@@ -143,6 +191,7 @@ impl Muxer {
pub fn force_remove_leg(&self, leg_id: u32) {
if self.legs.remove(&leg_id).is_some() {
self.clear_bindings_for_leg(leg_id);
self.update_legs_cache();
info!(leg_id, "MUXER: TCP leg force-removed on engine exit");
}
@@ -159,36 +208,56 @@ impl Muxer {
}
fn select_leg(&self, stream_id: u32) -> Option<MuxLeg> {
// 1. Читаем кэш (это Arc, поэтому clone здесь — это просто инкремент счетчика, не копирование данных)
let legs = self.active_legs_cache.read().unwrap().clone();
if legs.is_empty() {
return None;
}
// 2. Если поток уже привязан к леге, используем её (Sticky Connection)
// 1. FAST PATH (hot, per data frame): a bound stream resolves its leg by
// id straight from the legs map — no full-cache Arc clone and no vector
// scan. Reading `legs` (source of truth, not the cached snapshot) also
// transparently picks up a leg that reconnected under the same id.
if let Some(leg_id_ref) = self.stream_bindings.get(&stream_id) {
let leg_id = *leg_id_ref;
if let Some(leg) = legs.iter().find(|l| l.id == leg_id) {
if let Some(leg) = self.legs.get(&leg_id) {
return Some(leg.clone());
}
// Bound leg disappeared — fall through and re-pick a fresh one below.
}
// 2. New (or re-homed) stream: load the leg set and choose.
let legs = self.active_legs_cache.load_full();
if legs.is_empty() {
return None;
}
// 3. O(N) поиск лучшей леги без сортировки всего вектора.
// Consider all available legs so the 4th leg is not permanently starved.
// MUXER_POOL_SIZE is kept for topology printing but no longer limits
// leg selection: sticky bindings already prevent hot-leg thrashing.
let selected_leg = legs
.iter()
.min_by(|a, b| {
let score_a = a.stats.rtt_ms.load(Ordering::Relaxed) as f64
+ (a.congestion_factor() * MUXER_CONGESTION_WEIGHT);
let score_b = b.stats.rtt_ms.load(Ordering::Relaxed) as f64
+ (b.congestion_factor() * MUXER_CONGESTION_WEIGHT);
score_a
.partial_cmp(&score_b)
.unwrap_or(std::cmp::Ordering::Equal)
})
.cloned();
// RTT-DOMINANT score: a leg's latency sets the scale, congestion only
// modulates within legs of similar RTT. A drastically slower leg is never
// preferred over a fast one, even when the fast leg is congested. (The old
// additive `rtt + congestion*2000` could score a busy 160 ms leg WORSE
// than an idle 1300 ms one, routing new streams onto the laggy leg.)
let score = |leg: &MuxLeg| -> f64 {
let rtt = (leg.stats.rtt_ms.load(Ordering::Relaxed) as f64).max(1.0);
rtt * (1.0 + leg.congestion_factor())
};
let best = legs.iter().map(|l| score(l)).fold(f64::MAX, f64::min);
// Candidate set = every leg within 2× of the best score. Drastically
// worse (slow / bufferbloated) legs are excluded; near-equal legs are all
// eligible. We then ROUND-ROBIN across the candidates so a burst of new
// streams (speedtest / multi-connection upload opening many sockets at
// once, before congestion registers) spreads across legs instead of all
// binding to the single current-best leg — which previously left one leg
// saturated and the others idle (low aggregate upload + stop-start stalls).
let candidates: Vec<&MuxLeg> = legs.iter().filter(|&l| score(l) <= best * 2.0).collect();
let selected_leg = if candidates.is_empty() {
None
} else {
let idx =
self.rr_counter.fetch_add(1, Ordering::Relaxed) as usize % candidates.len();
Some(candidates[idx].clone())
};
if let Some(leg) = selected_leg {
self.stream_bindings.insert(stream_id, leg.id);
@@ -230,47 +299,75 @@ impl Muxer {
}
#[instrument(skip(self, message), fields(session_id = %self.session_id, stream_id = message.stream_id, frame = ?message.frame_type))]
pub async fn send_to_network(&self, message: MuxMessage) -> Result<(), AppError> {
let leg = match self.select_leg(message.stream_id) {
Some(l) => l,
None => {
return Err(AppError::new(
ERR_INFRA_TIMEOUT,
"Нет связи",
"No active legs",
))
}
};
pub async fn send_to_network(&self, mut message: MuxMessage) -> Result<(), AppError> {
let is_data = matches!(message.frame_type, FrameType::Data | FrameType::UdpData);
let stream_id = message.stream_id;
let size = message.data.len() as u64;
if is_data {
// 💡 ДАННЫЕ: Используем .send().await для создания Backpressure
match leg.data_tx.send(message).await {
Ok(_) => {
leg.stats.tx_bytes.fetch_add(size, Ordering::Relaxed);
if let Some(stream_ref) = self.streams.get(&stream_id) {
stream_ref
.value()
.1
.tx_bytes
.fetch_add(size, Ordering::Relaxed);
// 🔥 ANTI-DOMINO FAILOVER.
// A single leg dropping must NOT close the stream. We evict the dead
// leg, unbind the stream, and retry on the next-best leg. Only when
// *every* leg is gone do we return Err — and the bridge treats that
// as "pause & buffer", not "close" (see run_tcp_bridge). The loop is
// bounded: remove_leg drops the leg from the cache, so select_leg can
// never hand back the same dead leg, and it terminates at None.
loop {
let leg = match self.select_leg(message.stream_id) {
Some(l) => l,
None => {
return Err(AppError::new(
ERR_INFRA_TIMEOUT,
"Нет связи",
"No active legs",
))
}
};
let stream_id = message.stream_id;
let size = message.data.len() as u64;
// 💡 ДАННЫЕ: Используем .send().await для создания Backpressure
match leg.data_tx.send(message).await {
Ok(_) => {
leg.stats.tx_bytes.fetch_add(size, Ordering::Relaxed);
if let Some(stream_ref) = self.streams.get(&stream_id) {
stream_ref
.value()
.1
.tx_bytes
.fetch_add(size, Ordering::Relaxed);
}
return Ok(());
}
Err(send_err) => {
// Recover the payload from the failed send so the retry
// on another leg does not lose the chunk.
message = send_err.0;
DIAG_COUNTERS.upload_fails.fetch_add(1, Ordering::Relaxed);
diagnostics::send_diag_event(DiagnosticsEvent::UploadFailed {
stream_id,
reason: "data channel closed (leg dropped) — failing over".into(),
});
// Evict the dead leg (also unbinds its streams) so the
// next select_leg re-balances onto a healthy leg.
self.remove_leg(leg.id, &leg.control_tx);
// loop → pick another leg, or return Err if none remain.
}
Ok(())
}
Err(_) => {
DIAG_COUNTERS.upload_fails.fetch_add(1, Ordering::Relaxed);
diagnostics::send_diag_event(DiagnosticsEvent::UploadFailed {
stream_id,
reason: "data channel closed (leg dropped)".into(),
});
self.remove_leg(leg.id, &leg.control_tx);
Err(AppError::new(ERR_INFRA_TIMEOUT, "Обрыв", "Leg closed"))
}
}
} else {
let leg = match self.select_leg(message.stream_id) {
Some(l) => l,
None => {
return Err(AppError::new(
ERR_INFRA_TIMEOUT,
"Нет связи",
"No active legs",
))
}
};
let stream_id = message.stream_id;
let size = message.data.len() as u64;
// Close and Heartbeat frames MUST be delivered reliably (.send().await).
// Close: dropping it leaks stream resources.
// Heartbeat (PONG): dropping it via try_send causes the health-check
@@ -380,15 +477,16 @@ impl Muxer {
self.stream_bindings.remove(&stream_id);
}
// ORDERING CONTRACT: callers MUST .await this; the caller (TunnelEngine reader)
// is a spawned task, so blocking here creates correct back-pressure all the way
// back to the kernel TCP socket buffer. Never spawn a task to deliver data
// from this function — that breaks in-order delivery guarantees.
// ORDERING CONTRACT: in-order delivery — never spawn a task to deliver data
// from this function.
//
// TIMEOUT GUARD: if the stream's receive channel stays full for longer than
// DISPATCH_TO_LOCAL_TIMEOUT the stream is forcibly closed. Without this a
// single slow consumer (app socket buffer full, background app paused, etc.)
// would block the engine reader and starve every other stream on the same leg.
// HEAD-OF-LINE GUARD: the hot path is a non-blocking try_send, so one slow or
// dead stream can NEVER block the shared per-leg reader. (A finished speedtest
// socket the app stopped reading used to back its channel up and freeze EVERY
// other download on that leg, because the reader awaited here for up to 10 s.)
// Only a genuinely-full channel gets a SHORT grace wait (DISPATCH_TO_LOCAL_
// TIMEOUT); if it is still full that ONE stream is closed so the leg keeps
// serving everyone else.
pub async fn dispatch_to_local(&self, stream_id: u32, data: Bytes) {
let size = data.len() as u64;
@@ -397,22 +495,53 @@ impl Muxer {
(val.0.clone(), val.1.clone())
});
if let Some((tx, stats)) = tx_and_stats {
match tokio::time::timeout(DISPATCH_TO_LOCAL_TIMEOUT, tx.send(data)).await {
Ok(Ok(_)) => {
stats.rx_bytes.fetch_add(size, Ordering::Relaxed);
}
Ok(Err(_)) => { /* receiver already closed — stream gone */ }
Err(_) => {
// Bridge isn't consuming: app socket full or app paused too long.
// Close the stream to free the leg for all other streams.
warn!(
stream_id,
"dispatch_to_local: stream stalled for {:?}, closing",
DISPATCH_TO_LOCAL_TIMEOUT
);
self.remove_stream(stream_id);
}
let Some((tx, stats)) = tx_and_stats else {
// No stream registered for this id (already closed / never opened).
DIAG_COUNTERS
.mux_dispatch_no_stream
.fetch_add(1, Ordering::Relaxed);
return;
};
// Fast path: deliver without awaiting → zero head-of-line blocking.
let data = match tx.try_send(data) {
Ok(()) => {
stats.rx_bytes.fetch_add(size, Ordering::Relaxed);
DIAG_COUNTERS.mux_dispatch_ok.fetch_add(1, Ordering::Relaxed);
return;
}
// Receiver already closed — stream gone.
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
DIAG_COUNTERS
.mux_dispatch_recv_closed
.fetch_add(1, Ordering::Relaxed);
return;
}
// Channel full: recover the payload and fall through to a bounded wait.
Err(tokio::sync::mpsc::error::TrySendError::Full(data)) => data,
};
match tokio::time::timeout(DISPATCH_TO_LOCAL_TIMEOUT, tx.send(data)).await {
Ok(Ok(_)) => {
stats.rx_bytes.fetch_add(size, Ordering::Relaxed);
DIAG_COUNTERS.mux_dispatch_ok.fetch_add(1, Ordering::Relaxed);
}
Ok(Err(_)) => {
DIAG_COUNTERS
.mux_dispatch_recv_closed
.fetch_add(1, Ordering::Relaxed);
}
Err(_) => {
// Consumer stayed full past the grace window: close just this one
// stream so the leg keeps serving everyone else.
DIAG_COUNTERS
.mux_dispatch_full_closed
.fetch_add(1, Ordering::Relaxed);
warn!(
stream_id,
"dispatch_to_local: stream stalled for {:?}, closing", DISPATCH_TO_LOCAL_TIMEOUT
);
self.remove_stream(stream_id);
}
}
}
@@ -524,7 +653,7 @@ impl Muxer {
let mut total_rx = 0;
let mut legs_info = Vec::new();
let cached_legs = self.active_legs_cache.read().unwrap().clone();
let cached_legs = self.active_legs_cache.load_full();
for leg in cached_legs.iter() {
let tx = leg.stats.tx_bytes.load(Ordering::Relaxed);
let rx = leg.stats.rx_bytes.load(Ordering::Relaxed);
@@ -594,14 +723,32 @@ impl Muxer {
Self::format_size(rx)
));
}
// ── Pipeline health counters (cumulative) ────────────────────────────
let c = &DIAG_COUNTERS;
out.push_str(&format!(
"├─ 📥 Mux dispatch: ok={} no_stream={} full_closed={} recv_closed={}\n",
c.mux_dispatch_ok.load(Ordering::Relaxed),
c.mux_dispatch_no_stream.load(Ordering::Relaxed),
c.mux_dispatch_full_closed.load(Ordering::Relaxed),
c.mux_dispatch_recv_closed.load(Ordering::Relaxed),
));
out.push_str(&format!(
"└─ 📤 Upload/legs: upload_fails={} ctrl_full_drops={} write_stalls={} leg_disconnects={}",
c.upload_fails.load(Ordering::Relaxed),
c.control_full_drops.load(Ordering::Relaxed),
c.tunnel_write_stalls.load(Ordering::Relaxed),
c.leg_disconnects.load(Ordering::Relaxed),
));
info!("\n{}", out);
}
/// Collect a point-in-time snapshot of tunnel metrics for diagnostics.
/// Lock-free: reads only atomics and the RwLock-protected legs cache.
/// Lock-free: reads only atomics and the ArcSwap-backed legs cache.
pub fn snapshot_tunnel_metrics(&self) -> TunnelMetrics {
let global_min_rtt = crate::net::GLOBAL_MIN_RTT.load(Ordering::Relaxed);
let cached_legs = self.active_legs_cache.read().unwrap().clone();
let cached_legs = self.active_legs_cache.load_full();
let active_legs: Vec<LegMetrics> = cached_legs
.iter()
+27 -9
View File
@@ -21,10 +21,20 @@ pub const BRIDGE_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
/// If the app's receive buffer stays full longer than this, the connection
/// is closed to unblock the tunnel leg for other streams.
pub const BRIDGE_STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(30);
/// Max time dispatch_to_local will block waiting for a stream's receive channel.
/// Protects the engine reader (and thus the entire tunnel leg) from being stuck
/// behind one slow stream's backlog. On timeout the stream is forcibly closed.
pub const DISPATCH_TO_LOCAL_TIMEOUT: Duration = Duration::from_secs(10);
/// While *every* tunnel leg is momentarily down (all reconnecting), an upload
/// stream holds its current chunk and retries instead of closing — turning a
/// leg outage into a short pause rather than a mass stream reset. This bounds
/// how long a stream will wait before it finally gives up and closes.
pub const STREAM_PAUSE_BUDGET: Duration = Duration::from_secs(30);
/// Poll interval while a paused upload stream waits for a leg to come back.
pub const STREAM_PAUSE_RETRY: Duration = Duration::from_millis(250);
/// Grace window dispatch_to_local waits when a stream's receive channel is full
/// before closing that ONE stream. The hot path now uses try_send (no await), so
/// this applies only to a genuinely backed-up consumer; kept short so a slow or
/// dead stream (e.g. a finished speedtest socket the app stopped reading) can
/// never head-of-line-block the shared per-leg reader and freeze every other
/// download on that leg (was 10 s — caused multi-second download stalls).
pub const DISPATCH_TO_LOCAL_TIMEOUT: Duration = Duration::from_millis(300);
pub const TLS_HELLO_TIMEOUT: Duration = Duration::from_secs(10);
pub const SECURE_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20);
pub const FALLBACK_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
@@ -79,16 +89,24 @@ pub const TUNNEL_MAX_BUFFER_SIZE: usize = 1024 * 1024;
pub const TUNNEL_READ_RESERVE: usize = 16 * 1024;
/// Maximum bytes written per stream in a single interleaved write pass.
pub const TUNNEL_INTERLEAVE_CHUNK: usize = 16 * 1024;
/// Max bytes a stream bridge reads per pass before producing a data message.
/// Bounds the size of a single MuxMessage so the per-leg queue is byte-bounded
/// (CHANNEL_PACKETS × this), keeping post-speedtest bufferbloat small. One NRXP
/// frame is 16 KB, so reading in 16 KB units also aligns with the wire framing.
pub const BRIDGE_READ_CHUNK: usize = 16 * 1024;
// ── Tunnel leg TCP socket tuning ─────────────────────────────────────────────
/// OS-level TCP send buffer for each tunnel leg. The default (48 MB on
/// Linux/Android) can hold seconds of data at typical mobile speeds, causing
/// severe jitter. 256 KB limits extra queuing to ~80 ms at 25 Mbit/s per leg
/// while still providing enough headroom for TCP slow-start.
pub const TUNNEL_SOCKET_SNDBUF: u32 = 256 * 1024;
/// severe jitter. 128 KB limits extra queuing to ~40 ms at 25 Mbit/s per leg
/// while still providing enough headroom for TCP slow-start. (Halved from
/// 256 KB to cut post-speedtest bufferbloat — see CHANNEL_PACKETS.)
pub const TUNNEL_SOCKET_SNDBUF: u32 = 128 * 1024;
/// OS-level TCP receive buffer for each tunnel leg. Larger than the send
/// buffer so the receiver can absorb bursts without dropping packets.
pub const TUNNEL_SOCKET_RCVBUF: u32 = 512 * 1024;
/// buffer so the receiver can absorb bursts without dropping packets, but
/// bounded to keep stale in-flight download data (for already-closed streams)
/// small so the tunnel recovers in ~1 s after a heavy download.
pub const TUNNEL_SOCKET_RCVBUF: u32 = 256 * 1024;
// ── Smoltcp socket defaults ──────────────────────────────────────────────────
/// Packet slots for the ICMP socket's RX and TX packet buffers.
+13
View File
@@ -166,6 +166,15 @@ pub struct DiagnosticsCounters {
pub control_full_drops: AtomicU64,
pub tunnel_write_stalls: AtomicU64,
pub stream_errors: AtomicU64,
// ── Download dispatch funnel (muxer.dispatch_to_local) ──────────────────
/// Frames handed to a local stream's channel successfully.
pub mux_dispatch_ok: AtomicU64,
/// Frames dropped because no stream is registered for that id (gone/unknown).
pub mux_dispatch_no_stream: AtomicU64,
/// Streams closed because their channel stayed full past the grace window.
pub mux_dispatch_full_closed: AtomicU64,
/// Frames dropped because the stream's receiver was already closed.
pub mux_dispatch_recv_closed: AtomicU64,
}
impl DiagnosticsCounters {
@@ -177,6 +186,10 @@ impl DiagnosticsCounters {
control_full_drops: AtomicU64::new(0),
tunnel_write_stalls: AtomicU64::new(0),
stream_errors: AtomicU64::new(0),
mux_dispatch_ok: AtomicU64::new(0),
mux_dispatch_no_stream: AtomicU64::new(0),
mux_dispatch_full_closed: AtomicU64::new(0),
mux_dispatch_recv_closed: AtomicU64::new(0),
}
}