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