Skip to main content

http_stat/
tcp_info.rs

1// Copyright 2025 Tree xie.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Kernel TCP statistics snapshot.
16//!
17//! Wraps platform `getsockopt(TCP_INFO)` / `getsockopt(TCP_CONNECTION_INFO)`
18//! behind a portable [`TcpInfo`] struct. A baseline sample taken right after
19//! `connect(2)` plus a final sample taken after the response body has been
20//! fully received together reveal whether "Content Transfer slow" came from
21//! packet loss / cwnd movement, rather than the application layer.
22//!
23//! Supported targets: Linux (`tcp_info`) and macOS (`tcp_connection_info`).
24//! Everything else returns `None` from sampling — the call sites stay
25//! cross-platform via the [`TcpInfoProbe`] wrapper.
26
27use std::time::Duration;
28use tokio::net::TcpStream;
29
30/// Portable subset of `getsockopt(TCP_INFO)` exposed in the user-facing API.
31///
32/// Anything unmeasurable on the current OS / libc binding stays `None`. The
33/// diff between a baseline and a final sample is more informative than any
34/// single value: it isolates retransmits and cwnd movement caused by *this*
35/// request.
36#[derive(Default, Debug, Clone)]
37pub struct TcpInfo {
38    /// Smoothed round-trip time estimate.
39    pub rtt: Option<Duration>,
40    /// RTT variance.
41    pub rttvar: Option<Duration>,
42    /// Cumulative retransmitted segments observed on this connection.
43    /// Linux: locally-emitted retransmits (`tcpi_total_retrans`).
44    /// macOS: inbound segments seen as retransmits (`tcpi_rxretransmitpackets`).
45    /// Different perspectives, both surface "this connection had loss".
46    pub retransmits: Option<u32>,
47    /// Congestion window in segments.
48    pub cwnd: Option<u32>,
49    /// Sender-side MSS in bytes.
50    pub snd_mss: Option<u32>,
51}
52
53/// Captured socket handle that allows a TCP_INFO sample to be taken later,
54/// after the original [`TcpStream`] has been moved into the HTTP stack.
55///
56/// On Unix this holds a `dup(2)`'d file descriptor pointing at the same
57/// kernel socket — counters are shared. Dropping the probe closes only the
58/// duplicate; the original socket lifetime is unchanged. On non-Unix this is
59/// a zero-sized placeholder and `sample()` always returns `None`.
60#[cfg(unix)]
61pub struct TcpInfoProbe(std::os::unix::io::OwnedFd);
62#[cfg(not(unix))]
63pub struct TcpInfoProbe;
64
65impl TcpInfoProbe {
66    /// Take an immediate sample on the original stream (no `dup` needed), and
67    /// return a probe that can be sampled again later.
68    pub fn capture(stream: &TcpStream) -> (Option<TcpInfo>, Option<Self>) {
69        #[cfg(unix)]
70        {
71            use std::os::unix::io::AsRawFd;
72            let raw = stream.as_raw_fd();
73            let now = sample_fd(raw);
74            // SAFETY: dup returns a fresh FD; FromRawFd takes ownership.
75            let dup_fd = unsafe { libc::dup(raw) };
76            let probe = if dup_fd >= 0 {
77                use std::os::unix::io::FromRawFd;
78                Some(TcpInfoProbe(unsafe {
79                    std::os::unix::io::OwnedFd::from_raw_fd(dup_fd)
80                }))
81            } else {
82                None
83            };
84            (now, probe)
85        }
86        #[cfg(not(unix))]
87        {
88            let _ = stream;
89            (None, None)
90        }
91    }
92
93    /// Sample the kernel TCP statistics for the held socket.
94    #[allow(clippy::unused_self)]
95    pub fn sample(&self) -> Option<TcpInfo> {
96        #[cfg(unix)]
97        {
98            use std::os::unix::io::AsRawFd;
99            sample_fd(self.0.as_raw_fd())
100        }
101        #[cfg(not(unix))]
102        {
103            None
104        }
105    }
106}
107
108#[cfg(target_os = "linux")]
109fn sample_fd(fd: std::os::unix::io::RawFd) -> Option<TcpInfo> {
110    // SAFETY: getsockopt fills a tcp_info buffer; we only read scalar fields.
111    unsafe {
112        let mut info: libc::tcp_info = std::mem::zeroed();
113        let mut len = std::mem::size_of::<libc::tcp_info>() as libc::socklen_t;
114        let r = libc::getsockopt(
115            fd,
116            libc::IPPROTO_TCP,
117            libc::TCP_INFO,
118            &mut info as *mut _ as *mut libc::c_void,
119            &mut len,
120        );
121        if r != 0 {
122            return None;
123        }
124        Some(TcpInfo {
125            // tcpi_rtt / tcpi_rttvar are in microseconds on Linux.
126            rtt: Some(Duration::from_micros(info.tcpi_rtt as u64)),
127            rttvar: Some(Duration::from_micros(info.tcpi_rttvar as u64)),
128            retransmits: Some(info.tcpi_total_retrans),
129            cwnd: Some(info.tcpi_snd_cwnd),
130            snd_mss: Some(info.tcpi_snd_mss),
131        })
132    }
133}
134
135#[cfg(target_os = "macos")]
136fn sample_fd(fd: std::os::unix::io::RawFd) -> Option<TcpInfo> {
137    // SAFETY: macOS exposes TCP statistics via TCP_CONNECTION_INFO with a
138    // tcp_connection_info struct. Layout matches XNU's <netinet/tcp.h>.
139    unsafe {
140        let mut info: libc::tcp_connection_info = std::mem::zeroed();
141        let mut len = std::mem::size_of::<libc::tcp_connection_info>() as libc::socklen_t;
142        let r = libc::getsockopt(
143            fd,
144            libc::IPPROTO_TCP,
145            libc::TCP_CONNECTION_INFO,
146            &mut info as *mut _ as *mut libc::c_void,
147            &mut len,
148        );
149        if r != 0 {
150            return None;
151        }
152        Some(TcpInfo {
153            // tcpi_srtt / tcpi_rttvar are in *milliseconds* on macOS, while
154            // tcpi_rttcur is the latest single sample. Use srtt to match
155            // Linux's "smoothed estimate" semantics.
156            rtt: Some(Duration::from_millis(info.tcpi_srtt as u64)),
157            rttvar: Some(Duration::from_millis(info.tcpi_rttvar as u64)),
158            retransmits: Some(info.tcpi_rxretransmitpackets as u32),
159            cwnd: Some(info.tcpi_snd_cwnd),
160            snd_mss: Some(info.tcpi_maxseg),
161        })
162    }
163}
164
165#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))]
166fn sample_fd(_fd: std::os::unix::io::RawFd) -> Option<TcpInfo> {
167    None
168}
169
170/// Delta between two TCP_INFO samples — most useful: how many retransmits
171/// occurred *during* the request, plus the final RTT / cwnd seen by the kernel.
172#[derive(Default, Debug, Clone)]
173pub struct TcpInfoDelta {
174    pub retransmits_during: Option<u32>,
175    pub rtt_final: Option<Duration>,
176    pub cwnd_final: Option<u32>,
177}
178
179impl TcpInfoDelta {
180    pub fn compute(post_connect: Option<&TcpInfo>, final_: Option<&TcpInfo>) -> Option<Self> {
181        let f = final_?;
182        let retransmits_during = match (post_connect.and_then(|p| p.retransmits), f.retransmits) {
183            (Some(start), Some(end)) => Some(end.saturating_sub(start)),
184            _ => None,
185        };
186        Some(Self {
187            retransmits_during,
188            rtt_final: f.rtt,
189            cwnd_final: f.cwnd,
190        })
191    }
192}