ts_runtime/ipn_bus.rs
1//! Unified IPN notification bus: a single push-style stream that coalesces the device's
2//! connection-[`DeviceState`] and netmap peer-set changes into one [`Notify`] feed, mirroring Go
3//! `ipn` `LocalBackend.WatchNotifications` / the `WatchIPNBus` LocalAPI.
4//!
5//! Go delivers one `ipn.Notify` struct per event in which **only the changed fields are populated**
6//! (a nil field means "unchanged"); an optional subscribe-time mask ([`NotifyWatchOpt`]) front-loads
7//! an initial snapshot of the current state. [`Notify`] is the faithful Rust shape of that struct —
8//! a struct of `Option`s, not a per-event enum.
9//!
10//! # Coalescing: initial snapshot vs. streamed events
11//!
12//! The struct-of-`Option`s shape lets one `Notify` carry several changed fields at once. This bus
13//! exploits that **for the initial snapshot only**: the subscribe-time snapshot reads every source
14//! cell synchronously and packs the masked fields into one `Notify`. Post-subscribe, the merge loop
15//! is per-source — each source cell's change produces its own single-field `Notify` (a state change
16//! yields `state: Some`, a peer change yields `net_map: Some`), because the cells are independent
17//! `watch` channels with no cross-cell synchronization point to coalesce on. A consumer therefore
18//! sees at most one coalesced snapshot followed by single-field deltas. (Go can pack several fields
19//! into one streamed `Notify` because a single `MapResponse` updates several things together under
20//! one lock; the fork has already split those into separate cells, so the equivalent streamed events
21//! arrive separately here. The `Option` shape is still the right type — it keeps the snapshot
22//! faithful and leaves room for a future single source to set multiple fields.)
23//!
24//! # Why these sources
25//!
26//! The fork already decomposes Go's single notification channel into separate, individually-correct
27//! `watch` surfaces ([`Runtime::watch_state`](crate::Runtime::watch_state),
28//! [`Runtime::watch_netmap`](crate::Runtime::watch_netmap)). This bus *composes* the same cells (one
29//! source of truth — it cannot diverge from the narrow views) into the merged feed an embedder
30//! porting from Go's `WatchIPNBus` expects. The cells it reads map onto Go `Notify` fields:
31//!
32//! - [`DeviceState`] → `Notify.State`, and the **registration-time** interactive-login URL carried
33//! by [`DeviceState::NeedsLogin`] (`Notify.browse_to_url`, derived from that state — control's
34//! `MachineNotAuthorized`).
35//! - the running-node consent URL (`MapResponse.PopBrowserURL`) → `Notify.browse_to_url` as a
36//! mid-session event. Go also forwards this `BrowseToURL` for an already-`Running` node (re-auth /
37//! forced-re-login nudges). The fork's backing cell is **sticky** (the producer updates it only on
38//! a new non-empty URL, never resets it to `None` on an empty update — Go's `direct.go` guard
39//! `u != "" && u != sess.lastPopBrowserURL`), so a `watch` subscriber is not thrashed. It is
40//! streamed post-subscribe but **not** front-loaded into the initial snapshot — Go replays only the
41//! registration `b.authURL` (the `NeedsLogin`-derived URL above) on a new watcher, never the
42//! running-node `PopBrowserURL`; a consumer wanting the current pending URL at subscribe time reads
43//! the sticky `pop_browser_url` pull API.
44//! - the peer set (`Vec<StatusNode>`) → `Notify.NetMap` (the embedder-facing peer view).
45//! - the control runner's self-node cell → a `Notify.NetMap` tick carrying the current peer set.
46//! Go re-sends the netmap with `Notify.SelfChange` on every netmap update
47//! (`ipn/ipnlocal/local.go` `setControlClientStatusLocked`), so a watcher learns when only this
48//! node moved: a key-expiry extension, a MagicDNS rename, reassigned addresses. Such an update
49//! leaves the peer cell untouched, so without this source it would reach no watcher. The bus
50//! ticks when the self node differs from the last one this watcher saw; a repeat of the same
51//! node sends nothing. A consumer reads the new self node on that tick
52//! ([`Runtime::status`](crate::Runtime::status)).
53//!
54//! Go's `Notify` has no packet-filter cap-grant field (caps are an internal `WhoIs` input, not an
55//! embedder notification), so the retained cap-grants cell is intentionally **not** surfaced here.
56//!
57//! # Lossy by design
58//!
59//! Like Go's bus (a bounded 128-deep channel drained with a non-blocking `select { case ch<-n:
60//! default: drop }`), delivery is best-effort: the per-watcher [`mpsc`](tokio::sync::mpsc) channel
61//! is bounded at [`NOTIFY_BUFFER`](crate::ipn_bus::NOTIFY_BUFFER) and a notification for a watcher
62//! whose buffer is full is **dropped**, never
63//! blocking the producer. The underlying `watch` cells are themselves coalescing, so a slow consumer
64//! observes the latest state, not every intermediate — the right semantics for state/netmap
65//! snapshots (and the reason this bus is not used for any at-least-once delivery).
66
67use tokio::sync::{mpsc, watch};
68
69use crate::{device_state::DeviceState, status::StatusNode};
70
71/// Per-watcher notification buffer depth. Matches Go's `ipn` bus channel size
72/// (`make(chan *ipn.Notify, 128)`): a bounded queue that the producer never blocks on — a full
73/// buffer drops the notification (see module docs).
74pub const NOTIFY_BUFFER: usize = 128;
75
76/// Selects which initial-state fields are front-loaded into the first [`Notify`] when a watcher
77/// subscribes (Go `ipn.NotifyWatchOpt`). A bitfield; combine with `|`.
78///
79/// The numeric values match Go's `NotifyWatchOpt` literals exactly (`NotifyInitialState = 1 << 1`,
80/// `NotifyInitialNetMap = 1 << 3`), so a mask built from Go's integer constants is wire-compatible.
81/// Bits Go defines but this bus does not yet surface (initial prefs/health/etc.) are simply not
82/// honored — passing them is harmless, exactly as an unrecognized bit is in Go.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
84pub struct NotifyWatchOpt(u64);
85
86impl NotifyWatchOpt {
87 /// No initial snapshot: the watcher receives only changes that occur after it subscribes.
88 pub const fn empty() -> Self {
89 Self(0)
90 }
91
92 /// Front-load the current [`DeviceState`] (and, when it is [`DeviceState::NeedsLogin`], the
93 /// auth URL as `browse_to_url`) into the first [`Notify`]. Go `NotifyInitialState` (`1 << 1`).
94 pub const INITIAL_STATE: Self = Self(1 << 1);
95
96 /// Front-load the current peer set (`net_map`) into the first [`Notify`]. Go
97 /// `NotifyInitialNetMap` (`1 << 3`).
98 pub const INITIAL_NETMAP: Self = Self(1 << 3);
99
100 /// Whether all bits in `other` are set in `self`.
101 pub const fn contains(self, other: Self) -> bool {
102 self.0 & other.0 == other.0
103 }
104}
105
106impl core::ops::BitOr for NotifyWatchOpt {
107 type Output = Self;
108 fn bitor(self, rhs: Self) -> Self {
109 Self(self.0 | rhs.0)
110 }
111}
112
113/// A single notification from the [IPN bus](self), mirroring Go `ipn.Notify`: each field is `Some`
114/// only when it changed in this event (a `None` field means "unchanged"). One event may populate
115/// several fields at once (e.g. a netmap update that also moves the device state).
116///
117/// `#[non_exhaustive]` so future Go-parity fields (prefs, engine status, health) can be added
118/// without breaking embedders that match on it.
119#[derive(Debug, Clone, Default, PartialEq, Eq)]
120#[non_exhaustive]
121pub struct Notify {
122 /// The new device connection-state, if it changed (Go `Notify.State`).
123 pub state: Option<DeviceState>,
124 /// The current peer set, when the netmap changed (Go `Notify.NetMap`, embedder-facing peer
125 /// view). Set when the peer set moves and also when only this node's own entry moved (Go
126 /// `Notify.SelfChange`); in the second case the peers are the unchanged current set.
127 pub net_map: Option<Vec<StatusNode>>,
128 /// An interactive-login / consent URL the embedder should open (Go `Notify.BrowseToURL`). Two
129 /// sources feed it: the **registration-time** auth URL, derived from [`DeviceState::NeedsLogin`]
130 /// and set alongside `state` when the device enters that state; and the **mid-session**
131 /// `MapResponse.PopBrowserURL` (re-auth / consent on an already-running node), streamed on its own
132 /// as a standalone event. See the module docs for which is front-loaded into the initial snapshot
133 /// (only the registration URL) vs. streamed (both).
134 pub browse_to_url: Option<url::Url>,
135}
136
137impl Notify {
138 /// Whether this notification carries no populated field. An all-`None` `Notify` is never
139 /// delivered (the bus skips it), so observing one from [`IpnBusWatcher::next`] is impossible;
140 /// the predicate exists for the bus's own "is there anything to send?" check.
141 fn is_empty(&self) -> bool {
142 self.state.is_none() && self.net_map.is_none() && self.browse_to_url.is_none()
143 }
144}
145
146/// A handle to a live [IPN bus](self) subscription, mirroring Go's `IPNBusWatcher`. Await
147/// [`next`](Self::next) to receive [`Notify`] events; it returns `None` when the stream ends (the
148/// runtime shut down, or this watcher was dropped).
149#[derive(Debug)]
150pub struct IpnBusWatcher {
151 rx: mpsc::Receiver<Notify>,
152}
153
154impl IpnBusWatcher {
155 /// Await the next [`Notify`]. Returns `None` once the bus has terminated (runtime shutdown or
156 /// every source cell's sender dropped) — the clean end-of-stream signal, like Go's watcher
157 /// channel closing.
158 pub async fn next(&mut self) -> Option<Notify> {
159 self.rx.recv().await
160 }
161}
162
163/// Spawn the bus task feeding `tx` and return the consumer handle. Reads cloned `watch` receivers
164/// (so it never contends with the runtime's own readers) and a `shutdown` receiver that terminates
165/// the task. The task self-terminates on shutdown, on any source sender dropping, or when the
166/// returned [`IpnBusWatcher`] is dropped (the `tx` send then reports the channel closed) — so it
167/// cannot leak past the runtime or a discarded watcher.
168pub(crate) fn spawn_watcher(
169 mask: NotifyWatchOpt,
170 state_rx: watch::Receiver<DeviceState>,
171 peer_rx: watch::Receiver<Vec<StatusNode>>,
172 browser_rx: watch::Receiver<Option<url::Url>>,
173 self_rx: watch::Receiver<Option<ts_control::Node>>,
174 shutdown_rx: watch::Receiver<bool>,
175) -> IpnBusWatcher {
176 let (tx, rx) = mpsc::channel(NOTIFY_BUFFER);
177 tokio::spawn(run_bus(
178 mask,
179 state_rx,
180 peer_rx,
181 browser_rx,
182 self_rx,
183 shutdown_rx,
184 tx,
185 ));
186 IpnBusWatcher { rx }
187}
188
189/// Try to deliver `n`, returning `true` when the bus should stop (the consumer is gone).
190///
191/// Mirrors Go's non-blocking `select { case ch <- n: default: /* drop */ }`: a `Full` buffer drops
192/// the notification and keeps streaming (best-effort delivery, never block the producer); a `Closed`
193/// channel means the watcher was dropped, so the task is done.
194fn deliver(tx: &mpsc::Sender<Notify>, n: Notify) -> bool {
195 match tx.try_send(n) {
196 Ok(()) => false,
197 Err(mpsc::error::TrySendError::Full(_)) => false,
198 Err(mpsc::error::TrySendError::Closed(_)) => true,
199 }
200}
201
202/// The interactive-login URL implied by a device state: `Some` only for [`DeviceState::NeedsLogin`].
203/// The single derivation rule for `browse_to_url`, shared by the initial snapshot and the streaming
204/// state arm so the two can never drift (see module docs on the registration-time URL).
205fn browse_url_for(state: &DeviceState) -> Option<url::Url> {
206 match state {
207 DeviceState::NeedsLogin(u) => Some(u.clone()),
208 _ => None,
209 }
210}
211
212/// Build the `Notify` for a device-state transition: the state plus its derived `browse_to_url`.
213fn state_notify(state: DeviceState) -> Notify {
214 let browse_to_url = browse_url_for(&state);
215 Notify {
216 state: Some(state),
217 net_map: None,
218 browse_to_url,
219 }
220}
221
222/// The bus loop, factored out of [`spawn_watcher`] so the (subtle) ordering — the masked initial
223/// snapshot, the `borrow_and_update` that prevents an initial-value busy-loop, the shutdown arm, and
224/// sender-drop termination — is unit-testable against plain `watch`/`mpsc` channels without standing
225/// up a runtime (mirrors [`device_state::wait_for_running`](crate::device_state::wait_for_running)).
226pub(crate) async fn run_bus(
227 mask: NotifyWatchOpt,
228 mut state_rx: watch::Receiver<DeviceState>,
229 mut peer_rx: watch::Receiver<Vec<StatusNode>>,
230 mut browser_rx: watch::Receiver<Option<url::Url>>,
231 mut self_rx: watch::Receiver<Option<ts_control::Node>>,
232 mut shutdown_rx: watch::Receiver<bool>,
233 tx: mpsc::Sender<Notify>,
234) {
235 // If the runtime is already shutting down, end before doing anything. This also marks the
236 // shutdown cell's initial `false` as *seen* so the `select!` arm below doesn't fire spuriously
237 // on the unobserved initial value (the classic `watch`-in-`select!` busy-loop).
238 if *shutdown_rx.borrow_and_update() {
239 return;
240 }
241
242 // Initial snapshot: ONE coalesced `Notify` carrying whichever masked fields are requested
243 // (Go front-loads State+NetMap into a single `ini` struct). `borrow_and_update` reads the
244 // current value AND marks it seen, so the streaming loop's first `changed()` waits for a real
245 // transition instead of re-emitting the value we just snapshotted.
246 let mut initial = Notify::default();
247 {
248 let state = state_rx.borrow_and_update();
249 if mask.contains(NotifyWatchOpt::INITIAL_STATE) {
250 initial.browse_to_url = browse_url_for(&state);
251 initial.state = Some(state.clone());
252 }
253 }
254 {
255 let peers = peer_rx.borrow_and_update();
256 if mask.contains(NotifyWatchOpt::INITIAL_NETMAP) {
257 initial.net_map = Some(peers.clone());
258 }
259 }
260 // Mark the running-node browser-URL cell's initial value seen so the streaming arm waits for a
261 // real post-subscribe change (busy-loop prevention, same as the cells above). Its current value
262 // is deliberately NOT front-loaded into the initial snapshot: Go replays only the
263 // registration-time auth URL (the `NeedsLogin`-derived `browse_to_url` above), never the
264 // running-node `MapResponse.PopBrowserURL`, on a new watcher's initial state. A consumer wanting
265 // the current pending consent URL at subscribe time reads the sticky `pop_browser_url` pull API;
266 // the bus streams future transitions.
267 browser_rx.borrow_and_update();
268 // The self node this watcher has already been told about. Marking the current value seen keeps
269 // the streaming arm from ticking on the value present at subscribe time; comparing against it
270 // keeps a repeated identical self node (control re-sending it unchanged) from ticking at all.
271 let mut last_self = self_rx.borrow_and_update().clone();
272 if !initial.is_empty() && deliver(&tx, initial) {
273 return;
274 }
275
276 // Stream subsequent changes. `biased` makes shutdown take priority over data so a teardown is
277 // observed promptly. Each data arm re-reads with `borrow_and_update().clone()` into an owned
278 // value and drops the borrow guard *before* the next await — never holding a `watch` read guard
279 // across `.changed()` (which would deadlock). A sender-drop (`changed()` => `Err`) ends the
280 // stream, exactly as `wait_for_running` treats it.
281 loop {
282 tokio::select! {
283 biased;
284 _ = shutdown_rx.changed() => return,
285 // The consumer dropped its `IpnBusWatcher`: reclaim the task immediately rather than
286 // waiting for the next source change to surface a `Closed` on the next `deliver`. On an
287 // idle (quiet) device that next change might be far off, so without this arm a dropped
288 // watcher would leave the task parked until shutdown. `Sender::closed()` resolves once
289 // every receiver is gone.
290 _ = tx.closed() => return,
291 changed = state_rx.changed() => {
292 if changed.is_err() {
293 return;
294 }
295 let state = state_rx.borrow_and_update().clone();
296 if deliver(&tx, state_notify(state)) {
297 return;
298 }
299 }
300 changed = peer_rx.changed() => {
301 if changed.is_err() {
302 return;
303 }
304 let peers = peer_rx.borrow_and_update().clone();
305 let notify = Notify {
306 state: None,
307 net_map: Some(peers),
308 browse_to_url: None,
309 };
310 if deliver(&tx, notify) {
311 return;
312 }
313 }
314 changed = self_rx.changed() => {
315 if changed.is_err() {
316 return;
317 }
318 // Go `Notify.SelfChange`: a netmap update that moved only this node (key-expiry
319 // extension, rename, reassigned addresses) leaves the peer cell untouched, so tick
320 // `net_map` with the current peer set. `borrow_and_update` on the peer cell folds a
321 // peer change that is still pending into this frame instead of sending the same set
322 // twice.
323 let moved = {
324 let node = self_rx.borrow_and_update();
325 if *node == last_self {
326 false
327 } else {
328 last_self = node.clone();
329 true
330 }
331 };
332 if moved {
333 let peers = peer_rx.borrow_and_update().clone();
334 let notify = Notify {
335 state: None,
336 net_map: Some(peers),
337 browse_to_url: None,
338 };
339 if deliver(&tx, notify) {
340 return;
341 }
342 }
343 }
344 changed = browser_rx.changed() => {
345 if changed.is_err() {
346 return;
347 }
348 // The running-node consent URL (`MapResponse.PopBrowserURL`). The producer cell is
349 // de-thrashed (updated only on a new non-empty URL, never reset to `None`), so a
350 // change here carries a fresh `Some(url)`; skip the defensive `None` case rather than
351 // emit an empty `browse_to_url`.
352 let url = browser_rx.borrow_and_update().clone();
353 if let Some(url) = url {
354 let notify = Notify {
355 state: None,
356 net_map: None,
357 browse_to_url: Some(url),
358 };
359 if deliver(&tx, notify) {
360 return;
361 }
362 }
363 }
364 }
365 }
366}
367
368#[cfg(test)]
369mod tests {
370 use core::time::Duration;
371
372 use tokio::sync::{mpsc, watch};
373
374 use super::*;
375
376 /// The hand-made channel senders (state, peer, browser-URL, shutdown, self-node) plus the
377 /// consumer handle that [`harness`] returns — the source senders let a test drive `run_bus`,
378 /// and the `IpnBusWatcher` observes what it emits.
379 type Harness = (
380 watch::Sender<DeviceState>,
381 watch::Sender<Vec<StatusNode>>,
382 watch::Sender<Option<url::Url>>,
383 watch::Sender<bool>,
384 watch::Sender<Option<ts_control::Node>>,
385 IpnBusWatcher,
386 );
387
388 /// Drive `run_bus` on a task against hand-made channels, returning the senders (state, peer,
389 /// browser-URL, shutdown, self-node) and the consumer handle. The self-node cell starts empty,
390 /// as the control runner's does before the first netmap. Mirrors how `device_state` tests drive
391 /// `wait_for_running` off a plain `watch`.
392 fn harness(mask: NotifyWatchOpt, state: DeviceState, peers: Vec<StatusNode>) -> Harness {
393 harness_with_self(mask, state, peers, None)
394 }
395
396 /// [`harness`] with the self-node cell already holding `self_node` at subscribe time.
397 fn harness_with_self(
398 mask: NotifyWatchOpt,
399 state: DeviceState,
400 peers: Vec<StatusNode>,
401 self_node: Option<ts_control::Node>,
402 ) -> Harness {
403 let (state_tx, state_rx) = watch::channel(state);
404 let (peer_tx, peer_rx) = watch::channel(peers);
405 let (browser_tx, browser_rx) = watch::channel(None);
406 let (self_tx, self_rx) = watch::channel(self_node);
407 let (shutdown_tx, shutdown_rx) = watch::channel(false);
408 let (tx, rx) = mpsc::channel(NOTIFY_BUFFER);
409 tokio::spawn(run_bus(
410 mask,
411 state_rx,
412 peer_rx,
413 browser_rx,
414 self_rx,
415 shutdown_rx,
416 tx,
417 ));
418 (
419 state_tx,
420 peer_tx,
421 browser_tx,
422 shutdown_tx,
423 self_tx,
424 IpnBusWatcher { rx },
425 )
426 }
427
428 /// How long a test waits for a frame it expects. A missing frame then fails the test instead of
429 /// hanging it.
430 const EXPECT_WITHIN: Duration = Duration::from_secs(5);
431
432 /// The next frame, or a test failure naming `what` if none arrives within [`EXPECT_WITHIN`].
433 async fn next_within(w: &mut IpnBusWatcher, what: &str) -> Notify {
434 tokio::time::timeout(EXPECT_WITHIN, w.next())
435 .await
436 .unwrap_or_else(|_| panic!("timed out waiting for: {what}"))
437 .unwrap_or_else(|| panic!("stream ended before: {what}"))
438 }
439
440 /// A self node as control sends it, named `name`. Two calls with different names differ the way
441 /// a MagicDNS rename does; two with the same name are equal.
442 fn self_node(name: &str) -> ts_control::Node {
443 crate::peer_tracker::tka_tests::peer_node(name, [7; 32], Vec::new())
444 }
445
446 fn login_url() -> url::Url {
447 "https://login.example/auth".parse().unwrap()
448 }
449
450 fn consent_url() -> url::Url {
451 "https://login.example/consent".parse().unwrap()
452 }
453
454 /// A minimal non-empty peer, so a `net_map` payload assertion exercises a real value rather than
455 /// the degenerate empty-vec round-trip.
456 fn peer(id: &str) -> StatusNode {
457 use core::net::{IpAddr, Ipv4Addr, Ipv6Addr};
458 StatusNode {
459 stable_id: ts_control::StableNodeId(id.to_owned()),
460 display_name: id.to_owned(),
461 ipv4: IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1)),
462 ipv6: IpAddr::V6(Ipv6Addr::LOCALHOST),
463 tailscale_ips: vec![
464 IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1)),
465 IpAddr::V6(Ipv6Addr::LOCALHOST),
466 ],
467 online: Some(true),
468 last_seen: None,
469 allowed_routes: Vec::new(),
470 is_exit_node: false,
471 cur_addr: None,
472 relay: None,
473 ssh_host_keys: Vec::new(),
474 expired: false,
475 }
476 }
477
478 /// A negative-assertion window: long enough that a real-but-slow event would still arrive within
479 /// it on a loaded CI box (so "nothing arrived" is trustworthy, not just "nothing arrived *yet*").
480 const QUIET_WINDOW: Duration = Duration::from_millis(250);
481
482 /// `NotifyWatchOpt` is a faithful bitfield: Go's literal values, `contains`, and `|` compose.
483 #[test]
484 fn mask_bitfield_semantics() {
485 assert!(NotifyWatchOpt::empty().contains(NotifyWatchOpt::empty()));
486 assert!(!NotifyWatchOpt::empty().contains(NotifyWatchOpt::INITIAL_STATE));
487 let both = NotifyWatchOpt::INITIAL_STATE | NotifyWatchOpt::INITIAL_NETMAP;
488 assert!(both.contains(NotifyWatchOpt::INITIAL_STATE));
489 assert!(both.contains(NotifyWatchOpt::INITIAL_NETMAP));
490 // Wire-compatible with Go's NotifyWatchOpt integer literals.
491 assert_eq!(NotifyWatchOpt::INITIAL_STATE, NotifyWatchOpt(1 << 1));
492 assert_eq!(NotifyWatchOpt::INITIAL_NETMAP, NotifyWatchOpt(1 << 3));
493 }
494
495 /// `NotifyInitialState` front-loads the current state into the first `Notify` (state only, no
496 /// net_map).
497 #[tokio::test]
498 async fn initial_state_snapshot_emitted_when_masked() {
499 let (_s, _p, _b, _sd, _me, mut w) = harness(
500 NotifyWatchOpt::INITIAL_STATE,
501 DeviceState::Running,
502 Vec::new(),
503 );
504 let n = w.next().await.expect("initial snapshot");
505 assert_eq!(n.state, Some(DeviceState::Running));
506 assert_eq!(n.net_map, None);
507 assert_eq!(n.browse_to_url, None);
508 }
509
510 /// `NotifyInitialNetMap` front-loads the current peer set (net_map only, no state).
511 #[tokio::test]
512 async fn initial_netmap_snapshot_emitted_when_masked() {
513 let (_s, _p, _b, _sd, _me, mut w) = harness(
514 NotifyWatchOpt::INITIAL_NETMAP,
515 DeviceState::Running,
516 Vec::new(),
517 );
518 let n = w.next().await.expect("initial snapshot");
519 assert_eq!(n.net_map, Some(Vec::new()));
520 assert_eq!(n.state, None);
521 }
522
523 /// Both initial bits coalesce into ONE `Notify` (Go builds a single `ini` struct), not two
524 /// separate events.
525 #[tokio::test]
526 async fn initial_snapshot_coalesces_both_fields() {
527 let (_s, _p, _b, _sd, _me, mut w) = harness(
528 NotifyWatchOpt::INITIAL_STATE | NotifyWatchOpt::INITIAL_NETMAP,
529 DeviceState::Running,
530 Vec::new(),
531 );
532 let n = w.next().await.expect("initial snapshot");
533 assert_eq!(n.state, Some(DeviceState::Running));
534 assert_eq!(n.net_map, Some(Vec::new()));
535 }
536
537 /// An empty mask sends NO initial snapshot; the watcher then receives the next real transition.
538 #[tokio::test]
539 async fn empty_mask_skips_initial_then_streams_change() {
540 let (state_tx, _p, _b, _sd, _me, mut w) =
541 harness(NotifyWatchOpt::empty(), DeviceState::Connecting, Vec::new());
542 // No initial snapshot: nothing within the quiet window.
543 assert!(
544 tokio::time::timeout(QUIET_WINDOW, w.next()).await.is_err(),
545 "empty mask must not emit an initial snapshot"
546 );
547 // Positive anchor: the watcher is still live and delivers the next real transition (so the
548 // negative assertion above was "nothing to send", not "stream already dead").
549 state_tx.send_replace(DeviceState::Running);
550 let n = w.next().await.expect("change after subscribe");
551 assert_eq!(n.state, Some(DeviceState::Running));
552 }
553
554 /// A `NeedsLogin` transition derives `browse_to_url` alongside `state` — one source of truth for
555 /// the auth URL.
556 #[tokio::test]
557 async fn needs_login_transition_derives_browse_to_url() {
558 // Subscribe with INITIAL_STATE so awaiting the first `next()` (the snapshot) is a
559 // deterministic barrier proving the bus task has finished its init borrows and entered the
560 // streaming loop — only then is a post-subscribe send guaranteed to be observed (no sleeps,
561 // no spawn-vs-send race). Any change after `.changed()`'s seen-version is detected even if
562 // the loop is not yet parked on `.changed()`.
563 let (state_tx, _p, _b, _sd, _me, mut w) = harness(
564 NotifyWatchOpt::INITIAL_STATE,
565 DeviceState::Connecting,
566 Vec::new(),
567 );
568 let snap = w.next().await.expect("initial snapshot");
569 assert_eq!(snap.state, Some(DeviceState::Connecting));
570 assert_eq!(snap.browse_to_url, None);
571 state_tx.send_replace(DeviceState::NeedsLogin(login_url()));
572 let n = w.next().await.expect("needs-login event");
573 assert_eq!(n.state, Some(DeviceState::NeedsLogin(login_url())));
574 assert_eq!(n.browse_to_url, Some(login_url()));
575 }
576
577 /// `NeedsLogin` present at subscribe is front-loaded with its `browse_to_url` (matches Go: the
578 /// initial snapshot carries `BrowseToURL` only when `state == NeedsLogin`).
579 #[tokio::test]
580 async fn initial_needs_login_includes_browse_to_url() {
581 let (_s, _p, _b, _sd, _me, mut w) = harness(
582 NotifyWatchOpt::INITIAL_STATE,
583 DeviceState::NeedsLogin(login_url()),
584 Vec::new(),
585 );
586 let n = w.next().await.expect("initial snapshot");
587 assert_eq!(n.browse_to_url, Some(login_url()));
588 }
589
590 /// A peer-set change streams as a `net_map` notification (no state field), carrying the actual
591 /// new peer payload (not just the degenerate empty round-trip).
592 #[tokio::test]
593 async fn peer_change_streams_netmap() {
594 // INITIAL_NETMAP snapshot is the barrier (proves the task finished its init borrows and is
595 // in the streaming loop) before we send — avoids the spawn-vs-send race.
596 let (_s, peer_tx, _b, _sd, _me, mut w) = harness(
597 NotifyWatchOpt::INITIAL_NETMAP,
598 DeviceState::Running,
599 Vec::new(),
600 );
601 let snap = w.next().await.expect("initial netmap snapshot");
602 assert_eq!(snap.net_map, Some(Vec::new()));
603 // Send a NON-EMPTY peer set so the assertion proves the payload is actually carried through,
604 // not merely that a notification fires.
605 let peers = vec![peer("peer-a"), peer("peer-b")];
606 peer_tx.send_replace(peers.clone());
607 let n = w.next().await.expect("netmap change");
608 assert_eq!(n.net_map, Some(peers));
609 assert_eq!(n.state, None);
610 }
611
612 /// After the initial snapshot, with no further changes, the bus does NOT re-emit — proving the
613 /// `borrow_and_update` correctly marks the snapshotted values seen (no initial-value busy-loop).
614 #[tokio::test]
615 async fn no_spurious_reemit_after_initial() {
616 let (state_tx, _p, _b, _sd, _me, mut w) = harness(
617 NotifyWatchOpt::INITIAL_STATE | NotifyWatchOpt::INITIAL_NETMAP,
618 DeviceState::Running,
619 Vec::new(),
620 );
621 let _initial = w.next().await.expect("initial snapshot");
622 assert!(
623 tokio::time::timeout(QUIET_WINDOW, w.next()).await.is_err(),
624 "no change occurred, so no further notification must arrive"
625 );
626 // Positive liveness anchor: prove the watcher was genuinely alive during the quiet window
627 // (not dropped/dead, which would ALSO deliver nothing and make the assertion above vacuous).
628 // A real transition after the silence must still be delivered.
629 state_tx.send_replace(DeviceState::Expired);
630 let n = w
631 .next()
632 .await
633 .expect("watcher still live after the quiet window");
634 assert_eq!(n.state, Some(DeviceState::Expired));
635 }
636
637 /// Flipping the shutdown cell terminates the stream: `next()` returns `None`.
638 #[tokio::test]
639 async fn shutdown_terminates_stream() {
640 let (_s, _p, _b, shutdown_tx, _me, mut w) =
641 harness(NotifyWatchOpt::empty(), DeviceState::Running, Vec::new());
642 shutdown_tx.send_replace(true);
643 assert_eq!(w.next().await, None, "shutdown must end the stream");
644 }
645
646 /// If the runtime is already shutting down at subscribe time, the stream ends immediately.
647 #[tokio::test]
648 async fn already_shutdown_ends_immediately() {
649 let (state_tx, state_rx) = watch::channel(DeviceState::Running);
650 let (peer_tx, peer_rx) = watch::channel(Vec::new());
651 let (browser_tx, browser_rx) = watch::channel(None);
652 let (self_tx, self_rx) = watch::channel(None);
653 let (_shutdown_tx, shutdown_rx) = watch::channel(true);
654 let (tx, rx) = mpsc::channel(NOTIFY_BUFFER);
655 tokio::spawn(run_bus(
656 NotifyWatchOpt::INITIAL_STATE,
657 state_rx,
658 peer_rx,
659 browser_rx,
660 self_rx,
661 shutdown_rx,
662 tx,
663 ));
664 let mut w = IpnBusWatcher { rx };
665 assert_eq!(w.next().await, None, "already-shutdown must emit nothing");
666 // Keep the source senders alive until after the assertion so termination is attributable to
667 // the shutdown flag, not a sender drop.
668 drop((state_tx, peer_tx, browser_tx, self_tx));
669 }
670
671 /// Dropping every source sender (runtime tearing down without the graceful flag) also ends the
672 /// stream rather than hanging.
673 #[tokio::test]
674 async fn source_sender_drop_terminates_stream() {
675 let (state_tx, _p, _b, _sd, _me, mut w) =
676 harness(NotifyWatchOpt::empty(), DeviceState::Running, Vec::new());
677 drop((state_tx, _p, _b, _sd, _me));
678 assert_eq!(w.next().await, None, "all senders gone must end the stream");
679 }
680
681 /// Streamed (post-subscribe) events are delivered per-source: a state change and a peer change
682 /// arrive as TWO single-field `Notify`s, not one coalesced event. This pins the documented
683 /// contract (only the *initial snapshot* coalesces; the loop is per-cell) so a future change to
684 /// the merge loop can't silently alter it.
685 #[tokio::test]
686 async fn streamed_events_are_per_source_not_coalesced() {
687 let (state_tx, peer_tx, _b, _sd, _me, mut w) = harness(
688 NotifyWatchOpt::INITIAL_STATE,
689 DeviceState::Connecting,
690 Vec::new(),
691 );
692 let _snap = w.next().await.expect("initial snapshot barrier");
693 // Move two distinct sources. They are independent watch cells, so the bus emits one Notify
694 // per source — never a single Notify carrying both `state` and `net_map`.
695 state_tx.send_replace(DeviceState::Running);
696 peer_tx.send_replace(vec![peer("peer-a")]);
697 let first = w.next().await.expect("first event");
698 let second = w.next().await.expect("second event");
699 for n in [&first, &second] {
700 assert!(
701 n.state.is_some() ^ n.net_map.is_some(),
702 "each streamed Notify carries exactly one of state / net_map, got {n:?}"
703 );
704 }
705 // Both fields were delivered, just across two events (order is biased-but-unspecified here).
706 assert!(
707 first.state.is_some() || second.state.is_some(),
708 "a state event arrived"
709 );
710 assert!(
711 first.net_map.is_some() || second.net_map.is_some(),
712 "a net_map event arrived"
713 );
714 }
715
716 /// A sequence of state transitions yields one ordered `Notify` per transition, with
717 /// `browse_to_url` set only on the `NeedsLogin` one — proving the loop re-arms correctly across
718 /// more than a single cycle and preserves order.
719 #[tokio::test]
720 async fn sequential_state_transitions_stream_in_order() {
721 let (state_tx, _p, _b, _sd, _me, mut w) = harness(
722 NotifyWatchOpt::INITIAL_STATE,
723 DeviceState::Connecting,
724 Vec::new(),
725 );
726 assert_eq!(
727 w.next().await.expect("snapshot").state,
728 Some(DeviceState::Connecting)
729 );
730 for next in [
731 DeviceState::Running,
732 DeviceState::NeedsLogin(login_url()),
733 DeviceState::Expired,
734 ] {
735 state_tx.send_replace(next.clone());
736 let n = w.next().await.expect("transition");
737 assert_eq!(n.state, Some(next.clone()));
738 assert_eq!(n.net_map, None);
739 let expect_url = matches!(next, DeviceState::NeedsLogin(_)).then(login_url);
740 assert_eq!(n.browse_to_url, expect_url);
741 }
742 }
743
744 /// Each non-login state flows through as `state: Some(..)` with `browse_to_url: None` — closes
745 /// the enum (the earlier tests only exercised Connecting / Running / NeedsLogin).
746 #[tokio::test]
747 async fn expired_and_failed_states_stream_without_url() {
748 for state in [
749 DeviceState::Expired,
750 DeviceState::Failed(crate::RegistrationError::AuthRejected("bad key".into())),
751 ] {
752 let (state_tx, _p, _b, _sd, _me, mut w) = harness(
753 NotifyWatchOpt::INITIAL_STATE,
754 DeviceState::Connecting,
755 Vec::new(),
756 );
757 let _snap = w.next().await.expect("snapshot barrier");
758 state_tx.send_replace(state.clone());
759 let n = w.next().await.expect("state event");
760 assert_eq!(n.state, Some(state));
761 assert_eq!(n.browse_to_url, None);
762 }
763 }
764
765 /// "Lossy by design": when the consumer never drains, a flood of changes fills the bounded
766 /// buffer and excess notifications are DROPPED — the producer (`send_replace` on the source
767 /// cell + the bus task) must never block. If `deliver` were changed to a blocking `send().await`,
768 /// the bus task would wedge and the subsequent shutdown would never be observed → this test would
769 /// hang (caught by the suite timeout). Proves the non-blocking `try_send` contract.
770 #[tokio::test]
771 async fn full_buffer_drops_and_never_blocks_producer() {
772 let (state_tx, _p, _b, shutdown_tx, _me, mut w) =
773 harness(NotifyWatchOpt::empty(), DeviceState::Connecting, Vec::new());
774 // Never call w.next(): the per-watcher mpsc fills to NOTIFY_BUFFER then drops the rest.
775 // Push well past the buffer depth; yield so the bus task runs each send.
776 for _ in 0..(NOTIFY_BUFFER * 2 + 16) {
777 state_tx.send_replace(DeviceState::Running);
778 state_tx.send_replace(DeviceState::Connecting);
779 tokio::task::yield_now().await;
780 }
781 // The producer never blocked (we got here). The bus task is also not wedged: a shutdown is
782 // still observed promptly and ends the stream once the buffer drains.
783 shutdown_tx.send_replace(true);
784 // Drain whatever buffered (≤ NOTIFY_BUFFER) then the stream must terminate with None.
785 let mut drained = 0usize;
786 while let Some(_n) = w.next().await {
787 drained += 1;
788 assert!(
789 drained <= NOTIFY_BUFFER,
790 "buffer must be bounded at NOTIFY_BUFFER ({NOTIFY_BUFFER}), drained {drained}"
791 );
792 }
793 }
794
795 /// Dropping the `IpnBusWatcher` reclaims the bus task PROMPTLY via the `tx.closed()` select arm —
796 /// no subsequent source change is needed (the regression guard for the idle-device leak the
797 /// `tx.closed()` arm fixes). Proven by observing the task drop its cloned `state_rx`, which falls
798 /// the sender's `receiver_count` back to 0 once the task returns.
799 #[tokio::test]
800 async fn consumer_drop_terminates_task() {
801 let (state_tx, _p, _b, _sd, _me, w) =
802 harness(NotifyWatchOpt::empty(), DeviceState::Connecting, Vec::new());
803 // Sanity: the bus task is live and holds a clone of the state receiver.
804 assert_eq!(
805 state_tx.receiver_count(),
806 1,
807 "bus task holds the source receiver"
808 );
809 // Drop the consumer with NO further change: its mpsc Receiver is gone, so `tx.closed()`
810 // resolves and the task must return on its own (not wait for an event).
811 drop(w);
812 // Poll until the task has returned (and thus dropped its state_rx). Bounded: a real leak
813 // never reaches 0 and fails by timing out under the suite cap. yield_now lets the task run.
814 while state_tx.receiver_count() != 0 {
815 tokio::task::yield_now().await;
816 }
817 assert_eq!(
818 state_tx.receiver_count(),
819 0,
820 "bus task must reclaim (drop its source receiver) once the consumer is gone"
821 );
822 }
823
824 /// A running-node consent URL (`MapResponse.PopBrowserURL`, via the de-thrashed browser cell)
825 /// streams as a standalone `browse_to_url` event — no `state`, no `net_map`.
826 #[tokio::test]
827 async fn running_node_browser_url_streams_standalone() {
828 // INITIAL_STATE snapshot is the barrier proving the task is in its streaming loop.
829 let (_s, _p, browser_tx, _sd, _me, mut w) = harness(
830 NotifyWatchOpt::INITIAL_STATE,
831 DeviceState::Running,
832 Vec::new(),
833 );
834 let snap = w.next().await.expect("initial snapshot");
835 assert_eq!(snap.state, Some(DeviceState::Running));
836 assert_eq!(
837 snap.browse_to_url, None,
838 "running-node URL is not front-loaded"
839 );
840 // Control pushes a consent URL mid-session (the producer sends Some on a new URL).
841 browser_tx.send_replace(Some(consent_url()));
842 let n = w.next().await.expect("browse-to-url event");
843 assert_eq!(n.browse_to_url, Some(consent_url()));
844 assert_eq!(n.state, None);
845 assert_eq!(n.net_map, None);
846 }
847
848 /// The running-node consent URL is NOT front-loaded into the initial snapshot even when present
849 /// at subscribe time (Go replays only the registration `b.authURL`, never `PopBrowserURL`). The
850 /// sticky value is reachable via the pull API, not the bus snapshot.
851 #[tokio::test]
852 async fn running_node_browser_url_not_in_initial_snapshot() {
853 let (state_tx, state_rx) = watch::channel(DeviceState::Running);
854 let (peer_tx, peer_rx) = watch::channel(Vec::new());
855 // Browser cell already holds a URL at subscribe time.
856 let (browser_tx, browser_rx) = watch::channel(Some(consent_url()));
857 let (self_tx, self_rx) = watch::channel(None);
858 let (shutdown_tx, shutdown_rx) = watch::channel(false);
859 let (tx, rx) = mpsc::channel(NOTIFY_BUFFER);
860 tokio::spawn(run_bus(
861 NotifyWatchOpt::INITIAL_STATE | NotifyWatchOpt::INITIAL_NETMAP,
862 state_rx,
863 peer_rx,
864 browser_rx,
865 self_rx,
866 shutdown_rx,
867 tx,
868 ));
869 let mut w = IpnBusWatcher { rx };
870 let snap = w.next().await.expect("initial snapshot");
871 // The snapshot carries state + net_map (masked) but NOT the pre-existing browser URL.
872 assert_eq!(snap.state, Some(DeviceState::Running));
873 assert_eq!(snap.net_map, Some(Vec::new()));
874 assert_eq!(
875 snap.browse_to_url, None,
876 "pre-existing running-node URL must not be front-loaded"
877 );
878 // It only arrives once it CHANGES post-subscribe.
879 let next = consent_url();
880 let mut next2 = next.clone();
881 next2.set_path("/consent2");
882 browser_tx.send_replace(Some(next2.clone()));
883 let n = w.next().await.expect("browser-url change after subscribe");
884 assert_eq!(n.browse_to_url, Some(next2));
885 drop((state_tx, peer_tx, shutdown_tx, self_tx));
886 }
887
888 /// Mid-session re-auth, end to end through the bus: control returns `MachineNotAuthorized` on a
889 /// live re-register, the control client surfaces the URL, the runtime bridge sets
890 /// [`DeviceState::NeedsLogin`] — which the bus turns into a `browse_to_url` event — and then a
891 /// successful re-register flips the device back to `Running`, clearing `browse_to_url`. This is
892 /// the user-visible contract of the fix (the dropped re-auth URL now reaches the embedder, and
893 /// goes away once the node recovers), exercised over the same `state_tx` the bridge writes.
894 #[tokio::test]
895 async fn mid_session_reauth_surfaces_browse_to_url_then_clears() {
896 // Subscribe with INITIAL_STATE so the first `next()` (the snapshot) is the barrier proving
897 // the bus task is in its streaming loop before we drive transitions.
898 let (state_tx, _p, _b, _sd, _me, mut w) = harness(
899 NotifyWatchOpt::INITIAL_STATE,
900 DeviceState::Running,
901 Vec::new(),
902 );
903 let snap = w.next().await.expect("initial snapshot");
904 assert_eq!(snap.state, Some(DeviceState::Running));
905 assert_eq!(snap.browse_to_url, None);
906
907 // Mid-session re-auth: the bridge sets NeedsLogin(url) on the state cell.
908 state_tx.send_replace(DeviceState::NeedsLogin(login_url()));
909 let n = w.next().await.expect("needs-login event");
910 assert_eq!(n.state, Some(DeviceState::NeedsLogin(login_url())));
911 assert_eq!(
912 n.browse_to_url,
913 Some(login_url()),
914 "the re-auth URL must reach the embedder as browse_to_url"
915 );
916
917 // A later successful re-register: the netmap self-node handler flips back to Running, and
918 // the bus reports the state change with browse_to_url cleared.
919 state_tx.send_replace(DeviceState::Running);
920 let n = w.next().await.expect("recovery event");
921 assert_eq!(n.state, Some(DeviceState::Running));
922 assert_eq!(
923 n.browse_to_url, None,
924 "recovering to Running clears the browse_to_url"
925 );
926 }
927
928 /// Two distinct consent URLs in sequence stream as two `browse_to_url` events.
929 #[tokio::test]
930 async fn sequential_browser_urls_stream_each() {
931 let (_s, _p, browser_tx, _sd, _me, mut w) = harness(
932 NotifyWatchOpt::INITIAL_STATE,
933 DeviceState::Running,
934 Vec::new(),
935 );
936 let _snap = w.next().await.expect("snapshot barrier");
937 let url_a = consent_url();
938 let mut url_b = consent_url();
939 url_b.set_path("/consent-b");
940 browser_tx.send_replace(Some(url_a.clone()));
941 assert_eq!(
942 w.next().await.expect("first url").browse_to_url,
943 Some(url_a)
944 );
945 browser_tx.send_replace(Some(url_b.clone()));
946 assert_eq!(
947 w.next().await.expect("second url").browse_to_url,
948 Some(url_b)
949 );
950 }
951
952 /// A browser-URL change and a state change arrive as TWO distinct single-field events (the new
953 /// browser arm doesn't coalesce into, or clobber, a concurrent state transition). Companion to
954 /// `streamed_events_are_per_source_not_coalesced` (state+peer), for the browser+state pair.
955 #[tokio::test]
956 async fn browser_url_and_state_change_interleave() {
957 let (state_tx, _p, browser_tx, _sd, _me, mut w) = harness(
958 NotifyWatchOpt::INITIAL_STATE,
959 DeviceState::Running,
960 Vec::new(),
961 );
962 let _snap = w.next().await.expect("snapshot barrier");
963 browser_tx.send_replace(Some(consent_url()));
964 state_tx.send_replace(DeviceState::Expired);
965 let a = w.next().await.expect("first event");
966 let b = w.next().await.expect("second event");
967 for n in [&a, &b] {
968 assert!(
969 n.state.is_some() ^ n.browse_to_url.is_some(),
970 "each streamed event carries exactly one of state / browse_to_url, got {n:?}"
971 );
972 assert_eq!(n.net_map, None);
973 }
974 assert!(
975 a.browse_to_url.is_some() || b.browse_to_url.is_some(),
976 "a browse_to_url event arrived"
977 );
978 assert!(
979 a.state.is_some() || b.state.is_some(),
980 "a state event arrived"
981 );
982 }
983
984 /// A netmap update that moves only this node (here a MagicDNS rename) reaches the watcher as
985 /// exactly one `net_map` frame carrying the unchanged peer set, as Go's `Notify.SelfChange`
986 /// does. The peer cell is never touched, so before the self-node source this sent nothing.
987 #[tokio::test]
988 async fn self_only_change_ticks_netmap_once() {
989 let peers = vec![peer("peer-a")];
990 let (_s, _p, _b, _sd, self_tx, mut w) = harness_with_self(
991 NotifyWatchOpt::INITIAL_NETMAP,
992 DeviceState::Running,
993 peers.clone(),
994 Some(self_node("me")),
995 );
996 // The snapshot is the barrier proving the task is in its streaming loop.
997 let snap = w.next().await.expect("initial netmap snapshot");
998 assert_eq!(snap.net_map, Some(peers.clone()));
999
1000 self_tx.send_replace(Some(self_node("me-renamed")));
1001 let n = next_within(&mut w, "self-only change must tick net_map").await;
1002 assert_eq!(
1003 n.net_map,
1004 Some(peers),
1005 "the tick carries the current peer set"
1006 );
1007 assert_eq!(n.state, None);
1008 assert_eq!(n.browse_to_url, None);
1009 assert!(
1010 tokio::time::timeout(QUIET_WINDOW, w.next()).await.is_err(),
1011 "one self-only change must produce exactly one frame"
1012 );
1013 }
1014
1015 /// Control re-sending the same self node (the cell is replaced on every netmap that carries
1016 /// one) sends nothing. The positive anchor afterwards proves the watcher was live during the
1017 /// quiet window, so the silence is the dedup and not a dead stream.
1018 #[tokio::test]
1019 async fn unchanged_self_node_sends_nothing() {
1020 let (_s, _p, _b, _sd, self_tx, mut w) = harness_with_self(
1021 NotifyWatchOpt::INITIAL_NETMAP,
1022 DeviceState::Running,
1023 Vec::new(),
1024 Some(self_node("me")),
1025 );
1026 let _snap = w.next().await.expect("initial netmap snapshot");
1027
1028 self_tx.send_replace(Some(self_node("me")));
1029 assert!(
1030 tokio::time::timeout(QUIET_WINDOW, w.next()).await.is_err(),
1031 "an identical self node must not tick net_map"
1032 );
1033
1034 self_tx.send_replace(Some(self_node("me-renamed")));
1035 let n = next_within(&mut w, "watcher still live after the quiet window").await;
1036 assert_eq!(n.net_map, Some(Vec::new()));
1037 }
1038
1039 /// The first self node to arrive after subscribe (the cell starts empty) is a change and ticks.
1040 #[tokio::test]
1041 async fn first_self_node_after_subscribe_ticks_netmap() {
1042 let (_s, _p, _b, _sd, self_tx, mut w) = harness(
1043 NotifyWatchOpt::INITIAL_NETMAP,
1044 DeviceState::Running,
1045 Vec::new(),
1046 );
1047 let _snap = w.next().await.expect("initial netmap snapshot");
1048 self_tx.send_replace(Some(self_node("me")));
1049 let n = next_within(&mut w, "first self node must tick net_map").await;
1050 assert_eq!(n.net_map, Some(Vec::new()));
1051 }
1052}