Skip to content

Commit 79b33b0

Browse files
committed
fix(netwatch): reconcile interface state periodically
The netmon actor only re-enumerated interfaces in response to events from the OS route monitor (or a detected wall-time jump). Those events can be missed or stop entirely with no error: - Routing sockets are best-effort. On receive-buffer overflow the kernel drops the overflowing RTM_* messages, and on macOS (XNU raw_input/sbappendaddr) the drop is silent: so_error is not set and there is no SO_RERROR option, so the reader cannot tell a message was lost. A change can be missed on an otherwise-working socket. - In production we additionally observed the macOS AF_ROUTE read stop delivering events entirely (no further reads, no error) for the rest of the process lifetime. macOS PF_ROUTE is independently documented to be unreliable (it can fail to emit some interface-change events at all), and the socket is read through a tokio UnixStream over a SOCK_RAW fd, so a lost edge-triggered readiness wakeup is also possible; the exact cause was not determined and the fix does not depend on it. With no fallback the actor never recomputes State, so the interface state freezes at whatever it was when the last event was processed. In practice a brief interface change (a DHCP renew or link blip) made an endpoint permanently lose an interface and its addresses even though the interface was up and its address returned immediately; a fresh enumeration reflected the correct state the whole time. This matches iroh#3449. Re-enumerate on the existing periodic wake-up in addition to wall-time jumps, so a missed or absent event self-heals within one interval (15s on desktop). handle_potential_change already diffs against the current state, so this only emits a change when something actually changed. This is the same event-plus-periodic-reconcile pattern Tailscale uses (15s pollWallTimeInterval backstop) and complements the existing error-path socket rebind (net-tools#105) by covering the no-error case. The interface enumeration is made injectable and the poll interval configurable so the recovery path can be tested deterministically without a route monitor. Adds a regression test that drives the actor with no route-monitor events and asserts the state still converges to a changed enumeration.
1 parent 17c6491 commit 79b33b0

1 file changed

Lines changed: 155 additions & 13 deletions

File tree

netwatch/src/netmon/actor.rs

Lines changed: 155 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
use std::sync::Arc;
2+
3+
use n0_future::FutureExt as _;
4+
use n0_future::boxed::BoxFuture;
15
use n0_future::time::{self, Duration, Instant};
26
use n0_watcher::Watchable;
37
pub(super) use os::Error;
@@ -33,27 +37,48 @@ pub(super) enum NetworkMessage {
3337
Change,
3438
}
3539

36-
/// How often we execute a check for big jumps in wall time.
40+
/// How often the actor wakes up to check for wall-time jumps and to
41+
/// re-enumerate interfaces (see [`Actor::run`]).
3742
#[cfg(not(any(target_os = "ios", target_os = "android")))]
38-
const POLL_WALL_TIME_INTERVAL: Duration = Duration::from_secs(15);
43+
const POLL_INTERVAL: Duration = Duration::from_secs(15);
3944
/// Set background polling time to 1h to effectively disable it on mobile,
4045
/// to avoid increased battery usage. Sleep detection won't work this way there.
4146
#[cfg(any(target_os = "ios", target_os = "android"))]
42-
const POLL_WALL_TIME_INTERVAL: Duration = Duration::from_secs(60 * 60);
47+
const POLL_INTERVAL: Duration = Duration::from_secs(60 * 60);
4348
const MON_CHAN_CAPACITY: usize = 16;
4449
const ACTOR_CHAN_CAPACITY: usize = 16;
4550

51+
/// Produces the current [`State`] of the host's network interfaces.
52+
///
53+
/// Boxed so the enumeration can be substituted in tests; in production it is
54+
/// always [`State::new`].
55+
type StateFn = Arc<dyn Fn() -> BoxFuture<State> + Send + Sync + 'static>;
56+
57+
fn default_state_fn() -> StateFn {
58+
Arc::new(|| State::new().boxed())
59+
}
60+
4661
pub(super) struct Actor {
4762
/// Latest known interface state.
4863
interface_state: Watchable<State>,
4964
/// Latest observed wall time.
5065
wall_time: Instant,
5166
/// OS specific monitor.
67+
///
68+
/// `None` only in tests, where the OS route monitor is intentionally
69+
/// absent so that recovery via the periodic reconcile can be exercised in
70+
/// isolation. Held purely to keep the monitor task alive.
5271
#[allow(dead_code)]
53-
route_monitor: RouteMonitor,
72+
route_monitor: Option<RouteMonitor>,
5473
mon_receiver: mpsc::Receiver<NetworkMessage>,
5574
actor_receiver: mpsc::Receiver<ActorMessage>,
5675
actor_sender: mpsc::Sender<ActorMessage>,
76+
/// How the actor enumerates interfaces. Always [`State::new`] in
77+
/// production; overridable in tests.
78+
get_state: StateFn,
79+
/// Interval at which the actor re-enumerates interfaces and checks wall
80+
/// time. Defaults to [`POLL_INTERVAL`].
81+
poll_interval: Duration,
5782
}
5883

5984
pub(super) enum ActorMessage {
@@ -62,7 +87,8 @@ pub(super) enum ActorMessage {
6287

6388
impl Actor {
6489
pub(super) async fn new() -> Result<Self, os::Error> {
65-
let interface_state = State::new().await;
90+
let get_state = default_state_fn();
91+
let interface_state = (get_state)().await;
6692
let wall_time = Instant::now();
6793

6894
let (mon_sender, mon_receiver) = mpsc::channel(MON_CHAN_CAPACITY);
@@ -72,10 +98,12 @@ impl Actor {
7298
Ok(Actor {
7399
interface_state: Watchable::new(interface_state),
74100
wall_time,
75-
route_monitor,
101+
route_monitor: Some(route_monitor),
76102
mon_receiver,
77103
actor_receiver,
78104
actor_sender,
105+
get_state,
106+
poll_interval: POLL_INTERVAL,
79107
})
80108
}
81109

@@ -94,7 +122,10 @@ impl Actor {
94122
let mut pending_time_jump = false;
95123
let debounce = time::sleep(DEBOUNCE);
96124
tokio::pin!(debounce);
97-
let mut wall_time_interval = time::interval(POLL_WALL_TIME_INTERVAL);
125+
let mut poll_interval = time::interval(self.poll_interval);
126+
// The first tick fires immediately; skip it so startup does not do a
127+
// redundant reconcile right after the initial enumeration.
128+
poll_interval.tick().await;
98129

99130
loop {
100131
tokio::select! {
@@ -103,12 +134,27 @@ impl Actor {
103134
pending_change = false;
104135
pending_time_jump = false;
105136
}
106-
_ = wall_time_interval.tick() => {
107-
trace!("tick: wall_time_interval");
137+
_ = poll_interval.tick() => {
138+
trace!("tick: poll_interval");
108139
if self.check_wall_time_advance() {
109140
pending_time_jump = true;
110-
debounce.as_mut().reset(Instant::now() + DEBOUNCE);
111141
}
142+
// Re-enumerate interfaces on every tick, not just on
143+
// wall-time jumps. The OS route monitors are best-effort
144+
// and an event can be missed without any error: a routing
145+
// socket drops messages when its receive buffer overflows,
146+
// and on macOS (XNU `raw_input`) that drop is silent (no
147+
// `so_error`, no `SO_RERROR`), so a change can be missed on
148+
// an otherwise-working socket. We have also observed the
149+
// macOS AF_ROUTE read simply stop delivering events (no
150+
// further reads, no error), after which the actor has
151+
// nothing to react to. A missed or absent event must not
152+
// freeze the interface state forever, so reconcile
153+
// periodically. `handle_potential_change` diffs against the
154+
// current state, so this is a no-op (beyond the enumeration
155+
// itself) whenever nothing actually changed.
156+
pending_change = true;
157+
debounce.as_mut().reset(Instant::now() + DEBOUNCE);
112158
}
113159
event = self.mon_receiver.recv() => {
114160
match event {
@@ -143,7 +189,7 @@ impl Actor {
143189
async fn handle_potential_change(&mut self, time_jumped: bool) {
144190
trace!("potential change");
145191

146-
let mut new_state = State::new().await;
192+
let mut new_state = (self.get_state)().await;
147193
let old_state = &self.interface_state.get();
148194

149195
if time_jumped {
@@ -158,11 +204,11 @@ impl Actor {
158204
}
159205

160206
/// Reports whether wall time jumped more than 150%
161-
/// of `POLL_WALL_TIME_INTERVAL`, indicating we probably just came out of sleep.
207+
/// of [`Self::poll_interval`], indicating we probably just came out of sleep.
162208
fn check_wall_time_advance(&mut self) -> bool {
163209
let now = Instant::now();
164210
let jumped = if let Some(elapsed) = now.checked_duration_since(self.wall_time) {
165-
elapsed > POLL_WALL_TIME_INTERVAL * 3 / 2
211+
elapsed > self.poll_interval * 3 / 2
166212
} else {
167213
false
168214
};
@@ -171,3 +217,99 @@ impl Actor {
171217
jumped
172218
}
173219
}
220+
221+
#[cfg(test)]
222+
mod tests {
223+
use std::sync::Mutex;
224+
225+
use n0_watcher::Watcher as _;
226+
227+
use super::*;
228+
229+
/// Builds two distinct interface states so a transition is observable.
230+
fn state_pair() -> (State, State) {
231+
let with_iface = State::fake();
232+
let mut without_iface = with_iface.clone();
233+
without_iface.interfaces.clear();
234+
assert_ne!(with_iface, without_iface);
235+
(with_iface, without_iface)
236+
}
237+
238+
/// The actor must recover the correct interface state purely from its
239+
/// periodic reconcile, with no route-monitor events at all.
240+
///
241+
/// This reproduces the production failure where the OS route monitor stops
242+
/// delivering events (on macOS the raw AF_ROUTE socket can lose its read
243+
/// readiness after a burst of messages): the interface state would
244+
/// otherwise stay frozen forever. Before the periodic reconcile was added
245+
/// this test hangs until the timeout, because nothing ever re-enumerates.
246+
#[tokio::test]
247+
async fn recovers_state_without_route_events() {
248+
// The enumeration reports the "before" state once (the initial state),
249+
// then the "after" state on every subsequent call, modelling an
250+
// interface change that the route monitor never signalled.
251+
let (before, after) = state_pair();
252+
let calls = Arc::new(Mutex::new(0usize));
253+
let states = Arc::new((before.clone(), after.clone()));
254+
let get_state: StateFn = {
255+
let calls = calls.clone();
256+
let states = states.clone();
257+
Arc::new(move || {
258+
let n = {
259+
let mut g = calls.lock().unwrap();
260+
let n = *g;
261+
*g += 1;
262+
n
263+
};
264+
let states = states.clone();
265+
async move {
266+
if n == 0 {
267+
states.0.clone()
268+
} else {
269+
states.1.clone()
270+
}
271+
}
272+
.boxed()
273+
})
274+
};
275+
276+
// No route monitor: the only path that can update state is the
277+
// periodic reconcile. Keep `mon_sender` alive so the receiver does not
278+
// report all senders gone (which would shut the actor down).
279+
let initial = (get_state)().await;
280+
assert_eq!(initial, before);
281+
let (mon_sender, mon_receiver) = mpsc::channel(MON_CHAN_CAPACITY);
282+
let (actor_sender, actor_receiver) = mpsc::channel(ACTOR_CHAN_CAPACITY);
283+
let interface_state = Watchable::new(initial);
284+
let mut watch = interface_state.watch();
285+
286+
let actor = Actor {
287+
interface_state,
288+
wall_time: Instant::now(),
289+
route_monitor: None,
290+
mon_receiver,
291+
actor_receiver,
292+
actor_sender,
293+
get_state,
294+
// Short interval so the test does not wait the production 15s,
295+
// but above the 250ms debounce so each tick settles into a reconcile.
296+
poll_interval: Duration::from_millis(400),
297+
};
298+
let handle = tokio::spawn(actor.run());
299+
300+
let updated = tokio::time::timeout(Duration::from_secs(5), async {
301+
loop {
302+
let state = watch.updated().await.expect("watcher closed");
303+
if state == after {
304+
return state;
305+
}
306+
}
307+
})
308+
.await
309+
.expect("interface state was not reconciled without route events");
310+
assert_eq!(updated, after);
311+
312+
drop(mon_sender);
313+
handle.abort();
314+
}
315+
}

0 commit comments

Comments
 (0)