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}