Skip to main content

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}