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}