use std::time::Duration;
use crate::node::background_task_monitor::BackgroundTaskMonitor;
use crate::simulation::{RealTime, TimeSource};
use crate::transport::TRANSPORT_METRICS;
use crate::transport::shadow_stats::{SHADOW_ROLLUP_WINDOW_SECS, WindowedStat};
const MONITOR_INTERVAL: Duration = Duration::from_secs(1);
const PROC_NET_DEV: &str = "/proc/net/dev";
pub(crate) fn spawn_iface_tx_monitor(local_peer_id: String, monitor: &BackgroundTaskMonitor) {
let handle = tokio::spawn(async move {
let mut ticker = tokio::time::interval(MONITOR_INTERVAL);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
ticker.tick().await;
let time = RealTime::new();
let mut prev_total = read_total_tx_bytes().await;
let mut prev_own = TRANSPORT_METRICS.cumulative_bytes_sent();
let mut baseline_at_nanos = time.now_nanos();
let mut window = IfaceWindow::default();
loop {
ticker.tick().await;
let now_total = read_total_tx_bytes().await;
let now_own = TRANSPORT_METRICS.cumulative_bytes_sent();
let now_nanos = time.now_nanos();
if let (Some(prev), Some(now)) = (prev_total, now_total) {
let interval_secs = elapsed_secs(baseline_at_nanos, now_nanos);
let total_delta = now.saturating_sub(prev);
let own_delta = now_own.saturating_sub(prev_own);
let op_delta = iface_op(total_delta, own_delta);
window.record(
per_sec(total_delta, interval_secs),
per_sec(own_delta, interval_secs),
per_sec(op_delta, interval_secs),
);
}
window.ticks += 1;
if window.ticks >= SHADOW_ROLLUP_WINDOW_SECS {
emit_iface_rollup(&local_peer_id, &window);
window = IfaceWindow::default();
}
if now_total.is_some() {
prev_total = now_total;
prev_own = now_own;
baseline_at_nanos = now_nanos;
}
}
});
monitor.register("shadow_iface_tx_monitor", handle);
}
#[derive(Default)]
struct IfaceWindow {
ticks: u32,
samples: u32,
total_tx_bytes: WindowedStat,
own_tx_bytes: WindowedStat,
op_tx_bytes: WindowedStat,
}
impl IfaceWindow {
fn record(&mut self, total: u64, own: u64, op: u64) {
self.samples += 1;
self.total_tx_bytes.record(total);
self.own_tx_bytes.record(own);
self.op_tx_bytes.record(op);
}
}
async fn read_total_tx_bytes() -> Option<u64> {
let contents = tokio::fs::read_to_string(PROC_NET_DEV).await.ok()?;
parse_total_tx_bytes(&contents)
}
fn parse_total_tx_bytes(contents: &str) -> Option<u64> {
const TX_BYTES_FIELD: usize = 8;
let mut total: u64 = 0;
let mut found = false;
for line in contents.lines() {
let Some((iface, rest)) = line.split_once(':') else {
continue;
};
let iface = iface.trim();
if iface.is_empty() || iface == "lo" {
continue;
}
if let Some(tx) = rest
.split_whitespace()
.nth(TX_BYTES_FIELD)
.and_then(|s| s.parse::<u64>().ok())
{
total = total.saturating_add(tx);
found = true;
}
}
found.then_some(total)
}
fn iface_op(total_delta: u64, own_delta: u64) -> u64 {
total_delta.saturating_sub(own_delta)
}
fn per_sec(delta: u64, interval_secs: u64) -> u64 {
delta / interval_secs.max(1)
}
fn elapsed_secs(baseline_nanos: u64, now_nanos: u64) -> u64 {
let elapsed = now_nanos.saturating_sub(baseline_nanos);
(elapsed + 500_000_000) / 1_000_000_000
}
fn emit_iface_rollup(local_peer_id: &str, window: &IfaceWindow) {
if window.samples == 0 {
return;
}
tracing::debug!(
target: "freenet::transport::shadow_iface_tx",
total_tx_bytes = window.total_tx_bytes.mean(),
own_tx_bytes = window.own_tx_bytes.mean(),
op_tx_bytes = window.op_tx_bytes.mean(),
window_secs = SHADOW_ROLLUP_WINDOW_SECS,
"shadow_iface_tx"
);
crate::tracing::telemetry::send_standalone_shadow_event_with_peer_id(
"shadow_iface_tx",
local_peer_id,
iface_rollup_json(window),
);
}
fn iface_rollup_json(window: &IfaceWindow) -> serde_json::Value {
serde_json::json!({
"total_tx_bytes_per_sec": window.total_tx_bytes.mean(),
"total_tx_bytes_per_sec_p50": window.total_tx_bytes.p50(),
"total_tx_bytes_per_sec_max": window.total_tx_bytes.max(),
"freenet_own_tx_bytes_per_sec": window.own_tx_bytes.mean(),
"freenet_own_tx_bytes_per_sec_p50": window.own_tx_bytes.p50(),
"freenet_own_tx_bytes_per_sec_max": window.own_tx_bytes.max(),
"op_tx_bytes_per_sec": window.op_tx_bytes.mean(),
"op_tx_bytes_per_sec_p50": window.op_tx_bytes.p50(),
"op_tx_bytes_per_sec_max": window.op_tx_bytes.max(),
"window_secs": SHADOW_ROLLUP_WINDOW_SECS,
"samples": window.samples,
})
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "Inter-| Receive | Transmit\n\
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed\n\
lo: 1000 10 0 0 0 0 0 0 1000 10 0 0 0 0 0 0\n\
eth0: 500000 1000 0 0 0 0 0 0 250000 800 0 0 0 0 0 0\n\
wlan0: 10 1 0 0 0 0 0 0 7500 5 0 0 0 0 0 0\n";
#[test]
fn parse_sums_tx_excluding_loopback() {
assert_eq!(parse_total_tx_bytes(SAMPLE), Some(257_500));
}
#[test]
fn parse_returns_none_when_no_interface_lines() {
let headers = "Inter-| Receive | Transmit\n face |bytes ... |bytes ...\n";
assert_eq!(parse_total_tx_bytes(headers), None);
assert_eq!(parse_total_tx_bytes(""), None);
}
#[test]
fn parse_skips_only_loopback_named_lo() {
let input = " lodev: 1 2 3 4 5 6 7 8 999 10\n";
assert_eq!(parse_total_tx_bytes(input), Some(999));
}
#[test]
fn parse_tolerates_short_or_garbage_lines() {
let input = " eth0: 1 2 3\n eth1: 1 2 3 4 5 6 7 8 4242 9\n";
assert_eq!(parse_total_tx_bytes(input), Some(4242));
}
#[test]
fn parse_handles_colon_glued_to_first_counter() {
let glued = "eth0:100 1 2 3 4 5 6 7 9999 10\n";
assert_eq!(parse_total_tx_bytes(glued), Some(9999));
}
#[test]
fn parse_handles_huge_counters_without_overflow() {
let big = u64::MAX - 1;
let input = format!("eth0: 1 2 3 4 5 6 7 8 {big} 9\neth1: 1 2 3 4 5 6 7 8 5 9\n");
assert_eq!(parse_total_tx_bytes(&input), Some(u64::MAX));
}
#[test]
fn iface_op_is_total_minus_own() {
assert_eq!(iface_op(10_000, 3_000), 7_000);
assert_eq!(iface_op(0, 0), 0);
}
#[test]
fn iface_op_saturates_when_own_exceeds_total() {
assert_eq!(iface_op(1_000, 4_000), 0);
}
#[test]
fn per_sec_normalizes_bridged_gap_to_a_rate() {
assert_eq!(per_sec(9_000, 1), 9_000);
assert_eq!(per_sec(300_000, 30), 10_000);
assert_eq!(per_sec(500, 0), 500);
}
#[test]
fn elapsed_secs_uses_wall_clock_not_tick_count() {
assert_eq!(elapsed_secs(0, 1_000_000_000), 1);
assert_eq!(elapsed_secs(0, 5_000_000_000), 5);
let stalled_delta = 500_000u64; assert_eq!(
per_sec(stalled_delta, elapsed_secs(0, 5_000_000_000)),
100_000,
"a 5 s stall normalizes to the real per-second rate, not a 5x burst"
);
assert_eq!(elapsed_secs(1_000_000_000, 31_000_000_000), 30);
assert_eq!(elapsed_secs(0, 1_400_000_000), 1);
assert_eq!(elapsed_secs(0, 1_600_000_000), 2);
assert_eq!(elapsed_secs(0, 200_000_000), 0);
assert_eq!(per_sec(500, elapsed_secs(0, 200_000_000)), 500);
}
#[test]
fn bridged_gap_does_not_fake_a_burst_in_the_rollup() {
let mut window = IfaceWindow::default();
for _ in 0..29 {
window.ticks += 1;
}
let interval = 30u64;
window.record(per_sec(300_000, interval), per_sec(60_000, interval), 0);
window.ticks += 1;
let json = iface_rollup_json(&window);
assert_eq!(json["total_tx_bytes_per_sec"], 10_000);
assert_eq!(json["total_tx_bytes_per_sec_max"], 10_000);
assert_eq!(json["freenet_own_tx_bytes_per_sec"], 2_000);
assert_eq!(json["samples"], 1);
assert_eq!(json["window_secs"], SHADOW_ROLLUP_WINDOW_SECS);
}
#[test]
fn iface_rollup_json_includes_p50_for_byte_rates() {
let mut window = IfaceWindow::default();
window.record(10_000, 4_000, 6_000);
window.record(30_000, 4_000, 26_000);
window.ticks += 2;
let json = iface_rollup_json(&window);
assert_eq!(json["total_tx_bytes_per_sec"], 20_000); assert_eq!(json["total_tx_bytes_per_sec_p50"], 30_000);
assert_eq!(json["total_tx_bytes_per_sec_max"], 30_000);
assert_eq!(json["op_tx_bytes_per_sec_p50"], 26_000);
assert_eq!(json["samples"], 2);
}
#[tokio::test(start_paused = true)]
async fn monitor_survives_multiple_ticks() {
let monitor = BackgroundTaskMonitor::new();
spawn_iface_tx_monitor("test-peer".to_string(), &monitor);
tokio::time::advance(MONITOR_INTERVAL * 4 + Duration::from_millis(100)).await;
tokio::task::yield_now().await;
let exit = monitor.wait_for_any_exit();
tokio::pin!(exit);
let still_running = tokio::time::timeout(Duration::from_millis(50), &mut exit)
.await
.is_err();
assert!(
still_running,
"iface-tx monitor task should still be alive after a few ticks"
);
}
}