Skip to main content

subetha_cxc/
net_events.rs

1//! Active OS path-event observer: a background watcher that fires the instant
2//! the kernel's route table, an interface carrier, or the path MTU changes -
3//! *ahead of any loss*.
4//!
5//! This is the active dual of [`crate::path_sensor`]. The path sensor is
6//! passive: it learns of a path change only after a received datagram's TTL
7//! reveals a new hop count, which is one round trip late. A re-route or a
8//! Wi-Fi roam, by contrast, is announced by the OS the moment it happens - on
9//! a netlink multicast group (Linux), a `PF_ROUTE` socket (the BSDs), or an
10//! IP-helper change callback (Windows). Subscribing to that announcement lets
11//! the controller pre-arm protection a full round trip before the first
12//! datagram even reflects the new path.
13//!
14//! Two signals come out:
15//!
16//!  - **Path shift** (0..=1): spikes to 1.0 on a route / carrier / MTU event
17//!    and decays with a fixed half-life, exactly like the hop-count shift, so
18//!    the fusion controller treats an OS-announced path change the same way it
19//!    treats a hop-count change. The sender fuses it as a third `path_shift`
20//!    source alongside the passive hop-count shift and the link-class shift.
21//!  - **Path MTU**: the egress interface MTU. A drop (1500 -> ~1280) is the
22//!    tell of a lower-MTU link engaging - a cellular handoff, a tunnel coming
23//!    up - and is itself a path event. Each endpoint reports its own MTU to
24//!    the peer in a [`PmtuFrame`], so a receiver-side MTU drop rides its
25//!    feedback to the sender and pre-arms that end too.
26//!
27//! The watcher runs on its own thread (Linux / BSD) or as an OS change
28//! callback (Windows); the controller reads the two signals through cheap
29//! lock-free atomics on its normal cadence. Per platform:
30//!
31//!  - **Linux**: an `AF_NETLINK` / `NETLINK_ROUTE` socket bound to the link,
32//!    address, and route multicast groups; the egress MTU from
33//!    `/sys/class/net/<iface>/mtu`.
34//!  - **FreeBSD / macOS**: a `PF_ROUTE` raw socket (every routing-table
35//!    change is delivered). The MTU read is a Linux / Windows capability; on
36//!    the BSDs the observer reports route and carrier events and `pmtu`
37//!    stays `None`.
38//!  - **Windows**: `NotifyRouteChange2` + `NotifyIpInterfaceChange` callbacks;
39//!    the egress MTU from the best up, non-loopback `GetIfTable2` row.
40//!  - **Other**: a stub that never fires (always `path_shift = 0`, `pmtu`
41//!    `None`).
42//!
43//! [`PmtuFrame`]: crate::control_frame::PmtuFrame
44
45use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
46use std::sync::Arc;
47use std::time::Instant;
48
49/// Half-life of the path-shift spike, in seconds: the signal is 1.0 at the
50/// event and halves every `SHIFT_HALF_LIFE_SECS` thereafter, so it stays above
51/// the fusion controller's 0.5 pre-arm threshold for about this long.
52const SHIFT_HALF_LIFE_SECS: f32 = 2.0;
53
54/// The decaying path-shift value `secs_since_event` after the most recent
55/// event: 1.0 at the event, halving every [`SHIFT_HALF_LIFE_SECS`]. A pure
56/// function of elapsed time, so the decay is deterministically testable.
57fn decayed_shift(secs_since_event: f32) -> f32 {
58    if secs_since_event <= 0.0 {
59        return 1.0;
60    }
61    0.5f32.powf(secs_since_event / SHIFT_HALF_LIFE_SECS)
62}
63
64/// Lock-free state shared between the OS watcher (thread or callback) and the
65/// controller that reads it. Every field is an atomic so the reader never
66/// blocks the watcher and the watcher never blocks the reader.
67struct NetEventState {
68    /// Total path events observed (monotonic). A nonzero value is the durable
69    /// proof the watcher fired, surviving the path-shift decay.
70    event_count: AtomicU64,
71    /// `start.elapsed()` nanos at the most recent event; meaningful only once
72    /// `have_event` is set.
73    last_event_nanos: AtomicU64,
74    /// Whether any event has been recorded yet (so a fresh observer reports a
75    /// path shift of 0, not the decayed-from-zero 1.0).
76    have_event: AtomicBool,
77    /// Current egress-interface MTU in bytes; 0 means unknown / unavailable.
78    pmtu: AtomicU32,
79    /// Monotonic origin for the event timestamps.
80    start: Instant,
81}
82
83impl NetEventState {
84    fn new() -> Self {
85        Self {
86            event_count: AtomicU64::new(0),
87            last_event_nanos: AtomicU64::new(0),
88            have_event: AtomicBool::new(false),
89            pmtu: AtomicU32::new(0),
90            start: Instant::now(),
91        }
92    }
93
94    /// Record a path event: bump the count and stamp the time, spiking the
95    /// path shift to 1.0.
96    fn record_event(&self) {
97        self.event_count.fetch_add(1, Ordering::Relaxed);
98        let t = self.start.elapsed().as_nanos() as u64;
99        self.last_event_nanos.store(t, Ordering::Relaxed);
100        self.have_event.store(true, Ordering::Relaxed);
101    }
102
103    /// Store the current MTU without treating it as an event. Used by the OS
104    /// watcher, which already records the event for the netlink / callback
105    /// message that delivered the change, so re-reading the MTU here must not
106    /// double-count.
107    fn set_pmtu(&self, mtu: u16) {
108        if mtu != 0 {
109            self.pmtu.store(mtu as u32, Ordering::Relaxed);
110        }
111    }
112
113    /// Store the MTU and, if it dropped below the last known value, record a
114    /// path event - a path-MTU decrease is a path change in its own right.
115    /// Used by the synthetic inject path and exercised by the unit tests; the
116    /// real OS watcher uses [`set_pmtu`](Self::set_pmtu) because the kernel
117    /// message that carried the change already recorded the event.
118    fn note_pmtu(&self, mtu: u16) {
119        if mtu == 0 {
120            return;
121        }
122        let prev = self.pmtu.swap(mtu as u32, Ordering::Relaxed);
123        if prev != 0 && (mtu as u32) < prev {
124            self.record_event();
125        }
126    }
127
128    fn path_shift(&self) -> f32 {
129        if !self.have_event.load(Ordering::Relaxed) {
130            return 0.0;
131        }
132        let last = self.last_event_nanos.load(Ordering::Relaxed);
133        let now = self.start.elapsed().as_nanos() as u64;
134        let secs = now.saturating_sub(last) as f32 / 1e9;
135        decayed_shift(secs)
136    }
137
138    fn pmtu(&self) -> Option<u16> {
139        let v = self.pmtu.load(Ordering::Relaxed);
140        (v != 0).then_some(v as u16)
141    }
142
143    fn event_count(&self) -> u64 {
144        self.event_count.load(Ordering::Relaxed)
145    }
146}
147
148/// A running path-event observer. Construct one with [`start`](Self::start);
149/// the OS watcher runs until the observer is dropped, which stops the thread
150/// (Linux / BSD) or cancels the change callbacks (Windows).
151pub struct NetEventObserver {
152    state: Arc<NetEventState>,
153    backend: &'static str,
154    /// Platform watcher handle whose `Drop` stops the thread / cancels the
155    /// callbacks. Absent on the stub platform, which has nothing to stop.
156    #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))]
157    _watcher: unix_watch::Watcher,
158    #[cfg(target_os = "windows")]
159    _watcher: windows_watch::Watcher,
160}
161
162impl NetEventObserver {
163    /// Start watching for path events. `iface` names the interface to read the
164    /// MTU from; `None` auto-detects the first non-loopback up interface (the
165    /// usual single-uplink case). Watcher startup is best-effort: if the OS
166    /// notification source cannot be opened the observer still constructs and
167    /// simply never fires, so a caller need not handle a failure.
168    pub fn start(iface: Option<String>) -> Self {
169        let state = Arc::new(NetEventState::new());
170        // Seed the MTU once so `pmtu()` is populated from the start, before any
171        // event re-reads it.
172        if let Some(mtu) = read_iface_mtu(iface.as_deref()) {
173            state.set_pmtu(mtu);
174        }
175        #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))]
176        {
177            let (watcher, backend) = unix_watch::Watcher::start(Arc::clone(&state), iface);
178            Self { state, backend, _watcher: watcher }
179        }
180        #[cfg(target_os = "windows")]
181        {
182            drop(iface);
183            let (watcher, backend) = windows_watch::Watcher::start(Arc::clone(&state));
184            Self { state, backend, _watcher: watcher }
185        }
186        #[cfg(not(any(
187            target_os = "linux",
188            target_os = "freebsd",
189            target_os = "macos",
190            target_os = "windows"
191        )))]
192        {
193            drop(iface);
194            Self { state, backend: "stub" }
195        }
196    }
197
198    /// Decaying path-shift signal (0..=1): high just after a route / carrier /
199    /// MTU event, fading with a fixed half-life. Fused as a `path_shift` source.
200    pub fn path_shift(&self) -> f32 {
201        self.state.path_shift()
202    }
203
204    /// Current egress-interface MTU in bytes, or `None` if unknown / the
205    /// platform does not read it. Reported to the peer in a [`PmtuFrame`].
206    ///
207    /// [`PmtuFrame`]: crate::control_frame::PmtuFrame
208    pub fn pmtu(&self) -> Option<u16> {
209        self.state.pmtu()
210    }
211
212    /// Total path events observed since start (monotonic). A nonzero value is
213    /// the durable proof the watcher fired, independent of the shift decay.
214    pub fn event_count(&self) -> u64 {
215        self.state.event_count()
216    }
217
218    /// Backend identifier (for diagnostics).
219    pub fn backend(&self) -> &'static str {
220        self.backend
221    }
222
223    /// Synthetically record a path event, as if the OS had announced a route /
224    /// carrier change. Drives the `--sim-path-event` demo and the unit tests on
225    /// a host where flapping a real interface is impractical; the production
226    /// path is the OS watcher.
227    pub fn inject_event(&self) {
228        self.state.record_event();
229    }
230
231    /// Synthetically report a path MTU, recording an event if it is a drop -
232    /// the same path a polled MTU decrease would take. For tests / demos.
233    pub fn inject_pmtu(&self, mtu: u16) {
234        self.state.note_pmtu(mtu);
235    }
236}
237
238/// Read the egress-interface MTU for this platform, or `None` if unavailable.
239fn read_iface_mtu(iface: Option<&str>) -> Option<u16> {
240    #[cfg(target_os = "linux")]
241    {
242        unix_watch::read_iface_mtu_linux(iface)
243    }
244    #[cfg(target_os = "windows")]
245    {
246        let _iface = iface; // Windows reads the MTU via GetIfTable2, not by name.
247        windows_watch::read_iface_mtu_win()
248    }
249    #[cfg(any(target_os = "freebsd", target_os = "macos"))]
250    {
251        unix_watch::read_iface_mtu_bsd(iface)
252    }
253    #[cfg(not(any(
254        target_os = "linux",
255        target_os = "windows",
256        target_os = "freebsd",
257        target_os = "macos"
258    )))]
259    {
260        let _iface = iface; // No MTU source on this platform.
261        None
262    }
263}
264
265#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))]
266mod unix_watch {
267    use super::NetEventState;
268    use std::mem::size_of;
269    use std::sync::atomic::{AtomicBool, Ordering};
270    use std::sync::Arc;
271    use std::thread::JoinHandle;
272
273    /// The first non-loopback interface whose `operstate` is `up` (Linux). The
274    /// egress MTU is read from this interface unless the caller named one.
275    #[cfg(target_os = "linux")]
276    fn detect_iface() -> Option<String> {
277        let entries = std::fs::read_dir("/sys/class/net").ok()?;
278        for e in entries.flatten() {
279            let name = e.file_name().to_string_lossy().into_owned();
280            if name == "lo" {
281                continue;
282            }
283            let up = std::fs::read_to_string(e.path().join("operstate"))
284                .map(|s| s.trim() == "up")
285                .unwrap_or(false);
286            if up {
287                return Some(name);
288            }
289        }
290        None
291    }
292
293    /// Read `/sys/class/net/<iface>/mtu` (Linux), auto-detecting the interface
294    /// when none is named.
295    #[cfg(target_os = "linux")]
296    pub fn read_iface_mtu_linux(iface: Option<&str>) -> Option<u16> {
297        let name = iface.map(str::to_owned).or_else(detect_iface)?;
298        let p = format!("/sys/class/net/{name}/mtu");
299        std::fs::read_to_string(p).ok()?.trim().parse().ok()
300    }
301
302    /// The first non-loopback interface that is up and running (BSD). The
303    /// egress MTU is read from this interface unless the caller named one.
304    #[cfg(any(target_os = "freebsd", target_os = "macos"))]
305    fn detect_iface_bsd() -> Option<String> {
306        let mut ifap: *mut libc::ifaddrs = std::ptr::null_mut();
307        // SAFETY: `ifap` is a valid out-parameter; the list it returns is
308        // freed below on every path.
309        if unsafe { libc::getifaddrs(&mut ifap) } != 0 {
310            return None;
311        }
312        let mut found = None;
313        let mut cur = ifap;
314        while !cur.is_null() {
315            // SAFETY: the kernel builds this list null-terminated, and `cur`
316            // is non-null here.
317            let ifa = unsafe { &*cur };
318            let flags = ifa.ifa_flags as libc::c_int;
319            if !ifa.ifa_name.is_null()
320                && flags & libc::IFF_LOOPBACK == 0
321                && flags & libc::IFF_UP != 0
322                && flags & libc::IFF_RUNNING != 0
323            {
324                // SAFETY: `ifa_name` is a NUL-terminated C string owned by
325                // the list, read before the list is freed.
326                let name = unsafe { std::ffi::CStr::from_ptr(ifa.ifa_name) };
327                found = name.to_str().ok().map(str::to_owned);
328                if found.is_some() {
329                    break;
330                }
331            }
332            cur = ifa.ifa_next;
333        }
334        // SAFETY: `ifap` came from a successful getifaddrs and is freed once.
335        unsafe { libc::freeifaddrs(ifap) };
336        found
337    }
338
339    /// `struct ifreq` as far as `SIOCGIFMTU` needs it: the interface name,
340    /// then the `ifr_ifru` union whose first word carries the MTU. The
341    /// union is a `sockaddr`'s 16 bytes.
342    #[cfg(any(target_os = "freebsd", target_os = "macos"))]
343    #[repr(C)]
344    struct IfReqMtu {
345        ifr_name: [libc::c_char; libc::IF_NAMESIZE],
346        ifru_mtu: libc::c_int,
347        _ifru_rest: [u8; 12],
348    }
349
350    /// The kernel sizes the request code from `struct ifreq`, so a layout
351    /// that is not 32 bytes would encode a different ioctl.
352    #[cfg(any(target_os = "freebsd", target_os = "macos"))]
353    const _: () = assert!(size_of::<IfReqMtu>() == 32);
354
355    /// `SIOCGIFMTU`, encoded here because the libc crate does not export it
356    /// for the BSDs. `_IOWR('i', 51, struct ifreq)` packs the read/write
357    /// direction bits, the parameter size, the group letter and the command
358    /// number; on FreeBSD 15.0 that is 0xc0206933.
359    #[cfg(any(target_os = "freebsd", target_os = "macos"))]
360    const SIOCGIFMTU: libc::c_ulong = {
361        const IOC_INOUT: libc::c_ulong = 0xC000_0000;
362        const IOCPARM_MASK: libc::c_ulong = 0x1fff;
363        IOC_INOUT
364            | (((size_of::<IfReqMtu>() as libc::c_ulong) & IOCPARM_MASK) << 16)
365            | ((b'i' as libc::c_ulong) << 8)
366            | 51
367    };
368
369    /// Read the egress MTU with the `SIOCGIFMTU` ioctl (FreeBSD / macOS),
370    /// auto-detecting the interface when none is named. `None` when no
371    /// interface qualifies or the ioctl fails.
372    #[cfg(any(target_os = "freebsd", target_os = "macos"))]
373    pub fn read_iface_mtu_bsd(iface: Option<&str>) -> Option<u16> {
374        let name = iface.map(str::to_owned).or_else(detect_iface_bsd)?;
375        let bytes = name.as_bytes();
376        // The name must fit with room for the NUL the kernel expects.
377        if bytes.is_empty() || bytes.len() >= libc::IF_NAMESIZE {
378            return None;
379        }
380        let mut req = IfReqMtu {
381            ifr_name: [0; libc::IF_NAMESIZE],
382            ifru_mtu: 0,
383            _ifru_rest: [0; 12],
384        };
385        for (slot, b) in req.ifr_name.iter_mut().zip(bytes) {
386            *slot = *b as libc::c_char;
387        }
388        // SAFETY: a datagram socket is just the handle the ioctl needs; it
389        // is closed below whatever the ioctl returns.
390        let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0) };
391        if fd < 0 {
392            return None;
393        }
394        // SAFETY: `fd` is a valid socket and `req` is a live ifreq for the
395        // duration of the call.
396        let rc = unsafe {
397            libc::ioctl(fd, SIOCGIFMTU, &raw mut req as *mut libc::c_void)
398        };
399        // SAFETY: `fd` was opened above and is not referenced again.
400        unsafe { libc::close(fd) };
401        if rc != 0 {
402            return None;
403        }
404        u16::try_from(req.ifru_mtu).ok().filter(|m| *m > 0)
405    }
406
407    /// The watcher handle: its `Drop` signals the thread to stop and joins it,
408    /// so the netlink / route socket is closed and no thread leaks.
409    pub struct Watcher {
410        stop: Arc<AtomicBool>,
411        join: Option<JoinHandle<()>>,
412    }
413
414    impl Watcher {
415        pub fn start(state: Arc<NetEventState>, iface: Option<String>) -> (Self, &'static str) {
416            let stop = Arc::new(AtomicBool::new(false));
417            let join = spawn(Arc::clone(&state), Arc::clone(&stop), iface);
418            (Self { stop, join }, BACKEND)
419        }
420    }
421
422    impl Drop for Watcher {
423        fn drop(&mut self) {
424            self.stop.store(true, Ordering::Relaxed);
425            if let Some(j) = self.join.take() {
426                // The thread polls the stop flag on a sub-second receive
427                // timeout, so the join completes within one timeout window.
428                j.join().ok();
429            }
430        }
431    }
432
433    #[cfg(target_os = "linux")]
434    const BACKEND: &str = "linux-netlink";
435    #[cfg(any(target_os = "freebsd", target_os = "macos"))]
436    const BACKEND: &str = "bsd-pf-route";
437
438    /// 250 ms receive timeout: long enough that the blocking `recv` spends
439    /// almost all its time parked, short enough that a stop request is honored
440    /// promptly.
441    const RECV_TIMEOUT_US: i64 = 250_000;
442
443    /// Set `SO_RCVTIMEO` so the blocking `recv` wakes periodically to check the
444    /// stop flag instead of blocking forever.
445    ///
446    /// # Safety
447    /// `fd` must be a valid socket file descriptor.
448    unsafe fn set_recv_timeout(fd: i32) {
449        let tv = libc::timeval {
450            tv_sec: 0,
451            tv_usec: RECV_TIMEOUT_US as libc::suseconds_t,
452        };
453        // SAFETY: `tv` is a valid timeval that outlives the call; `fd` is a
454        // valid socket.
455        unsafe {
456            libc::setsockopt(
457                fd,
458                libc::SOL_SOCKET,
459                libc::SO_RCVTIMEO,
460                &tv as *const libc::timeval as *const libc::c_void,
461                size_of::<libc::timeval>() as libc::socklen_t,
462            );
463        }
464    }
465
466    /// Open the OS path-notification socket: a bound `NETLINK_ROUTE` socket on
467    /// Linux. `None` on any error.
468    ///
469    /// # Safety
470    /// The returned fd is owned by the caller, which must close it.
471    #[cfg(target_os = "linux")]
472    unsafe fn open_event_socket() -> Option<i32> {
473        // Subscribe to link (carrier), address, and route changes for IPv4 and
474        // IPv6: every path event the controller cares about flows through one
475        // of these multicast groups.
476        const RTMGRP_LINK: u32 = 1;
477        const RTMGRP_IPV4_IFADDR: u32 = 0x10;
478        const RTMGRP_IPV4_ROUTE: u32 = 0x40;
479        const RTMGRP_IPV6_IFADDR: u32 = 0x100;
480        const RTMGRP_IPV6_ROUTE: u32 = 0x400;
481        // SAFETY: a zeroed sockaddr_nl is a valid bind address; the socket is
482        // closed by the caller on every error path.
483        unsafe {
484            let fd = libc::socket(libc::AF_NETLINK, libc::SOCK_RAW, libc::NETLINK_ROUTE);
485            if fd < 0 {
486                return None;
487            }
488            let mut addr: libc::sockaddr_nl = std::mem::zeroed();
489            addr.nl_family = libc::AF_NETLINK as u16;
490            addr.nl_groups = RTMGRP_LINK
491                | RTMGRP_IPV4_IFADDR
492                | RTMGRP_IPV4_ROUTE
493                | RTMGRP_IPV6_IFADDR
494                | RTMGRP_IPV6_ROUTE;
495            if libc::bind(
496                fd,
497                &addr as *const _ as *const libc::sockaddr,
498                size_of::<libc::sockaddr_nl>() as libc::socklen_t,
499            ) < 0
500            {
501                libc::close(fd);
502                return None;
503            }
504            set_recv_timeout(fd);
505            Some(fd)
506        }
507    }
508
509    /// A `PF_ROUTE` raw socket delivers every routing-table change to a
510    /// listener with no explicit subscription (the BSDs / macOS).
511    ///
512    /// # Safety
513    /// The returned fd is owned by the caller, which must close it.
514    #[cfg(any(target_os = "freebsd", target_os = "macos"))]
515    unsafe fn open_event_socket() -> Option<i32> {
516        // SAFETY: a PF_ROUTE raw socket needs no bind; closed by the caller.
517        unsafe {
518            let fd = libc::socket(libc::PF_ROUTE, libc::SOCK_RAW, 0);
519            if fd < 0 {
520                return None;
521            }
522            set_recv_timeout(fd);
523            Some(fd)
524        }
525    }
526
527    /// The BSDs report route / carrier events here; the MTU read is a Linux /
528    /// Windows capability, so `pmtu` stays `None` on these targets.
529    #[cfg(any(target_os = "freebsd", target_os = "macos"))]
530    fn read_iface_mtu_after_event(_iface: &Option<String>) -> Option<u16> {
531        None
532    }
533
534    #[cfg(target_os = "linux")]
535    fn read_iface_mtu_after_event(iface: &Option<String>) -> Option<u16> {
536        read_iface_mtu_linux(iface.as_deref())
537    }
538
539    /// Spawn the watcher thread: block on the OS notification socket and, on
540    /// each delivered change, record one path event and refresh the MTU. On a
541    /// receive timeout it checks the stop flag and loops. Returns `None` if the
542    /// socket could not be opened (the observer then simply never fires).
543    fn spawn(
544        state: Arc<NetEventState>,
545        stop: Arc<AtomicBool>,
546        iface: Option<String>,
547    ) -> Option<JoinHandle<()>> {
548        // SAFETY: open_event_socket returns an owned fd; the thread closes it.
549        let fd = unsafe { open_event_socket() }?;
550        std::thread::Builder::new()
551            .name("net-events".into())
552            .spawn(move || {
553                let mut buf = vec![0u8; 8192];
554                loop {
555                    if stop.load(Ordering::Relaxed) {
556                        break;
557                    }
558                    // SAFETY: `buf` is a valid, owned 8192-byte buffer; `fd` is
559                    // the socket opened above and not closed until after the
560                    // loop.
561                    let n = unsafe {
562                        libc::recv(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len(), 0)
563                    };
564                    if n > 0 {
565                        // Any message on these groups is a path change. We do
566                        // not parse it: the controller wants "the path moved",
567                        // not which route. Record one event per delivered batch
568                        // and refresh the MTU (a decrease is implicit in the
569                        // event already counted).
570                        state.record_event();
571                        if let Some(mtu) = read_iface_mtu_after_event(&iface) {
572                            state.set_pmtu(mtu);
573                        }
574                    }
575                    // n <= 0 is a timeout (SO_RCVTIMEO) or a transient error;
576                    // either way, loop back and re-check the stop flag.
577                }
578                // SAFETY: `fd` was opened above and is closed exactly once here,
579                // after the receive loop has finished using it.
580                unsafe {
581                    libc::close(fd);
582                }
583            })
584            .ok()
585    }
586}
587
588#[cfg(target_os = "windows")]
589mod windows_watch {
590    use super::NetEventState;
591    use std::ffi::c_void;
592    use std::ptr;
593    use std::sync::Arc;
594    use windows_sys::Win32::Foundation::{BOOLEAN, HANDLE};
595    use windows_sys::Win32::NetworkManagement::IpHelper::{
596        CancelMibChangeNotify2, FreeMibTable, GetIfTable2, NotifyIpInterfaceChange,
597        NotifyRouteChange2, MIB_IF_TABLE2, MIB_IPFORWARD_ROW2, MIB_IPINTERFACE_ROW,
598        MIB_NOTIFICATION_TYPE,
599    };
600    use windows_sys::Win32::Networking::WinSock::AF_UNSPEC;
601
602    /// `IF_OPER_STATUS` value for an interface that is up.
603    const IF_OPER_STATUS_UP: i32 = 1;
604    /// `IFTYPE` value for a software loopback interface (skipped).
605    const IF_TYPE_SOFTWARE_LOOPBACK: u32 = 24;
606
607    /// Read the MTU of the busiest up, non-loopback adapter via `GetIfTable2`.
608    pub fn read_iface_mtu_win() -> Option<u16> {
609        // SAFETY: GetIfTable2 allocates the table; every row is read within
610        // `NumEntries`, the table is freed exactly once, and no pointer
611        // outlives the call.
612        unsafe {
613            let mut table: *mut MIB_IF_TABLE2 = ptr::null_mut();
614            if GetIfTable2(&mut table) != 0 || table.is_null() {
615                return None;
616            }
617            let n = (*table).NumEntries as usize;
618            let rows = &raw const (*table).Table[0];
619            let (mut best_pkts, mut best_mtu, mut found) = (0u64, 0u32, false);
620            for i in 0..n {
621                let row = &*rows.add(i);
622                if row.OperStatus != IF_OPER_STATUS_UP || row.Type == IF_TYPE_SOFTWARE_LOOPBACK {
623                    continue;
624                }
625                let pkts = row.InUcastPkts.saturating_add(row.OutUcastPkts);
626                if !found || pkts > best_pkts {
627                    best_pkts = pkts;
628                    best_mtu = row.Mtu;
629                    found = true;
630                }
631            }
632            FreeMibTable(table as *const c_void);
633            (found && best_mtu != 0).then_some(best_mtu.min(u16::MAX as u32) as u16)
634        }
635    }
636
637    /// Handle a route or interface change: record one path event and refresh
638    /// the MTU. `ctx` is the `Arc<NetEventState>` pointer handed to the OS at
639    /// registration; the observer keeps that `Arc` alive and cancels the
640    /// callbacks before dropping it, so the pointer is valid for every call.
641    /// Does only atomic stores and a `GetIfTable2` read, so it cannot unwind
642    /// across the FFI boundary.
643    ///
644    /// # Safety
645    /// `ctx` must be the live `*const NetEventState` passed to the notify call.
646    unsafe fn on_change(ctx: *const c_void) {
647        if ctx.is_null() {
648            return;
649        }
650        // SAFETY: the observer holds the Arc and cancels notifications before
651        // releasing it, so the state outlives every callback.
652        let state = unsafe { &*(ctx as *const NetEventState) };
653        state.record_event();
654        if let Some(mtu) = read_iface_mtu_win() {
655            state.set_pmtu(mtu);
656        }
657    }
658
659    /// `NotifyRouteChange2` callback: a route-table entry changed.
660    ///
661    /// # Safety
662    /// Invoked by the OS with the context registered below.
663    unsafe extern "system" fn route_cb(
664        ctx: *const c_void,
665        _row: *const MIB_IPFORWARD_ROW2,
666        _ty: MIB_NOTIFICATION_TYPE,
667    ) {
668        // SAFETY: `ctx` is the registered NetEventState pointer.
669        unsafe { on_change(ctx) }
670    }
671
672    /// `NotifyIpInterfaceChange` callback: an interface property (carrier,
673    /// MTU) changed.
674    ///
675    /// # Safety
676    /// Invoked by the OS with the context registered below.
677    unsafe extern "system" fn iface_cb(
678        ctx: *const c_void,
679        _row: *const MIB_IPINTERFACE_ROW,
680        _ty: MIB_NOTIFICATION_TYPE,
681    ) {
682        // SAFETY: `ctx` is the registered NetEventState pointer.
683        unsafe { on_change(ctx) }
684    }
685
686    /// The watcher handle: keeps the `Arc` alive for the callbacks and, on
687    /// `Drop`, cancels both notifications before the `Arc` is released so no
688    /// callback can fire against freed state.
689    pub struct Watcher {
690        _state: Arc<NetEventState>,
691        route_handle: HANDLE,
692        iface_handle: HANDLE,
693    }
694
695    // The handles are opaque OS tokens used only to cancel; sending the watcher
696    // across threads is sound because the callbacks reference the Arc'd state,
697    // not the handle.
698    unsafe impl Send for Watcher {}
699    unsafe impl Sync for Watcher {}
700
701    impl Watcher {
702        pub fn start(state: Arc<NetEventState>) -> (Self, &'static str) {
703            // The context is the stable heap address of the shared state; the
704            // Arc kept in `_state` below keeps it alive, and Drop cancels the
705            // callbacks before that Arc is released.
706            let ctx = Arc::as_ptr(&state) as *const c_void;
707            let mut route_handle: HANDLE = ptr::null_mut();
708            let mut iface_handle: HANDLE = ptr::null_mut();
709            // SAFETY: valid callback pointers and a stable context; the output
710            // handles are owned by this Watcher and cancelled in Drop. The
711            // `FALSE` initial-notification flag means no callback fires before
712            // a real change.
713            unsafe {
714                NotifyRouteChange2(
715                    AF_UNSPEC,
716                    Some(route_cb),
717                    ctx,
718                    0 as BOOLEAN,
719                    &mut route_handle,
720                );
721                NotifyIpInterfaceChange(
722                    AF_UNSPEC,
723                    Some(iface_cb),
724                    ctx,
725                    0 as BOOLEAN,
726                    &mut iface_handle,
727                );
728            }
729            (
730                Self {
731                    _state: state,
732                    route_handle,
733                    iface_handle,
734                },
735                "windows-notify",
736            )
737        }
738    }
739
740    impl Drop for Watcher {
741        fn drop(&mut self) {
742            // Cancel both notifications FIRST, so no in-flight callback can run
743            // after the Arc'd state is released. CancelMibChangeNotify2 blocks
744            // until any running callback returns. `_state` then drops once the
745            // callbacks are guaranteed quiesced.
746            // SAFETY: each handle was produced by the matching notify call.
747            unsafe {
748                if !self.route_handle.is_null() {
749                    CancelMibChangeNotify2(self.route_handle);
750                }
751                if !self.iface_handle.is_null() {
752                    CancelMibChangeNotify2(self.iface_handle);
753                }
754            }
755        }
756    }
757}
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762
763    #[test]
764    fn decay_halves_each_half_life() {
765        assert!((decayed_shift(0.0) - 1.0).abs() < 1e-6, "spike at the event");
766        assert!(
767            (decayed_shift(SHIFT_HALF_LIFE_SECS) - 0.5).abs() < 1e-6,
768            "halves after one half-life"
769        );
770        assert!(
771            decayed_shift(3.0 * SHIFT_HALF_LIFE_SECS) < 0.15,
772            "well decayed after three half-lives"
773        );
774    }
775
776    #[test]
777    fn fresh_observer_reports_no_shift() {
778        let st = NetEventState::new();
779        assert_eq!(st.event_count(), 0);
780        assert_eq!(st.path_shift(), 0.0, "no event -> no shift");
781        assert_eq!(st.pmtu(), None);
782    }
783
784    #[test]
785    fn an_event_spikes_the_shift() {
786        let st = NetEventState::new();
787        st.record_event();
788        assert_eq!(st.event_count(), 1);
789        assert!(st.path_shift() > 0.9, "shift spikes right after an event");
790    }
791
792    #[test]
793    fn a_pmtu_drop_is_a_path_event() {
794        let st = NetEventState::new();
795        // First reading: just establishes the baseline, not an event.
796        st.note_pmtu(1500);
797        assert_eq!(st.event_count(), 0, "first MTU reading is not an event");
798        assert_eq!(st.pmtu(), Some(1500));
799        // A drop is a path event.
800        st.note_pmtu(1280);
801        assert_eq!(st.event_count(), 1, "an MTU drop records an event");
802        assert_eq!(st.pmtu(), Some(1280));
803        assert!(st.path_shift() > 0.9, "the drop spikes the shift");
804        // A rise (back to a higher MTU) is not a fresh path-degradation event.
805        st.note_pmtu(1500);
806        assert_eq!(st.event_count(), 1, "an MTU rise is not a new drop event");
807        assert_eq!(st.pmtu(), Some(1500));
808    }
809
810    #[test]
811    fn set_pmtu_never_records_an_event() {
812        // The OS watcher path: the kernel message already counted the event, so
813        // refreshing the MTU value must not double-count.
814        let st = NetEventState::new();
815        st.set_pmtu(1500);
816        st.set_pmtu(1280);
817        st.set_pmtu(1500);
818        assert_eq!(st.event_count(), 0, "set_pmtu is value-only");
819        assert_eq!(st.pmtu(), Some(1500), "last value wins, no event");
820    }
821
822    #[test]
823    fn observer_starts_and_stops_without_panicking() {
824        // Whatever backend this platform builds, starting and dropping the
825        // observer must be safe, and an injected event must register.
826        let obs = NetEventObserver::start(None);
827        assert!(!obs.backend().is_empty());
828        assert_eq!(obs.event_count(), 0);
829        obs.inject_event();
830        assert_eq!(obs.event_count(), 1);
831        assert!(obs.path_shift() > 0.9);
832        // Dropping joins the watcher thread / cancels the callbacks cleanly.
833        drop(obs);
834    }
835}