fix: stabilize tunnel downloads (deadlock, stuck-consumer eviction, real diagnostics)

Downloads were dying and stalling under real network conditions. Root causes
fixed in the muxer's stream-delivery path:

- dispatch_to_local held a DashMap Ref across a nested remove_stream call,
  self-deadlocking the leg's reader task whenever a stream's backlog needed
  eviction — permanently losing a tokio worker thread per occurrence.
- Eviction now runs off the hot path entirely, in a periodic background
  reaper, and only fires when a stream is both over its byte budget AND has
  made no delivery progress for an RTT-adaptive grace window (was a flat
  300ms then 5s, which killed merely-slow-but-alive consumers).
- The server never told the client when a bridge ended normally (only on
  failed connect), leaving the client's virtual TCP socket stuck in
  CloseWait for a full idle timeout and slowing the whole engine loop.
- The client's upload path silently dropped in-flight chunks when all tunnel
  legs were briefly down instead of pausing and retrying like the server
  bridge already did.
- Tried end-to-end credit-based flow control for the download producer;
  reverted the enforcement (kept the dormant frame/API) after it proved
  worse than the pre-existing local channel backpressure — RTT-coupled
  pauses produced burst-then-stall downloads and jitter/ping spikes on the
  shared physical leg.
- Server-side diagnostics snapshot was a stub (always-empty tunnel state,
  misleading placeholder trigger); now aggregates real per-session Muxer
  metrics from the SessionManager.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kirill
2026-07-02 15:06:00 +07:00
parent 582ad714ae
commit 3b2296a6d9
13 changed files with 693 additions and 136 deletions
+3 -2
View File
@@ -45,8 +45,9 @@
- **Anti-domino failover** (`muxer::send_to_network`) — падение ноги не закрывает
поток: дохлая нога эвиктится, кадр переотправляется на соседнюю.
- **Graceful pause** (`bridge::run_tcp_bridge`) — если легли все ноги, мост
держит чанк и ретраит (TCP backpressure), а не рвёт стрим.
- **Graceful pause** (`bridge::run_tcp_bridge` на сервере, тот же паттерн в
`ClientHandler::connect` на клиенте) — если легли все ноги, TCP-чанк держится
и ретраится (`STREAM_PAUSE_BUDGET`), а не рвёт/теряет данные аплоада.
- **Anti-bufferbloat** — маленькая ёмкость каналов, адаптивные по RTT таймауты и
размер батча, неблокирующая раздача `dispatch_to_local`.
- **Stealth-fallback** (`ServerHandler`) — «не наш» клиент прозрачно проксируется
+9 -9
View File
@@ -70,15 +70,15 @@ impl NetworkConfig {
// 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;
// 🔥 ANTI-BUFFERBLOAT vs HIGH-RTT THROUGHPUT TRADE-OFF:
// At low RTT (50 ms), 16 slots = ~3 MB queue drains fast. At high RTT
// (300+ ms), BDP = 300 Mbps × 0.35s ≈ 13 MB required for full throughput.
// Increased to 64: provides ~11 MB per leg (64 × ~180 KB), matching BDP
// at high RTT while still preventing pathological post-speedtest queuing.
// Anti-bufferbloat protection remains via per-stream dispatch backpressure
// and read-chunk sizing in dispatch_to_local (byte-bounded backlog closes
// genuinely stalled streams — see STREAM_BACKLOG_MAX_BYTES).
const CHANNEL_PACKETS: usize = 64;
// 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
+11
View File
@@ -82,6 +82,17 @@ pub(crate) async fn run_tcp_bridge<R, W>(
if buf.capacity() - buf.len() < BRIDGE_READ_CHUNK {
buf.reserve(BRIDGE_READ_CHUNK);
}
// 🔥 NOT credit-gated (tried, reverted): the local mpsc channel
// backpressure below (`data_tx.send().await` inside
// `send_data_safe`) already throttles this read loop to match the
// leg's real drain rate — that signal is local (sub-ms). Gating
// reads on a `Credit` frame instead ties pacing to a full network
// round-trip: every time the window ran dry the reader had to wait
// out (a fraction of) an RTT before resuming, producing exactly the
// burst-then-stall pattern users saw as jerky downloads plus
// jitter/ping spikes on the same physical leg. See Muxer::consume_credit
// for the (currently unused) machinery, kept for a possible future
// redesign with a much more generous, non-binding window.
tokio::select! {
biased;
_ = token.cancelled() => break,
+55 -7
View File
@@ -17,7 +17,7 @@
//! Обе роли сходятся на [`TunnelEngine`]: клиент задаёт `remote_addr`
//! (реконнектит), сервер оставляет его пустым (нога просто завершается).
use std::{net::Ipv4Addr, sync::Arc};
use std::{net::Ipv4Addr, sync::Arc, time::Instant};
use crate::{
crypto::{ChaChaCipher, SessionKeys},
@@ -29,8 +29,8 @@ use crate::{
},
NetworkConfig, DNS_LOOKUP_TIMEOUT, FALLBACK_CONNECT_TIMEOUT, LEG_RECONNECT_DELAY,
LEG_STAGGER_DELAY, MAX_TUNNEL_LEGS, NETWORK_WATCHER_INTERVAL, SECURE_HANDSHAKE_TIMEOUT,
SESSION_CLEANUP_DELAY, STEALTH_FALLBACK_HOST, STEALTH_FALLBACK_SNI, TLS_HELLO_TIMEOUT,
TOPOLOGY_PRINT_INTERVAL,
SESSION_CLEANUP_DELAY, STEALTH_FALLBACK_HOST, STEALTH_FALLBACK_SNI, STREAM_PAUSE_BUDGET,
STREAM_PAUSE_RETRY, TLS_HELLO_TIMEOUT, TOPOLOGY_PRINT_INTERVAL,
},
nrxp::{Codec, Frame, FrameType, TlsBridge},
rawcast::{LocalProtocol, RawCastAdapter, RawCastFrame},
@@ -508,7 +508,7 @@ impl ClientHandler {
let cap = NetworkConfig::global().channel_capacity;
let (v_tx, mut v_rx) = mpsc::channel::<Bytes>(cap);
muxer_inner.register_stream(global_stream_id, v_tx);
let cancel_token = muxer_inner.register_stream(global_stream_id, v_tx);
let tx_to_tun = tx_to_engine.clone();
let reg = registry.clone();
@@ -569,9 +569,57 @@ impl ClientHandler {
.await;
while let Some(data_payload) = up_rx.recv().await {
let _ = m_clone
.send_data_safe(global_stream_id, data_payload, is_udp)
.await;
if is_udp {
// UDP has no delivery guarantee — best-effort like
// run_udp_bridge: drop on a dead tunnel, don't pause.
let _ = m_clone
.send_data_safe(global_stream_id, data_payload, true)
.await;
continue;
}
// 🔥 GRACEFUL PAUSE (anti-domino), symmetric to
// run_tcp_bridge's upload half on the server. send_data_safe
// already fails over between live legs; it only errors when
// EVERY leg is down. That used to be silently ignored here
// (`let _ = ...await`), permanently dropping the chunk and
// corrupting the upload mid-stream. Now we hold the chunk and
// retry while the engine reconnects, bounded by
// STREAM_PAUSE_BUDGET, and bail out immediately if the stream
// gets torn down from elsewhere (peer Close, backlog
// eviction) meanwhile.
let deadline = Instant::now() + STREAM_PAUSE_BUDGET;
let mut delivered = false;
loop {
if m_clone
.send_data_safe(
global_stream_id,
data_payload.clone(),
false,
)
.await
.is_ok()
{
delivered = true;
break;
}
if Instant::now() >= deadline {
break;
}
tokio::select! {
biased;
_ = cancel_token.cancelled() => break,
_ = tokio::time::sleep(STREAM_PAUSE_RETRY) => {}
}
}
if !delivered {
warn!(
global_stream_id,
"Upload stream pause budget exceeded — no leg recovered, dropping stream"
);
m_clone.remove_stream(global_stream_id);
break;
}
}
});
}
+44 -5
View File
@@ -67,11 +67,30 @@ impl RemoteOpener {
info!(stream_id, "✅ [Remote] Connected in {:?}", start.elapsed());
let (r, w) = stream.into_split();
// Credit-gated reads (Muxer::consume_credit) were tried here and
// reverted: tying read pacing to a network round-trip produced
// burst-then-stall downloads and jitter on the shared physical leg,
// on top of the local mpsc backpressure that already paced reads
// correctly. The Credit frame/API stays in Muxer for a possible
// future redesign but isn't wired up on this path anymore.
// 🔥 Защищаем и сам мост токеном отмены
tokio::select! {
_ = token.cancelled() => { debug!(stream_id, "🔪 TCP bridge closed by Eviction"); }
_ = run_tcp_bridge(stream_id, r, w, muxer.clone(), v_rx) => {}
}
// 🔥 Сообщаем клиенту, что поток завершён — неважно, из-за
// EOF цели, write-timeout ноги, истёкшего STREAM_PAUSE_BUDGET
// или нашей же эвикции по бэклогу. Раньше это отправлялось
// только при неудачном CONNECT: при штатном завершении моста
// клиент никогда не узнавал, что стрим кончился — его
// виртуальный TCP-сокет навсегда застревал в CloseWait (ждёт
// от нас Close, см. server_eof/socket.close() в клиентском
// TcpConnection::poll_and_process), и освобождался только
// 120-секундным idle-таймаутом, попутно замедляя весь движок.
let _ = muxer
.send_control(stream_id, FrameType::Close, Bytes::new())
.await;
}
_ => {
error!(stream_id, "❌ [Remote] Target connection failed: {}", target);
@@ -144,7 +163,7 @@ impl StreamHandler {
});
} else if payload == b"PONG" {
trace!(stream_id, "🤝 [Tunnel] PONG received");
self.muxer.dispatch_to_local(stream_id, frame.payload).await;
self.muxer.dispatch_to_local(stream_id, frame.payload);
} else {
if self.opener.is_some() {
trace!(
@@ -173,8 +192,9 @@ impl StreamHandler {
}
FrameType::Data | FrameType::UdpData => {
// MUST .await — maintains in-order delivery via back-pressure.
self.muxer.dispatch_to_local(stream_id, frame.payload).await;
// Non-blocking: in-order delivery is guaranteed by the stream's
// single persistent backlog-drainer task, not by awaiting here.
self.muxer.dispatch_to_local(stream_id, frame.payload);
}
FrameType::Close => {
@@ -182,6 +202,18 @@ impl StreamHandler {
self.muxer.remove_stream(stream_id);
}
FrameType::Credit => {
// Сквозной flow control (см. Muxer::consume_credit/grant_credit):
// приёмник шлёт "можешь прислать ещё N байт". Синхронно и дёшево —
// просто прибавляет к атомарному счётчику и будит ждущего отправителя.
if let Ok(bytes) = frame.payload.as_ref().try_into().map(u32::from_be_bytes) {
trace!(stream_id, bytes, "💳 [Tunnel] Credit received");
self.muxer.grant_credit(stream_id, bytes);
} else {
warn!(stream_id, "Malformed Credit frame payload, ignoring");
}
}
FrameType::Diag => {
// Диагностика клиента, доставленная по туннелю. Осмысленна только
// на сервере: пересылаем в сток вместе с id сессии (берём из
@@ -215,8 +247,15 @@ impl StreamHandler {
let cap = NetworkConfig::global().channel_capacity;
let (v_tx, v_rx) = mpsc::channel::<Bytes>(cap);
// 🔥 Собираем токен для мгновенного обрыва связи при Eviction
let cancel_token = self.muxer.register_stream(stream_id, v_tx);
// 🔥 Собираем токен для мгновенного обрыва связи при Eviction.
// Больший бэклог, чем клиентский дефолт: реальная цель в интернете
// медленнее и капризнее локального TUN — аплоаду нужен запас (см.
// SERVER_STREAM_BACKLOG_MAX_BYTES).
let cancel_token = self.muxer.register_stream_with_backlog_cap(
stream_id,
v_tx,
crate::net::SERVER_STREAM_BACKLOG_MAX_BYTES,
);
if is_udp {
opener.open_udp(stream_id, target, v_rx, cancel_token).await;
+424 -72
View File
@@ -16,8 +16,29 @@
//! нет вовсе — и тогда мост делает паузу с буфером, а не сброс (см.
//! `send_to_network` и `run_tcp_bridge`).
//! - **Анти-bufferbloat доставка.** Входящие кадры доставляются неблокирующим
//! `try_send` (`dispatch_to_local`), чтобы один медленный потребитель не
//! блокировал общий reader ноги (head-of-line).
//! `try_send` (`dispatch_to_local`); если канал потока временно полон, кадр
//! уходит в его персональный байтовый бэклог вместо ожидания — общий reader
//! ноги никогда не блокируется на медленном потребителе (head-of-line) и
//! никогда сам не мутирует реестр потоков. Бэклог дренит отдельная
//! persistent-задача на поток; закрытие "зависшего" потока — исключительно
//! работа фонового `spawn_backlog_reaper` (байтовый бюджет
//! [`STREAM_BACKLOG_MAX_BYTES`]/[`crate::net::SERVER_STREAM_BACKLOG_MAX_BYTES`]
//! **и** отсутствие прогресса дольше RTT-адаптивного grace-окна —
//! [`adaptive_write_timeout`] от [`crate::net::BACKLOG_STUCK_GRACE`], та же
//! логика, что и у медленной-но-живой ноги, — фиксированные 5с раньше
//! ошибочно убивали, например, upload в реальную цель под высоким RTT) —
//! решение никогда не принимается изнутри горячего пути доставки, см.
//! докстринг `StreamBacklog`.
//! - **Credit flow control (сейчас не подключён).** В `Muxer` остаётся API
//! (`init_credit`/`grant_credit`/`consume_credit`) и кадр `FrameType::Credit`
//! для сквозного окна получатель→отправитель — идея была не дать отправителю
//! производить данные быстрее приёмника, вместо того чтобы копить и потом
//! эвиктить. На практике привязка паузы к сетевому round-trip (ожидание
//! `Credit`-кадра) оказалась хуже уже работавшего локального backpressure
//! `data_tx.send().await` (тот реагирует мгновенно, без RTT): давала
//! burst-then-stall на скачивании и подрывала джиттер/пинг на общей ноге.
//! Отключено в `run_tcp_bridge` (там просто читают без гейта), байтовый
//! бэклог + reaper выше остаются единственной защитой.
//!
//! ## Адаптация под RTT
//!
@@ -30,14 +51,19 @@ 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;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::mpsc::Sender;
use tokio::sync::mpsc::{error::TrySendError, Sender};
use tokio::sync::Notify;
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::{
BACKLOG_REAPER_IDLE_TIMEOUT, BACKLOG_REAPER_INTERVAL, BACKLOG_STUCK_GRACE, MAX_TUNNEL_LEGS,
STREAM_BACKLOG_MAX_BYTES,
};
use crate::net::INITIAL_RTT_MS;
use crate::nrxp::FrameType;
@@ -56,6 +82,91 @@ pub struct StreamStats {
pub rx_bytes: AtomicU64,
}
/// Байтовый бэклог одного потока на приём.
///
/// Когда канал потока временно полон, кадры копятся здесь вместо того, чтобы
/// блокировать общий ридер ноги (`dispatch_to_local` никогда не ждёт и никогда
/// не мутирует реестр потоков — только кладёт кадр сюда). Решение "поток
/// по-настоящему завис, закрыть" принимает ИСКЛЮЧИТЕЛЬНО фоновый
/// `Muxer::spawn_backlog_reaper`, а не сам вызов доставки: так вызов, держащий
/// `Ref` в `Muxer::streams`, никогда не пытается сам же удалить свой ключ из
/// той же шарды DashMap (что раньше приводило к самоблокировке потока ОС —
/// DashMap не поддерживает реентерабельные локи).
///
/// Условие эвикции у ридера — не просто байтовый бюджет (`cap_bytes`), а бюджет
/// **и** отсутствие прогресса дольше [`BACKLOG_STUCK_GRACE`]: так отличаем
/// «бэклог большой, потому что источник быстрый и продолжает сливаться» от
/// «бэклог большой, потому что потребитель встал намертво».
struct StreamBacklog {
queue: Mutex<VecDeque<Bytes>>,
bytes: AtomicUsize,
cap_bytes: usize,
notify: Notify,
/// Метка времени (мс, `current_timestamp_ms`) последней успешной доставки
/// этому потоку — неважно, быстрым путём или через дренер бэклога.
last_progress_ms: AtomicU64,
}
impl StreamBacklog {
fn new(cap_bytes: usize) -> Self {
Self {
queue: Mutex::new(VecDeque::new()),
bytes: AtomicUsize::new(0),
cap_bytes,
notify: Notify::new(),
last_progress_ms: AtomicU64::new(diagnostics::current_timestamp_ms()),
}
}
/// Кладёт кадр в бэклог. Никогда не отказывает и не трогает `Muxer::streams` —
/// решение "хватит ждать" не отсюда, см. докстринг типа.
fn push(&self, data: Bytes, size: u64) {
self.bytes.fetch_add(size as usize, Ordering::AcqRel);
self.queue.lock().unwrap().push_back(data);
self.notify.notify_one();
}
/// Отмечает успешную доставку: сбрасывает счётчик "с каких пор нет прогресса".
fn mark_progress(&self) {
self.last_progress_ms
.store(diagnostics::current_timestamp_ms(), Ordering::Relaxed);
}
}
/// Кредитное окно одного потока на СТОРОНЕ ОТПРАВИТЕЛЯ: сколько байт ещё можно
/// протолкнуть в туннель, прежде чем ждать `Credit`-кадр от приёмника.
///
/// Существует отдельно от `StreamBacklog` (тот — на стороне приёмника, отвечает
/// за "что делать, если консьюмер не успевает"). Кредит — упреждающая мера:
/// если он работает как задумано, `StreamBacklog` почти никогда не разрастается,
/// потому что отправитель сам не производит данные быстрее, чем приёмник может
/// их принять. `available` — `i64`, а не `usize`, чтобы `fetch_sub` мог уводить
/// его в отрицательные значения без паники при гонках (`consume_credit` всё
/// равно трактует `<= 0` как "кредита нет").
struct CreditState {
available: std::sync::atomic::AtomicI64,
notify: Notify,
/// Метка времени (мс) последнего РЕАЛЬНОГО пополнения — либо `init_credit`,
/// либо `grant_credit` от входящего `Credit`-кадра. `consume_credit`
/// откатывается на неограниченную отправку, только если с последнего
/// такого пополнения прошло больше [`crate::net::CREDIT_FALLBACK_AFTER`] —
/// НЕ если истёк дедлайн текущего вызова (это была ошибка: дедлайн
/// пересчитывался с нуля на каждый вызов `consume_credit`, поэтому после
/// исчерпания стартового окна на высокой скорости отправитель получал
/// жалкие `BRIDGE_READ_CHUNK` раз в `CREDIT_FALLBACK_AFTER` — то есть
/// credit-контроль топил скачивание СИЛЬНЕЕ, чем если бы его не было
/// вовсе, вместо того чтобы просто подождать очередной грант).
last_grant_ms: AtomicU64,
}
/// Регистрационная запись потока в реестре `Muxer::streams`.
struct StreamSlot {
tx: Sender<Bytes>,
stats: Arc<StreamStats>,
token: CancellationToken,
backlog: Arc<StreamBacklog>,
}
/// Одна нога туннеля = одно физическое TCP+TLS-соединение.
///
/// Два раздельных канала к writer-задаче ноги: `control_tx` (Close/Heartbeat,
@@ -139,6 +250,24 @@ pub fn adaptive_batch_chunk(base: usize) -> usize {
base.saturating_mul(factor)
}
/// Credit window that grows with RTT, same idea as [`adaptive_batch_chunk`].
///
/// A stream's credit window should hold roughly one bandwidth-delay product
/// in flight so the sender never has to stall waiting for a grant under
/// normal operation. A flat window sized for a healthy path (tens of ms RTT)
/// would be far too small once RTT climbs into the hundreds/low thousands of
/// ms (mobile network, as seen in production — `GLOBAL_MIN_RTT` peaks well
/// past 1 s): the fallback in `consume_credit` prevents that from ever
/// stalling a stream outright, but scaling the window up front means it
/// mostly doesn't need to. Wider cap than `adaptive_batch_chunk` (up to 8×,
/// matching `SERVER_STREAM_BACKLOG_MAX_BYTES` at the top end) since BDP grows
/// with RTT much faster than a comfortable interleave chunk does.
pub fn adaptive_credit_window(base: u32) -> u32 {
let rtt_ms = GLOBAL_MIN_RTT.load(Ordering::Relaxed) as u32;
let factor = (1 + rtt_ms / 250).clamp(1, 8);
base.saturating_mul(factor)
}
/// Мультиплексор туннеля. Дёшево клонируется (всё внутри `Arc`) и шарится между
/// всеми задачами ног и потоков.
#[derive(Clone)]
@@ -151,9 +280,13 @@ pub struct Muxer {
// чтение брало read-guard.
active_legs_cache: Arc<ArcSwap<Vec<MuxLeg>>>,
/// Реестр потоков: id → (канал доставки данных, статистика, токен отмены).
/// Токен мгновенно убивает связанные с потоком задачи при `remove_stream`.
streams: Arc<DashMap<u32, (Sender<Bytes>, Arc<StreamStats>, CancellationToken)>>,
/// Реестр потоков: id → регистрационная запись (канал, статистика, токен,
/// бэклог). Токен мгновенно убивает связанные с потоком задачи при `remove_stream`.
streams: Arc<DashMap<u32, StreamSlot>>,
/// Кредитные окна потоков, для которых ЭТА сторона — отправитель (см.
/// [`CreditState`]). Отдельная карта от `streams`: та — про приём, эта —
/// про то, сколько ещё можно отправить, не дожидаясь `Credit`-кадра.
credits: Arc<DashMap<u32, Arc<CreditState>>>,
/// Sticky-привязка потока к ноге (`stream_id` → `leg_id`).
stream_bindings: Arc<DashMap<u32, u32>>,
/// Время отправки PING по каждой ноге — для измерения RTT по PONG.
@@ -170,16 +303,89 @@ pub struct Muxer {
impl Muxer {
pub fn new(is_client: bool, session_id: String) -> Self {
Self {
let muxer = Self {
legs: Arc::new(DashMap::new()),
active_legs_cache: Arc::new(ArcSwap::from_pointee(Vec::new())),
streams: Arc::new(DashMap::new()),
credits: 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)),
}
};
muxer.spawn_backlog_reaper();
muxer
}
/// Фоновый "ридер" бэклогов: единственное место, которое реально закрывает
/// поток за зависший бэклог (см. докстринг [`StreamBacklog`]). Никогда не
/// вызывается изнутри `dispatch_to_local` — работает по расписанию, вне
/// любых `Ref`-гвардов `Muxer::streams`, поэтому структурно не может
/// повторить самоблокировку DashMap.
///
/// Держит собственный клон `Muxer` (дёшево — всё внутри `Arc`), поэтому
/// самостоятельно завершается, если сессия опустела (нет ног и потоков)
/// дольше [`BACKLOG_REAPER_IDLE_TIMEOUT`] — иначе на сервере, обслужившем
/// много клиентов, эти задачи копились бы вечно.
fn spawn_backlog_reaper(&self) {
let muxer = self.clone();
tokio::spawn(async move {
let mut idle_since: Option<Instant> = None;
loop {
tokio::time::sleep(BACKLOG_REAPER_INTERVAL).await;
if muxer.active_legs_count() == 0 && muxer.streams.is_empty() {
let since = *idle_since.get_or_insert_with(Instant::now);
if since.elapsed() >= BACKLOG_REAPER_IDLE_TIMEOUT {
trace!("Backlog reaper: muxer idle, stopping");
return;
}
continue;
}
idle_since = None;
let now = diagnostics::current_timestamp_ms();
// Same reasoning as adaptive_write_timeout: a stuck-but-alive
// consumer under high/variable RTT (e.g. the server writing a
// client's uploaded bytes into a slow real target) needs more
// than a flat grace window before it's judged dead — a fixed 5 s
// was fine for the low-RTT case this was tuned against, but
// evicted legitimately-slow-but-recovering streams once RTT (or
// its variance) climbed, exactly where this reaper replaced the
// old adaptive_write_timeout-only protection on that path.
let grace = adaptive_write_timeout(BACKLOG_STUCK_GRACE);
let grace_ms = grace.as_millis() as u64;
let stuck: Vec<u32> = muxer
.streams
.iter()
.filter_map(|kv| {
let backlog = &kv.value().backlog;
let over_budget = backlog.bytes.load(Ordering::Relaxed) > backlog.cap_bytes;
let stale = now
.saturating_sub(backlog.last_progress_ms.load(Ordering::Relaxed))
>= grace_ms;
(over_budget && stale).then_some(*kv.key())
})
.collect();
// Evict AFTER the .iter() above is fully dropped (collected into
// an owned Vec) — removing a key while iterating the same
// DashMap would hold a shard Ref and a write-lock request on it
// at once, exactly the self-deadlock this design avoids.
for stream_id in stuck {
DIAG_COUNTERS
.mux_dispatch_full_closed
.fetch_add(1, Ordering::Relaxed);
warn!(
stream_id,
"Backlog reaper: over budget with no progress for {:?} — closing stream",
grace
);
muxer.remove_stream(stream_id);
}
}
});
}
/// Пересобирает lock-free снапшот ног из источника истины (`legs`) и
@@ -414,7 +620,7 @@ impl Muxer {
if let Some(stream_ref) = self.streams.get(&stream_id) {
stream_ref
.value()
.1
.stats
.tx_bytes
.fetch_add(size, Ordering::Relaxed);
}
@@ -463,7 +669,7 @@ impl Muxer {
if let Some(stream_ref) = self.streams.get(&stream_id) {
stream_ref
.value()
.1
.stats
.tx_bytes
.fetch_add(size, Ordering::Relaxed);
}
@@ -481,7 +687,7 @@ impl Muxer {
if let Some(stream_ref) = self.streams.get(&stream_id) {
stream_ref
.value()
.1
.stats
.tx_bytes
.fetch_add(size, Ordering::Relaxed);
}
@@ -566,93 +772,237 @@ impl Muxer {
.await
}
/// Регистрирует поток и возвращает его [`CancellationToken`]. Канал `tx`
/// используется для доставки входящих данных потоку (`dispatch_to_local`).
/// Регистрирует поток с бэклогом по умолчанию ([`STREAM_BACKLOG_MAX_BYTES`])
/// и возвращает его [`CancellationToken`]. Канал `tx` используется для
/// доставки входящих данных потоку (`dispatch_to_local`).
pub fn register_stream(&self, stream_id: u32, tx: Sender<Bytes>) -> CancellationToken {
self.register_stream_with_backlog_cap(stream_id, tx, STREAM_BACKLOG_MAX_BYTES)
}
/// Регистрирует поток с явным байтовым бюджетом бэклога. Используется
/// сервером для потоков к реальной цели ([`SERVER_STREAM_BACKLOG_MAX_BYTES`]),
/// где нужен запас больше дефолтного — см. модульный докстринг.
pub fn register_stream_with_backlog_cap(
&self,
stream_id: u32,
tx: Sender<Bytes>,
backlog_cap_bytes: usize,
) -> CancellationToken {
let token = CancellationToken::new();
let stats = Arc::new(StreamStats::default());
let backlog = Arc::new(StreamBacklog::new(backlog_cap_bytes));
Self::spawn_backlog_drainer(
stream_id,
tx.clone(),
backlog.clone(),
stats.clone(),
token.clone(),
);
self.streams.insert(
stream_id,
(tx, Arc::new(StreamStats::default()), token.clone()),
StreamSlot {
tx,
stats,
token: token.clone(),
backlog,
},
);
token
}
/// Удаляет поток, отменяя его токен (мгновенно гасит связанные задачи) и
/// снимая привязку к ноге.
/// Persistent-задача одного потока: спит на [`Notify`], по пробуждению сливает
/// весь накопленный бэклог в реальный канал потребителя блокирующим `send`
/// (сколько угодно времени — здесь нет тайм-аута). Ровно одна такая задача на
/// поток за всё время его жизни, поэтому порядок доставки внутри потока не
/// нарушается, в отличие от спавна задачи на каждый кадр.
fn spawn_backlog_drainer(
stream_id: u32,
tx: Sender<Bytes>,
backlog: Arc<StreamBacklog>,
stats: Arc<StreamStats>,
token: CancellationToken,
) {
tokio::spawn(async move {
loop {
tokio::select! {
biased;
_ = token.cancelled() => return,
_ = backlog.notify.notified() => {}
}
loop {
let item = backlog.queue.lock().unwrap().pop_front();
let Some(item) = item else { break };
let len = item.len() as u64;
if tx.send(item).await.is_err() {
// Consumer dropped its receiver — remove_stream elsewhere
// will clean up the entry; nothing more to drain into.
trace!(stream_id, "backlog drainer: consumer channel closed, stopping");
return;
}
backlog.bytes.fetch_sub(len as usize, Ordering::AcqRel);
backlog.mark_progress();
stats.rx_bytes.fetch_add(len, Ordering::Relaxed);
DIAG_COUNTERS.mux_dispatch_ok.fetch_add(1, Ordering::Relaxed);
}
}
});
}
/// Удаляет поток, отменяя его токен (мгновенно гасит связанные задачи, включая
/// бэклог-дренер) и снимая привязку к ноге.
pub fn remove_stream(&self, stream_id: u32) {
// 🔥 Мгновенно убиваем "зомби-задачи", привязанные к стриму!
if let Some((_, (_, _, token))) = self.streams.remove(&stream_id) {
token.cancel();
if let Some((_, slot)) = self.streams.remove(&stream_id) {
slot.token.cancel();
}
self.stream_bindings.remove(&stream_id);
}
// ORDERING CONTRACT: in-order delivery — never spawn a task to deliver data
// from this function.
// ORDERING CONTRACT: preserved by construction — each stream has exactly one
// persistent backlog-drainer task (spawned once, at register_stream), so this
// function never spawns per-frame and never reorders within a stream.
//
// 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) {
// HEAD-OF-LINE GUARD: this function never awaits. The hot path is a
// non-blocking try_send; when the channel is momentarily full, the frame is
// queued in the stream's own backlog and the call returns immediately — the
// shared per-leg reader can move on to the next frame/stream right away.
//
// NO Ref HELD ACROSS A MUTATING CALL: the DashMap `Ref` from `streams.get`
// is dropped the instant we've cloned the owned handles we need (`tx`,
// `stats`, `backlog` — all cheap Arc/Sender clones). This function never
// calls anything that mutates `self.streams` for this same key — eviction
// is entirely the background reaper's job (see `spawn_backlog_reaper`) —
// so there is no risk of a shard read-lock (still held by an outer `Ref`)
// deadlocking against that shard's write-lock (DashMap locks are not
// reentrant). An earlier version held the `Ref` across a nested
// `remove_stream` call here and could self-deadlock the calling task.
pub fn dispatch_to_local(&self, stream_id: u32, data: Bytes) {
let size = data.len() as u64;
let tx_and_stats = self.streams.get(&stream_id).map(|s| {
let val = s.value();
(val.0.clone(), val.1.clone())
});
let Some((tx, stats)) = tx_and_stats else {
let Some((tx, stats, backlog)) = self.streams.get(&stream_id).map(|entry| {
let slot = entry.value();
(slot.tx.clone(), slot.stats.clone(), slot.backlog.clone())
}) else {
// No stream registered for this id (already closed / never opened).
DIAG_COUNTERS
.mux_dispatch_no_stream
.fetch_add(1, Ordering::Relaxed);
return;
};
let backlog_empty = backlog.bytes.load(Ordering::Acquire) == 0;
// 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;
// Fast path: nothing queued ahead of this frame, try to hand it straight
// to the consumer without ever touching the backlog.
if backlog_empty {
match tx.try_send(data) {
Ok(()) => {
stats.rx_bytes.fetch_add(size, Ordering::Relaxed);
backlog.mark_progress();
DIAG_COUNTERS.mux_dispatch_ok.fetch_add(1, Ordering::Relaxed);
return;
}
Err(TrySendError::Closed(_)) => {
DIAG_COUNTERS
.mux_dispatch_recv_closed
.fetch_add(1, Ordering::Relaxed);
return;
}
Err(TrySendError::Full(data)) => {
backlog.push(data, size);
}
}
// 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,
} else {
backlog.push(data, size);
}
}
/// Инициализирует кредитное окно потока: столько байт отправитель (эта
/// сторона) может протолкнуть, не дожидаясь `Credit`-кадра от приёмника.
/// Вызывает тот, кто НАЧИНАЕТ производить данные для потока (например,
/// `RemoteOpener::open_tcp` перед запуском моста к цели).
pub fn init_credit(&self, stream_id: u32, initial_bytes: u32) {
self.credits.insert(
stream_id,
Arc::new(CreditState {
available: std::sync::atomic::AtomicI64::new(initial_bytes as i64),
notify: Notify::new(),
last_grant_ms: AtomicU64::new(diagnostics::current_timestamp_ms()),
}),
);
}
/// Снимает кредитное окно потока. Вызывать при завершении отправки для
/// этого потока (симметрично `remove_stream`, но для другой карты —
/// `credits` живёт по циклу жизни ОТПРАВКИ, а не приёма).
pub fn drop_credit(&self, stream_id: u32) {
self.credits.remove(&stream_id);
}
/// Обрабатывает входящий `Credit`-кадр: пополняет окно и будит того, кто
/// сейчас ждёт кредит в [`consume_credit`]. No-op, если для этого
/// `stream_id` кредит не инициализирован (например, кадр пришёл уже после
/// `drop_credit`, или писала сторона, которая credit вообще не считает).
pub fn grant_credit(&self, stream_id: u32, bytes: u32) {
if let Some(state) = self.credits.get(&stream_id) {
let state = state.value();
state.available.fetch_add(bytes as i64, Ordering::AcqRel);
state
.last_grant_ms
.store(diagnostics::current_timestamp_ms(), Ordering::Relaxed);
state.notify.notify_one();
}
}
/// Ждёт, пока для потока не появится кредит, и забирает `min(available, want)`
/// байт. Возвращает `want` без ожидания, если credit для этого потока не
/// инициализирован (тот, кто вызвал, просто не участвует в этой схеме —
/// поведение как до появления credit-контроля).
///
/// Не блокирует НАВСЕГДА: если приёмник ни разу не прислал `Credit` дольше
/// [`CREDIT_FALLBACK_AFTER`] С МОМЕНТА ПОСЛЕДНЕГО РЕАЛЬНОГО ГРАНТА (не
/// понимает кадр, или сильно отстал), считаем credit-контроль неработающим
/// для этого потока и откатываемся на неограниченную отправку — байтовый
/// бюджет бэклога и его ридер остаются подстраховкой в любом случае.
///
/// Дедлайн считается от `last_grant_ms`, а НЕ от момента входа в эту
/// функцию: на высокой скорости `consume_credit` вызывается на каждый
/// ~64 КБ чанк, и если бы каждый вызов заново отсчитывал полный
/// `CREDIT_FALLBACK_AFTER`, окно, работающее штатно, но чуть отстающее от
/// потребления, топило бы скачивание до пары кадров в 10 секунд — то есть
/// сильнее, чем при полном отсутствии credit-контроля. Пока приёмник шлёт
/// гранты хоть с какой-то регулярностью, ожидание прерывается по `notify`
/// в течение примерно одного RTT, а не по этому дедлайну.
pub async fn consume_credit(&self, stream_id: u32, want: usize) -> usize {
let Some(state) = self.credits.get(&stream_id).map(|e| e.value().clone()) else {
return want;
};
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);
loop {
let avail = state.available.load(Ordering::Acquire);
if avail > 0 {
let take = (avail as usize).min(want);
state
.available
.fetch_sub(take as i64, Ordering::AcqRel);
return take;
}
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!(
let since_last_grant = diagnostics::current_timestamp_ms()
.saturating_sub(state.last_grant_ms.load(Ordering::Relaxed));
if since_last_grant >= crate::net::CREDIT_FALLBACK_AFTER.as_millis() as u64 {
trace!(
stream_id,
"dispatch_to_local: stream stalled for {:?}, closing", DISPATCH_TO_LOCAL_TIMEOUT
"consume_credit: no grant for {:?} — falling back to unrestricted",
crate::net::CREDIT_FALLBACK_AFTER
);
self.remove_stream(stream_id);
return want;
}
let _ = tokio::time::timeout(
crate::net::CREDIT_WAIT_POLL,
state.notify.notified(),
)
.await;
}
}
@@ -819,8 +1169,8 @@ impl Muxer {
.map(|kv| {
(
*kv.key(),
kv.value().1.tx_bytes.load(Ordering::Relaxed),
kv.value().1.rx_bytes.load(Ordering::Relaxed),
kv.value().stats.tx_bytes.load(Ordering::Relaxed),
kv.value().stats.rx_bytes.load(Ordering::Relaxed),
)
})
.collect();
@@ -889,6 +1239,7 @@ impl Muxer {
congestion_factor,
data_channel_free: free,
data_channel_capacity: cap,
session_id: self.session_id.to_string(),
}
})
.collect();
@@ -897,6 +1248,7 @@ impl Muxer {
global_min_rtt_ms: global_min_rtt,
active_legs,
total_streams: self.streams.len(),
session_count: 1,
}
}
}
+69 -17
View File
@@ -46,13 +46,63 @@ pub const BRIDGE_STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(30);
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);
/// Memory budget for one stream's local-delivery backlog (see
/// `Muxer::dispatch_to_local`). When a stream's receive channel is momentarily
/// full, frames queue here instead of blocking the shared per-leg reader — so a
/// slow-but-alive consumer (disk write hiccup, TUN backpressure, scheduler
/// jitter) gets as long as it needs to drain, while a genuinely dead stream
/// (e.g. a finished speedtest socket the app stopped reading) is caught by
/// exceeding this bound rather than by guessing a latency. Bytes, not
/// milliseconds, because "how slow is too slow" has no universal answer but
/// "how much unread data are we willing to hold for one stalled stream" does.
pub const STREAM_BACKLOG_MAX_BYTES: usize = 4 * 1024 * 1024;
/// Same budget, but for server-side streams (tunnel → real internet target,
/// i.e. the user's upload direction — see `bridge.rs` module docs for the
/// upload/download naming). Bigger than the client default: a remote target
/// is inherently slower and more variable than the local TUN device, so
/// uploads need more slack before a stalled target is judged dead.
pub const SERVER_STREAM_BACKLOG_MAX_BYTES: usize = 16 * 1024 * 1024;
/// How often the background backlog reaper (`Muxer::spawn_backlog_reaper`) scans
/// streams for genuinely stuck consumers. Runs off the hot path entirely — the
/// dispatch call itself never evicts anything — so this only bounds how far a
/// dead stream's backlog can overshoot its byte budget between ticks.
pub const BACKLOG_REAPER_INTERVAL: Duration = Duration::from_millis(500);
/// Grace window: a stream over its backlog byte budget is evicted only once it
/// has ALSO made no delivery progress for this long. Separates "backlog is
/// big because the producer is fast and still draining" from "backlog is big
/// because the consumer stopped entirely" — a raw byte cap alone can't tell
/// those apart, and evicting the former destabilizes healthy fast downloads.
pub const BACKLOG_STUCK_GRACE: Duration = Duration::from_secs(5);
/// How long a `Muxer` with zero legs and zero streams is kept alive before its
/// backlog reaper self-terminates. Without this, every session's reaper task
/// (and the Arc'd registries it keeps alive) would leak forever on a server
/// that has served many short-lived client sessions.
pub const BACKLOG_REAPER_IDLE_TIMEOUT: Duration = Duration::from_secs(120);
// ── End-to-end credit flow control (Muxer::init_credit/grant_credit/consume_credit) ──
/// Initial credit window granted to a stream's sender: how many bytes it may
/// push into the tunnel before it must wait for the receiver to grant more via
/// a `Credit` frame. Bounds how much can ever be "in flight" for one stream —
/// unlike the local byte-budget backlog (a last-resort backstop), this stops
/// the sender from ever producing the excess in the first place, so a slow
/// receiver never has to buffer-then-give-up.
pub const STREAM_CREDIT_INITIAL: u32 = 2 * 1024 * 1024;
/// The receiver batches freed bytes and sends one `Credit` frame per this many
/// bytes reclaimed, instead of one per delivered frame — same idea as TCP
/// delayed window updates, avoids flooding tiny control frames. Deliberately
/// finer than a quarter of the (possibly RTT-scaled, see
/// `adaptive_credit_window`) sender window: the receiver has no way to know
/// the sender's actual multiplier, and smaller/more frequent grants keep the
/// window topped up with less slack regardless of how big it ended up being.
pub const STREAM_CREDIT_RETURN_THRESHOLD: u32 = STREAM_CREDIT_INITIAL / 8;
/// How long `consume_credit` waits on each poll before re-checking the balance
/// (bounds the delay from a `notify` race, not a hard deadline by itself).
pub const CREDIT_WAIT_POLL: Duration = Duration::from_secs(2);
/// If the peer hasn't granted any credit at all for this long, treat it as not
/// speaking the credit protocol (or badly behind) and fall back to unrestricted
/// sending rather than stalling the stream forever — the byte-budget backlog
/// and its reaper remain the ultimate backstop either way.
pub const CREDIT_FALLBACK_AFTER: Duration = Duration::from_secs(10);
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);
@@ -111,20 +161,22 @@ pub const TUNNEL_MAX_BUFFER_SIZE: usize = 1024 * 1024;
/// Bytes reserved in the read buffer before each `read_buf` call.
pub const TUNNEL_READ_RESERVE: usize = 16 * 1024;
/// Maximum bytes written per stream in a single interleaved write pass.
/// Base value; adaptive_batch_chunk multiplies this by (1 + RTT_ms / 250) up to 4×.
/// At 300+ ms RTT, expect ~64 KB per pass (4× base), batching more frames per syscall.
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;
/// At high RTT (>300 ms), bigger chunks reduce context switches and improve
/// coalescing in the writer. Increased to 64 KB: still fits in wire frames
/// (multiple 16 KB NRXP frames per message) while batching better.
/// Per-leg queue size = CHANNEL_PACKETS × this ≈ 64 × 64 KB = 4 MB baseline.
pub const BRIDGE_READ_CHUNK: usize = 64 * 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. 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 send buffer for each tunnel leg. At high RTT (>300 ms),
/// this must accommodate BDP = bandwidth × RTT. For 300 Mbps and 350 ms,
/// BDP ≈ 13 MB, so 1 MB per leg is a floor. Scales per-leg: 4 legs × 1 MB = 4 MB
/// total OS buffer. Matches adaptive_batch_chunk logic (high RTT = bigger writes).
pub const TUNNEL_SOCKET_SNDBUF: u32 = 1024 * 1024;
/// OS-level TCP receive buffer for each tunnel leg. Larger than the send
/// buffer so the receiver can absorb bursts without dropping packets, but
/// bounded to keep stale in-flight download data (for already-closed streams)
+13
View File
@@ -132,6 +132,10 @@ pub enum DiagnosticsEvent {
leg_id: u32,
stream_id: u32,
},
/// Периодический/стартовый снапшот без конкретного события-триггера — "всё
/// по-прежнему живо". Раньше для этого переиспользовали `LegReconnecting{0,0}`,
/// что в логе выглядело как настоящий (несуществующий) реконнект ноги 0.
Heartbeat,
}
// ── Per-snapshot sub-structs (all Serialize) ──────────────────────────────────
@@ -167,6 +171,11 @@ pub struct LegMetrics {
pub congestion_factor: f64,
pub data_channel_free: usize,
pub data_channel_capacity: usize,
/// Сессия, которой принадлежит эта нога. На клиенте — всегда своя (одна на
/// процесс); на многоклиентском сервере разграничивает ноги разных сессий,
/// у которых `leg_id` иначе совпадали бы (каждая сессия нумерует свои ноги
/// независимо, 0..MAX_TUNNEL_LEGS).
pub session_id: String,
}
/// Сводка по туннелю в целом: глобальный мин. RTT, метрики всех ног, число потоков.
@@ -175,6 +184,10 @@ pub struct TunnelMetrics {
pub global_min_rtt_ms: u32,
pub active_legs: Vec<LegMetrics>,
pub total_streams: usize,
/// Сколько туннельных сессий покрывает этот снапшот. Для одного `Muxer`
/// (клиент) всегда 1; сервер агрегирует несколько сессий и подставляет сюда
/// реальное число одновременно подключённых клиентов.
pub session_count: usize,
}
/// Метрики одного smoltcp-сокета (только клиент): состояние, очереди, congestion.