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(not(any(target_os = "linux", target_os = "windows")))]
250    {
251        let _iface = iface; // No MTU source on this platform.
252        None
253    }
254}
255
256#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))]
257mod unix_watch {
258    use super::NetEventState;
259    use std::mem::size_of;
260    use std::sync::atomic::{AtomicBool, Ordering};
261    use std::sync::Arc;
262    use std::thread::JoinHandle;
263
264    /// The first non-loopback interface whose `operstate` is `up` (Linux). The
265    /// egress MTU is read from this interface unless the caller named one.
266    #[cfg(target_os = "linux")]
267    fn detect_iface() -> Option<String> {
268        let entries = std::fs::read_dir("/sys/class/net").ok()?;
269        for e in entries.flatten() {
270            let name = e.file_name().to_string_lossy().into_owned();
271            if name == "lo" {
272                continue;
273            }
274            let up = std::fs::read_to_string(e.path().join("operstate"))
275                .map(|s| s.trim() == "up")
276                .unwrap_or(false);
277            if up {
278                return Some(name);
279            }
280        }
281        None
282    }
283
284    /// Read `/sys/class/net/<iface>/mtu` (Linux), auto-detecting the interface
285    /// when none is named.
286    #[cfg(target_os = "linux")]
287    pub fn read_iface_mtu_linux(iface: Option<&str>) -> Option<u16> {
288        let name = iface.map(str::to_owned).or_else(detect_iface)?;
289        let p = format!("/sys/class/net/{name}/mtu");
290        std::fs::read_to_string(p).ok()?.trim().parse().ok()
291    }
292
293    /// The watcher handle: its `Drop` signals the thread to stop and joins it,
294    /// so the netlink / route socket is closed and no thread leaks.
295    pub struct Watcher {
296        stop: Arc<AtomicBool>,
297        join: Option<JoinHandle<()>>,
298    }
299
300    impl Watcher {
301        pub fn start(state: Arc<NetEventState>, iface: Option<String>) -> (Self, &'static str) {
302            let stop = Arc::new(AtomicBool::new(false));
303            let join = spawn(Arc::clone(&state), Arc::clone(&stop), iface);
304            (Self { stop, join }, BACKEND)
305        }
306    }
307
308    impl Drop for Watcher {
309        fn drop(&mut self) {
310            self.stop.store(true, Ordering::Relaxed);
311            if let Some(j) = self.join.take() {
312                // The thread polls the stop flag on a sub-second receive
313                // timeout, so the join completes within one timeout window.
314                j.join().ok();
315            }
316        }
317    }
318
319    #[cfg(target_os = "linux")]
320    const BACKEND: &str = "linux-netlink";
321    #[cfg(any(target_os = "freebsd", target_os = "macos"))]
322    const BACKEND: &str = "bsd-pf-route";
323
324    /// 250 ms receive timeout: long enough that the blocking `recv` spends
325    /// almost all its time parked, short enough that a stop request is honored
326    /// promptly.
327    const RECV_TIMEOUT_US: i64 = 250_000;
328
329    /// Set `SO_RCVTIMEO` so the blocking `recv` wakes periodically to check the
330    /// stop flag instead of blocking forever.
331    ///
332    /// # Safety
333    /// `fd` must be a valid socket file descriptor.
334    unsafe fn set_recv_timeout(fd: i32) {
335        let tv = libc::timeval {
336            tv_sec: 0,
337            tv_usec: RECV_TIMEOUT_US as libc::suseconds_t,
338        };
339        // SAFETY: `tv` is a valid timeval that outlives the call; `fd` is a
340        // valid socket.
341        unsafe {
342            libc::setsockopt(
343                fd,
344                libc::SOL_SOCKET,
345                libc::SO_RCVTIMEO,
346                &tv as *const libc::timeval as *const libc::c_void,
347                size_of::<libc::timeval>() as libc::socklen_t,
348            );
349        }
350    }
351
352    /// Open the OS path-notification socket: a bound `NETLINK_ROUTE` socket on
353    /// Linux. `None` on any error.
354    ///
355    /// # Safety
356    /// The returned fd is owned by the caller, which must close it.
357    #[cfg(target_os = "linux")]
358    unsafe fn open_event_socket() -> Option<i32> {
359        // Subscribe to link (carrier), address, and route changes for IPv4 and
360        // IPv6: every path event the controller cares about flows through one
361        // of these multicast groups.
362        const RTMGRP_LINK: u32 = 1;
363        const RTMGRP_IPV4_IFADDR: u32 = 0x10;
364        const RTMGRP_IPV4_ROUTE: u32 = 0x40;
365        const RTMGRP_IPV6_IFADDR: u32 = 0x100;
366        const RTMGRP_IPV6_ROUTE: u32 = 0x400;
367        // SAFETY: a zeroed sockaddr_nl is a valid bind address; the socket is
368        // closed by the caller on every error path.
369        unsafe {
370            let fd = libc::socket(libc::AF_NETLINK, libc::SOCK_RAW, libc::NETLINK_ROUTE);
371            if fd < 0 {
372                return None;
373            }
374            let mut addr: libc::sockaddr_nl = std::mem::zeroed();
375            addr.nl_family = libc::AF_NETLINK as u16;
376            addr.nl_groups = RTMGRP_LINK
377                | RTMGRP_IPV4_IFADDR
378                | RTMGRP_IPV4_ROUTE
379                | RTMGRP_IPV6_IFADDR
380                | RTMGRP_IPV6_ROUTE;
381            if libc::bind(
382                fd,
383                &addr as *const _ as *const libc::sockaddr,
384                size_of::<libc::sockaddr_nl>() as libc::socklen_t,
385            ) < 0
386            {
387                libc::close(fd);
388                return None;
389            }
390            set_recv_timeout(fd);
391            Some(fd)
392        }
393    }
394
395    /// A `PF_ROUTE` raw socket delivers every routing-table change to a
396    /// listener with no explicit subscription (the BSDs / macOS).
397    ///
398    /// # Safety
399    /// The returned fd is owned by the caller, which must close it.
400    #[cfg(any(target_os = "freebsd", target_os = "macos"))]
401    unsafe fn open_event_socket() -> Option<i32> {
402        // SAFETY: a PF_ROUTE raw socket needs no bind; closed by the caller.
403        unsafe {
404            let fd = libc::socket(libc::PF_ROUTE, libc::SOCK_RAW, 0);
405            if fd < 0 {
406                return None;
407            }
408            set_recv_timeout(fd);
409            Some(fd)
410        }
411    }
412
413    /// The BSDs report route / carrier events here; the MTU read is a Linux /
414    /// Windows capability, so `pmtu` stays `None` on these targets.
415    #[cfg(any(target_os = "freebsd", target_os = "macos"))]
416    fn read_iface_mtu_after_event(_iface: &Option<String>) -> Option<u16> {
417        None
418    }
419
420    #[cfg(target_os = "linux")]
421    fn read_iface_mtu_after_event(iface: &Option<String>) -> Option<u16> {
422        read_iface_mtu_linux(iface.as_deref())
423    }
424
425    /// Spawn the watcher thread: block on the OS notification socket and, on
426    /// each delivered change, record one path event and refresh the MTU. On a
427    /// receive timeout it checks the stop flag and loops. Returns `None` if the
428    /// socket could not be opened (the observer then simply never fires).
429    fn spawn(
430        state: Arc<NetEventState>,
431        stop: Arc<AtomicBool>,
432        iface: Option<String>,
433    ) -> Option<JoinHandle<()>> {
434        // SAFETY: open_event_socket returns an owned fd; the thread closes it.
435        let fd = unsafe { open_event_socket() }?;
436        std::thread::Builder::new()
437            .name("net-events".into())
438            .spawn(move || {
439                let mut buf = vec![0u8; 8192];
440                loop {
441                    if stop.load(Ordering::Relaxed) {
442                        break;
443                    }
444                    // SAFETY: `buf` is a valid, owned 8192-byte buffer; `fd` is
445                    // the socket opened above and not closed until after the
446                    // loop.
447                    let n = unsafe {
448                        libc::recv(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len(), 0)
449                    };
450                    if n > 0 {
451                        // Any message on these groups is a path change. We do
452                        // not parse it: the controller wants "the path moved",
453                        // not which route. Record one event per delivered batch
454                        // and refresh the MTU (a decrease is implicit in the
455                        // event already counted).
456                        state.record_event();
457                        if let Some(mtu) = read_iface_mtu_after_event(&iface) {
458                            state.set_pmtu(mtu);
459                        }
460                    }
461                    // n <= 0 is a timeout (SO_RCVTIMEO) or a transient error;
462                    // either way, loop back and re-check the stop flag.
463                }
464                // SAFETY: `fd` was opened above and is closed exactly once here,
465                // after the receive loop has finished using it.
466                unsafe {
467                    libc::close(fd);
468                }
469            })
470            .ok()
471    }
472}
473
474#[cfg(target_os = "windows")]
475mod windows_watch {
476    use super::NetEventState;
477    use std::ffi::c_void;
478    use std::ptr;
479    use std::sync::Arc;
480    use windows_sys::Win32::Foundation::{BOOLEAN, HANDLE};
481    use windows_sys::Win32::NetworkManagement::IpHelper::{
482        CancelMibChangeNotify2, FreeMibTable, GetIfTable2, NotifyIpInterfaceChange,
483        NotifyRouteChange2, MIB_IF_TABLE2, MIB_IPFORWARD_ROW2, MIB_IPINTERFACE_ROW,
484        MIB_NOTIFICATION_TYPE,
485    };
486    use windows_sys::Win32::Networking::WinSock::AF_UNSPEC;
487
488    /// `IF_OPER_STATUS` value for an interface that is up.
489    const IF_OPER_STATUS_UP: i32 = 1;
490    /// `IFTYPE` value for a software loopback interface (skipped).
491    const IF_TYPE_SOFTWARE_LOOPBACK: u32 = 24;
492
493    /// Read the MTU of the busiest up, non-loopback adapter via `GetIfTable2`.
494    pub fn read_iface_mtu_win() -> Option<u16> {
495        // SAFETY: GetIfTable2 allocates the table; every row is read within
496        // `NumEntries`, the table is freed exactly once, and no pointer
497        // outlives the call.
498        unsafe {
499            let mut table: *mut MIB_IF_TABLE2 = ptr::null_mut();
500            if GetIfTable2(&mut table) != 0 || table.is_null() {
501                return None;
502            }
503            let n = (*table).NumEntries as usize;
504            let rows = &raw const (*table).Table[0];
505            let (mut best_pkts, mut best_mtu, mut found) = (0u64, 0u32, false);
506            for i in 0..n {
507                let row = &*rows.add(i);
508                if row.OperStatus != IF_OPER_STATUS_UP || row.Type == IF_TYPE_SOFTWARE_LOOPBACK {
509                    continue;
510                }
511                let pkts = row.InUcastPkts.saturating_add(row.OutUcastPkts);
512                if !found || pkts > best_pkts {
513                    best_pkts = pkts;
514                    best_mtu = row.Mtu;
515                    found = true;
516                }
517            }
518            FreeMibTable(table as *const c_void);
519            (found && best_mtu != 0).then_some(best_mtu.min(u16::MAX as u32) as u16)
520        }
521    }
522
523    /// Handle a route or interface change: record one path event and refresh
524    /// the MTU. `ctx` is the `Arc<NetEventState>` pointer handed to the OS at
525    /// registration; the observer keeps that `Arc` alive and cancels the
526    /// callbacks before dropping it, so the pointer is valid for every call.
527    /// Does only atomic stores and a `GetIfTable2` read, so it cannot unwind
528    /// across the FFI boundary.
529    ///
530    /// # Safety
531    /// `ctx` must be the live `*const NetEventState` passed to the notify call.
532    unsafe fn on_change(ctx: *const c_void) {
533        if ctx.is_null() {
534            return;
535        }
536        // SAFETY: the observer holds the Arc and cancels notifications before
537        // releasing it, so the state outlives every callback.
538        let state = unsafe { &*(ctx as *const NetEventState) };
539        state.record_event();
540        if let Some(mtu) = read_iface_mtu_win() {
541            state.set_pmtu(mtu);
542        }
543    }
544
545    /// `NotifyRouteChange2` callback: a route-table entry changed.
546    ///
547    /// # Safety
548    /// Invoked by the OS with the context registered below.
549    unsafe extern "system" fn route_cb(
550        ctx: *const c_void,
551        _row: *const MIB_IPFORWARD_ROW2,
552        _ty: MIB_NOTIFICATION_TYPE,
553    ) {
554        // SAFETY: `ctx` is the registered NetEventState pointer.
555        unsafe { on_change(ctx) }
556    }
557
558    /// `NotifyIpInterfaceChange` callback: an interface property (carrier,
559    /// MTU) changed.
560    ///
561    /// # Safety
562    /// Invoked by the OS with the context registered below.
563    unsafe extern "system" fn iface_cb(
564        ctx: *const c_void,
565        _row: *const MIB_IPINTERFACE_ROW,
566        _ty: MIB_NOTIFICATION_TYPE,
567    ) {
568        // SAFETY: `ctx` is the registered NetEventState pointer.
569        unsafe { on_change(ctx) }
570    }
571
572    /// The watcher handle: keeps the `Arc` alive for the callbacks and, on
573    /// `Drop`, cancels both notifications before the `Arc` is released so no
574    /// callback can fire against freed state.
575    pub struct Watcher {
576        _state: Arc<NetEventState>,
577        route_handle: HANDLE,
578        iface_handle: HANDLE,
579    }
580
581    // The handles are opaque OS tokens used only to cancel; sending the watcher
582    // across threads is sound because the callbacks reference the Arc'd state,
583    // not the handle.
584    unsafe impl Send for Watcher {}
585    unsafe impl Sync for Watcher {}
586
587    impl Watcher {
588        pub fn start(state: Arc<NetEventState>) -> (Self, &'static str) {
589            // The context is the stable heap address of the shared state; the
590            // Arc kept in `_state` below keeps it alive, and Drop cancels the
591            // callbacks before that Arc is released.
592            let ctx = Arc::as_ptr(&state) as *const c_void;
593            let mut route_handle: HANDLE = ptr::null_mut();
594            let mut iface_handle: HANDLE = ptr::null_mut();
595            // SAFETY: valid callback pointers and a stable context; the output
596            // handles are owned by this Watcher and cancelled in Drop. The
597            // `FALSE` initial-notification flag means no callback fires before
598            // a real change.
599            unsafe {
600                NotifyRouteChange2(
601                    AF_UNSPEC,
602                    Some(route_cb),
603                    ctx,
604                    0 as BOOLEAN,
605                    &mut route_handle,
606                );
607                NotifyIpInterfaceChange(
608                    AF_UNSPEC,
609                    Some(iface_cb),
610                    ctx,
611                    0 as BOOLEAN,
612                    &mut iface_handle,
613                );
614            }
615            (
616                Self {
617                    _state: state,
618                    route_handle,
619                    iface_handle,
620                },
621                "windows-notify",
622            )
623        }
624    }
625
626    impl Drop for Watcher {
627        fn drop(&mut self) {
628            // Cancel both notifications FIRST, so no in-flight callback can run
629            // after the Arc'd state is released. CancelMibChangeNotify2 blocks
630            // until any running callback returns. `_state` then drops once the
631            // callbacks are guaranteed quiesced.
632            // SAFETY: each handle was produced by the matching notify call.
633            unsafe {
634                if !self.route_handle.is_null() {
635                    CancelMibChangeNotify2(self.route_handle);
636                }
637                if !self.iface_handle.is_null() {
638                    CancelMibChangeNotify2(self.iface_handle);
639                }
640            }
641        }
642    }
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648
649    #[test]
650    fn decay_halves_each_half_life() {
651        assert!((decayed_shift(0.0) - 1.0).abs() < 1e-6, "spike at the event");
652        assert!(
653            (decayed_shift(SHIFT_HALF_LIFE_SECS) - 0.5).abs() < 1e-6,
654            "halves after one half-life"
655        );
656        assert!(
657            decayed_shift(3.0 * SHIFT_HALF_LIFE_SECS) < 0.15,
658            "well decayed after three half-lives"
659        );
660    }
661
662    #[test]
663    fn fresh_observer_reports_no_shift() {
664        let st = NetEventState::new();
665        assert_eq!(st.event_count(), 0);
666        assert_eq!(st.path_shift(), 0.0, "no event -> no shift");
667        assert_eq!(st.pmtu(), None);
668    }
669
670    #[test]
671    fn an_event_spikes_the_shift() {
672        let st = NetEventState::new();
673        st.record_event();
674        assert_eq!(st.event_count(), 1);
675        assert!(st.path_shift() > 0.9, "shift spikes right after an event");
676    }
677
678    #[test]
679    fn a_pmtu_drop_is_a_path_event() {
680        let st = NetEventState::new();
681        // First reading: just establishes the baseline, not an event.
682        st.note_pmtu(1500);
683        assert_eq!(st.event_count(), 0, "first MTU reading is not an event");
684        assert_eq!(st.pmtu(), Some(1500));
685        // A drop is a path event.
686        st.note_pmtu(1280);
687        assert_eq!(st.event_count(), 1, "an MTU drop records an event");
688        assert_eq!(st.pmtu(), Some(1280));
689        assert!(st.path_shift() > 0.9, "the drop spikes the shift");
690        // A rise (back to a higher MTU) is not a fresh path-degradation event.
691        st.note_pmtu(1500);
692        assert_eq!(st.event_count(), 1, "an MTU rise is not a new drop event");
693        assert_eq!(st.pmtu(), Some(1500));
694    }
695
696    #[test]
697    fn set_pmtu_never_records_an_event() {
698        // The OS watcher path: the kernel message already counted the event, so
699        // refreshing the MTU value must not double-count.
700        let st = NetEventState::new();
701        st.set_pmtu(1500);
702        st.set_pmtu(1280);
703        st.set_pmtu(1500);
704        assert_eq!(st.event_count(), 0, "set_pmtu is value-only");
705        assert_eq!(st.pmtu(), Some(1500), "last value wins, no event");
706    }
707
708    #[test]
709    fn observer_starts_and_stops_without_panicking() {
710        // Whatever backend this platform builds, starting and dropping the
711        // observer must be safe, and an injected event must register.
712        let obs = NetEventObserver::start(None);
713        assert!(!obs.backend().is_empty());
714        assert_eq!(obs.event_count(), 0);
715        obs.inject_event();
716        assert_eq!(obs.event_count(), 1);
717        assert!(obs.path_shift() > 0.9);
718        // Dropping joins the watcher thread / cancels the callbacks cleanly.
719        drop(obs);
720    }
721}