Skip to main content

proxy_watch/
watch.rs

1//! Public watcher façade: [`ProxyWatcher`], [`WatchOptions`]. Platform backend on a dedicated thread.
2
3use std::collections::VecDeque;
4use std::fmt;
5use std::pin::Pin;
6use std::sync::{Arc, Mutex, OnceLock};
7use std::task::{Context, Poll, Waker};
8use std::time::Duration;
9
10use futures_core::Stream;
11
12use crate::config::{ProxyConfig, ProxyConfigSource};
13use crate::error::Error;
14use crate::sys;
15
16/// The default debounce window (200 ms).
17pub const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(200);
18
19// The shortest [`WatchOptions::poll_interval`] any backend will actually wait for.
20pub(crate) const MIN_POLL_INTERVAL: Duration = DEFAULT_DEBOUNCE;
21
22// The longest [`WatchOptions::debounce`] any backend will actually wait for.
23//
24// Every backend opens its window as `Instant::now() + debounce`, and that addition
25// *panics* on overflow rather than saturating. `Instant` is an opaque monotonic counter
26// with no exposed maximum, so the only portable guard is to refuse windows large enough
27// to approach it. There is no portable number at that end to measure a cap against, so
28// the cap comes from the other end: a day is already far longer than any window that
29// coalesces usefully. For scale, `Duration::MAX` carries `u64::MAX` seconds — upwards of
30// 10^19 — against the 86_400 below.
31pub(crate) const MAX_DEBOUNCE: Duration = Duration::from_secs(24 * 60 * 60);
32
33// Cap undelivered items at 1024; [`Shared::emit`] drops the oldest when full. Snapshots
34// fold into one another there, so what this bounds is the number of undelivered failures.
35const MAX_QUEUED_CHANGES: usize = 1024;
36
37// Clamp a caller-supplied poll interval to [`MIN_POLL_INTERVAL`] so no backend can spin.
38#[cfg_attr(
39    not(any(windows, target_os = "macos", target_os = "linux")),
40    allow(dead_code)
41)]
42pub(crate) fn effective_poll_interval(requested: Duration) -> Duration {
43    if requested < MIN_POLL_INTERVAL {
44        crate::trace::warning!(
45            requested = ?requested,
46            floor = ?MIN_POLL_INTERVAL,
47            "poll_interval is below the minimum that avoids a busy loop; using the floor instead"
48        );
49        MIN_POLL_INTERVAL
50    } else {
51        requested
52    }
53}
54
55// Clamp a caller-supplied debounce window to [`MAX_DEBOUNCE`] so no backend can panic
56// computing its deadline.
57#[cfg_attr(
58    not(any(windows, target_os = "macos", target_os = "linux")),
59    allow(dead_code)
60)]
61pub(crate) fn effective_debounce(requested: Duration) -> Duration {
62    if requested > MAX_DEBOUNCE {
63        crate::trace::warning!(
64            requested = ?requested,
65            ceiling = ?MAX_DEBOUNCE,
66            "debounce is above the maximum that keeps the window's deadline representable; \
67             using the ceiling instead"
68        );
69        MAX_DEBOUNCE
70    } else {
71        requested
72    }
73}
74
75/// Tuning knobs for [`ProxyWatcher::with_options`] (`#[non_exhaustive]` — use
76/// [`WatchOptions::new`] / `with_*`).
77///
78/// ```
79/// use proxy_watch::WatchOptions;
80/// use std::time::Duration;
81///
82/// let opts = WatchOptions::new()
83///     .with_debounce(Duration::from_millis(50))
84///     .with_group_policy(false);
85/// assert_eq!(opts.debounce, Duration::from_millis(50));
86/// ```
87#[derive(Debug, Clone, PartialEq, Eq)]
88#[non_exhaustive]
89pub struct WatchOptions {
90    /// Debounce before re-read (default [`DEFAULT_DEBOUNCE`]); anything over 24 h is
91    /// lowered to it.
92    ///
93    /// A *fixed* window, not a sliding one: it opens on the first change, and further
94    /// changes inside it are folded into the same emission rather than pushing it back.
95    /// The wait after a change is therefore bounded by this value however long the storm
96    /// of changes behind it lasts.
97    pub debounce: Duration,
98
99    /// Also read and watch per-machine group policy (default `true`; Windows only).
100    ///
101    /// The one source no failure of which can fail a read. Whatever goes wrong here — an
102    /// HKLM key this process cannot read, a policy `AutoConfigURL` this crate refuses as
103    /// malformed — is reported as no policy, and [`ProxyConfig::fallbacks`] records that
104    /// something went wrong without saying what. That is affordable because a
105    /// [`ProxyConfigSource::GroupPolicy`] entry is never
106    /// [`effective`](ProxyConfig::effective) — see that variant for the measurement — so
107    /// failing the whole read on its account would refuse a machine whose per-user store
108    /// answers perfectly well. A value stored under a type the key's own name does not
109    /// carry is not a failure at all: a `ProxyEnable` written as a `REG_SZ` rather than the
110    /// documented `REG_DWORD` reads here exactly like one that was never written, and the
111    /// policy is reported as absent.
112    ///
113    /// Watching it is worth more than reading it. The key holds one value Windows *does*
114    /// act on — `ProxySettingsPerUser`, the only one `inetres.admx` defines there — and
115    /// switching it changes what `WinHttpGetIEProxyConfigForCurrentUser` returns, so a
116    /// change under this key can change the effective configuration even though nothing
117    /// under it is ever the effective configuration.
118    pub watch_group_policy: bool,
119
120    /// Timer re-read in addition to notifications; anything under 200 ms is raised to it,
121    /// and on macOS anything over an hour is lowered to an hour — the run loop wait this
122    /// becomes is bounded so that it is never literally infinite.
123    ///
124    /// It also decides how a notification route that will not arm reaches the caller. With
125    /// no interval set, the platform's primary route failing to arm is fatal:
126    /// [`ProxyWatcher::with_options`] returns [`Error::Io`], naming this option as the fix.
127    /// With one set, that same failure only reaches [`WatchHealth::degraded`], on a watcher
128    /// that starts. Every other route degrades either way.
129    pub poll_interval: Option<Duration>,
130}
131
132impl Default for WatchOptions {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138impl WatchOptions {
139    /// Default: 200 ms debounce, group policy on, polling off.
140    #[must_use]
141    pub const fn new() -> Self {
142        Self {
143            debounce: DEFAULT_DEBOUNCE,
144            watch_group_policy: true,
145            poll_interval: None,
146        }
147    }
148
149    /// Set the debounce window (builder style).
150    #[must_use]
151    pub const fn with_debounce(mut self, debounce: Duration) -> Self {
152        self.debounce = debounce;
153        self
154    }
155
156    /// Enable or disable reading and watching per-machine group policy (builder style).
157    #[must_use]
158    pub const fn with_group_policy(mut self, watch: bool) -> Self {
159        self.watch_group_policy = watch;
160        self
161    }
162
163    /// Set or clear the polling interval (see [`WatchOptions::poll_interval`]).
164    ///
165    /// ```
166    /// use proxy_watch::WatchOptions;
167    /// use std::time::Duration;
168    ///
169    /// let opts = WatchOptions::new().with_poll_interval(Some(Duration::from_secs(30)));
170    /// assert_eq!(opts.poll_interval, Some(Duration::from_secs(30)));
171    /// ```
172    #[must_use]
173    pub const fn with_poll_interval(mut self, interval: Option<Duration>) -> Self {
174        self.poll_interval = interval;
175        self
176    }
177}
178
179// Fail-soft outcome for establishing one change-notification route.
180#[cfg_attr(
181    not(any(
182        windows,
183        all(
184            target_os = "linux",
185            any(feature = "linux-gnome", feature = "linux-kde")
186        )
187    )),
188    allow(dead_code)
189)]
190pub(crate) enum WatchFailSoft<T> {
191    // Route established (may still be "nothing to watch", e.g. missing courtesy path).
192    Live(T),
193    // Non-fatal: log at `WARN` and continue without it.
194    Degraded(Error),
195    // Fatal: caller has no other way to learn of later changes.
196    Fatal(Error),
197}
198
199// Leading + no poll → Fatal; otherwise a failed establish is Degraded.
200#[cfg_attr(
201    not(any(
202        windows,
203        all(
204            target_os = "linux",
205            any(feature = "linux-gnome", feature = "linux-kde")
206        )
207    )),
208    allow(dead_code)
209)]
210pub(crate) fn watch_fail_soft<T>(
211    leading: bool,
212    poll_interval: Option<Duration>,
213    established: Result<T, Error>,
214) -> WatchFailSoft<T> {
215    match established {
216        Ok(value) => WatchFailSoft::Live(value),
217        Err(error) if leading && poll_interval.is_none() => WatchFailSoft::Fatal(error),
218        Err(error) => WatchFailSoft::Degraded(error),
219    }
220}
221
222// [`Error::Io`] for a fatal route failure; names the fix (`poll_interval`).
223#[cfg_attr(
224    not(any(
225        windows,
226        all(
227            target_os = "linux",
228            any(feature = "linux-gnome", feature = "linux-kde")
229        )
230    )),
231    allow(dead_code)
232)]
233pub(crate) fn fatal_watch_error(what: &str, why_no_fallback: &str, source: Error) -> Error {
234    Error::io(
235        format!(
236            "{what} failed ({source}), and {why_no_fallback}; set WatchOptions::poll_interval \
237             to fall back to polling and continue anyway"
238        ),
239        std::io::Error::other(source),
240    )
241}
242
243// Backend report of change-notification routes after construction.
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub(crate) struct BackendHealth {
246    // See [`WatchHealth::degraded`].
247    pub(crate) degraded: Vec<ProxyConfigSource>,
248    // See [`WatchHealth::has_live_notifications`].
249    pub(crate) has_live_notifications: bool,
250}
251
252/// Liveness of change-notification routes ([`ProxyWatcher::health`]).
253///
254/// **`degraded`:** route is not delivering — set [`WatchOptions::poll_interval`].
255/// **`is_frozen`:** nothing arrives on its own — no route and no poll, or a stopped
256/// thread. [`ProxyWatcher::poll_now`] still delivers until the thread stops.
257#[non_exhaustive]
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct WatchHealth {
260    /// Routes that could not be established, or that were established and later lost. An
261    /// entry names the source a dead route stops reporting on, which is not always the
262    /// store the reader opens; a source that is simply not configured here is nothing to
263    /// watch rather than a failed watch, and is not listed. Do not expect every entry to
264    /// appear in [`ProxyConfig::sources`](crate::ProxyConfig::sources): notification routes
265    /// are per store, so a lost `kioslaverc` watch is reported as
266    /// [`Kioslaverc`](crate::ProxyConfigSource::Kioslaverc) even on a machine whose
267    /// `ProxyType = 4` makes that store report
268    /// [`KioslavercEnv`](crate::ProxyConfigSource::KioslavercEnv). Only ever grows: a source that
269    /// degrades stays listed for the life of the watcher, the same one-way shape as
270    /// [`has_live_notifications`](Self::has_live_notifications).
271    pub degraded: Vec<ProxyConfigSource>,
272    /// At least one native notification route is still live. Starts from what was
273    /// established at construction and only ever falls to `false`, once every route is
274    /// gone. A backend thread that stopped is reported by [`stopped`](Self::stopped)
275    /// instead — that is not a statement about the routes.
276    pub has_live_notifications: bool,
277    /// Poll interval copied from [`WatchOptions::poll_interval`] at construction: what the
278    /// caller asked for, not the floor a shorter request is raised to before any backend
279    /// uses it. The floor is never reported, because this field says whether polling was
280    /// asked for and [`stopped`](Self::stopped) says whether the request is still met.
281    pub poll_interval: Option<Duration>,
282    /// The backend thread has stopped, so `poll_interval` no longer buys anything: the
283    /// polling this type reports happens *on* that thread. The stream ends with `None`
284    /// once drained, but a caller that only reads health would otherwise keep waiting.
285    pub stopped: bool,
286}
287
288impl WatchHealth {
289    /// Every route is delivering: nothing degraded, at least one live native
290    /// notification, and a backend thread still running to serve them. A
291    /// [`stopped`](Self::stopped) thread is never fully live, whatever the routes
292    /// reported while it ran.
293    #[must_use]
294    pub fn is_fully_live(&self) -> bool {
295        !self.stopped && self.degraded.is_empty() && self.has_live_notifications
296    }
297
298    /// No live native notification and no [`WatchOptions::poll_interval`] — or a
299    /// [`stopped`](Self::stopped) thread, which takes the polling with it.
300    #[must_use]
301    pub fn is_frozen(&self) -> bool {
302        self.stopped || (!self.has_live_notifications && self.poll_interval.is_none())
303    }
304}
305
306/// One internally consistent observation of a watcher.
307///
308/// [`ProxyWatcher::current`] and [`ProxyWatcher::health`] remain convenient independent
309/// reads. Use [`ProxyWatcher::state`] when the configuration and liveness must describe
310/// the same instant: both halves are copied while holding the watcher's shared mutex once.
311#[non_exhaustive]
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct WatchState {
314    /// Most recent proxy configuration.
315    pub config: ProxyConfig,
316    /// Route and backend-thread liveness at the same observation point.
317    pub health: WatchHealth,
318}
319
320/// Stream item of [`ProxyWatcher`]: a state snapshot or a failed re-read, each stamped
321/// with the watcher's [`WatchState`] as it stood at delivery.
322///
323/// A snapshot arrives when the configuration changed, when the health changed, or both —
324/// which is what makes a lost notification route reach a subscriber rather than only
325/// [`ProxyWatcher::health`].
326#[non_exhaustive]
327#[derive(Debug)]
328pub enum WatchEvent {
329    /// The configuration, the liveness of the routes, or both moved.
330    ///
331    /// Consecutive undelivered snapshots fold into the newest one, so a subscriber that
332    /// stops polling loses the history between snapshots, never the current answer.
333    #[non_exhaustive]
334    Snapshot {
335        /// Configuration and liveness at delivery.
336        state: WatchState,
337    },
338    /// Re-reading the OS settings failed. This does not end the subscription.
339    ///
340    /// Nothing is guaranteed to follow, and nothing reports the recovery either. If the
341    /// next successful read returns the same configuration and no route changed, the
342    /// equality skip publishes nothing, and [`ProxyWatcher::current`] and
343    /// [`ProxyWatcher::state`] answer exactly what they answered before the failure,
344    /// `captured_at` included — so there is nothing to wait for and nothing to confirm:
345    /// what they already hold is what that read agreed with. A
346    /// [`Snapshot`](Self::Snapshot) does follow whenever the configuration or the health
347    /// actually moved.
348    #[non_exhaustive]
349    Error {
350        /// Why the re-read failed.
351        error: Error,
352        /// Liveness at delivery, and the last configuration that did read cleanly.
353        state: WatchState,
354    },
355}
356
357impl WatchEvent {
358    /// The state stamped on this event, whichever variant it is.
359    #[must_use]
360    pub fn state(&self) -> &WatchState {
361        match self {
362            Self::Snapshot { state } | Self::Error { state, .. } => state,
363        }
364    }
365}
366
367// Fold runtime additions into construction-time [`WatchHealth`] (dedupe; one-way live→false).
368//
369// `poll_interval` is reported as it was requested even once `stopped`, because it says
370// what the caller asked for; `stopped` is what says the request is no longer being met.
371fn merge_runtime_health(
372    construction: &WatchHealth,
373    runtime_degraded: Vec<ProxyConfigSource>,
374    runtime_no_live_notifications: bool,
375    stopped: bool,
376) -> WatchHealth {
377    let mut degraded = construction.degraded.clone();
378    for source in runtime_degraded {
379        if !degraded.contains(&source) {
380            degraded.push(source);
381        }
382    }
383    WatchHealth {
384        degraded,
385        has_live_notifications: construction.has_live_notifications
386            && !runtime_no_live_notifications,
387        poll_interval: construction.poll_interval,
388        stopped,
389    }
390}
391
392/// Read the OS proxy configuration once, without starting a watcher.
393///
394/// The read [`ProxyWatcher::new`] performs during construction, on its own: no thread is
395/// spawned and no change-notification route is registered. That is the difference worth
396/// knowing — a machine where notifications cannot be armed makes `ProxyWatcher::new` fail
397/// while its settings are still perfectly readable, and this call returns them.
398///
399/// Nothing here is watched, so nothing reports a later change; call it again.
400///
401/// Blocking, and not always briefly. Both non-Windows backends wait on a system service
402/// that can be absent: on macOS a `configd` that is still coming up is retried for five
403/// seconds before this gives up, and inside a Linux sandbox each portal `Lookup` is
404/// bounded at five seconds too. That bound is per call, and a sandboxed read asks five
405/// questions: one unanswered `Lookup` ends the read at five seconds, but five answers
406/// that each arrive just inside the bound hold it for twenty-five. Neither is the ordinary
407/// case, but this is not a call to put on a request path expecting syscall latency.
408///
409/// A Linux read consults both desktop stores, and an error from the one that is *not*
410/// leading this session is softened to "absent" rather than failing the call — a
411/// malformed `kioslaverc` should not break a GNOME session. So a success can be missing a
412/// source that exists, and without the `tracing` feature nothing says so. The softening
413/// holds only while the leading store is itself configured, which is what makes the
414/// missing source one that could not have changed the answer. When the leading store is
415/// unset, the store that failed *was* the effective one, and the error is returned rather
416/// than reported as a `Direct` nothing measured.
417///
418/// ```no_run
419/// let config = proxy_watch::read()?;
420/// println!("effective: {:?}", config.effective);
421/// # Ok::<(), proxy_watch::Error>(())
422/// ```
423///
424/// # Errors
425///
426/// [`Error::Unsupported`], [`Error::Io`], [`Error::Sandboxed`] (Linux, in a sandbox with
427/// no dconf), or a parse error from malformed OS settings.
428pub fn read() -> Result<ProxyConfig, Error> {
429    read_with_options(&WatchOptions::default())
430}
431
432/// [`read`], with the one option a read can honour:
433/// [`WatchOptions::watch_group_policy`]. The debounce and poll-interval fields describe a
434/// watcher this call never starts, so they are ignored.
435///
436/// # Errors
437///
438/// Same as [`read`].
439pub fn read_with_options(options: &WatchOptions) -> Result<ProxyConfig, Error> {
440    crate::trace::debug!(
441        group_policy = options.watch_group_policy,
442        "reading the system proxy configuration once"
443    );
444    let config = sys::read_config(options)?;
445    crate::trace::debug!(
446        config = %crate::trace::ConfigSummary(&config),
447        "read the system proxy configuration"
448    );
449    Ok(config)
450}
451
452/// OS proxy watcher: initial snapshot, then debounced changes (equality ignores `captured_at`).
453/// Own OS threads — one, and on Linux one more per live source, plus one for
454/// [`WatchOptions::poll_interval`] (elsewhere that interval shortens the single thread's
455/// own wait instead of adding one). Drop joins them.
456/// [`ProxyWatcher::health`] for route liveness. Win/macOS/Linux;
457/// else [`Error::Unsupported`]. Applies no `*_proxy` convention of its own — only KDE's
458/// `ProxyType = 4` builds a [`ProxyEnv`](crate::ProxyEnv), from the variables
459/// `kioslaverc` names.
460///
461/// The stream is level-triggered: while a subscriber keeps polling, a finite number of
462/// polls always reaches a [`WatchEvent::Snapshot`] matching the current
463/// [`state`](Self::state), a [`WatchEvent::Error`], or `None`. An `Error` item does not
464/// end the stream; `None` comes only once the backend thread has stopped, and is preceded
465/// by a snapshot reporting [`WatchHealth::stopped`]. Undelivered snapshots fold into the
466/// newest one, so a subscriber that stops polling loses the history between snapshots,
467/// never [`current`](Self::current); undelivered failures are capped at 1024, oldest
468/// discarded first. The trim runs when a snapshot is published, so a failure arriving
469/// after a full one holds the queue at 1025 until the next snapshot trims it back.
470///
471/// One subscriber, not a broadcast. Only the most recently registered waker is kept, and
472/// `poll_next` takes `&mut`, so the only way to poll a watcher from more than one task is
473/// to put it behind a lock — after which the task that polled first is parked with its
474/// waker overwritten, and stays parked until something else on this watcher wakes it. To
475/// fan a stream out, poll it in one place and forward.
476///
477/// [`Send`] but not [`Sync`] — on every platform, for a different platform reason each
478/// time. So [`current`](Self::current), [`health`](Self::health), [`state`](Self::state)
479/// and [`poll_now`](Self::poll_now) take `&self` and do no OS I/O, yet still belong to
480/// the thread that owns the watcher, and `Arc<ProxyWatcher>` is not `Send`. Move the
481/// watcher into the task that polls it and forward from there what other threads need.
482/// Under the `tokio` feature `watch_channel` is that forwarding already written — named in
483/// backticks rather than linked because it exists only under that feature; without it the
484/// shape is the same by hand, one owner republishing [`current`](Self::current) into a
485/// `Mutex` or a channel after every item. Neither recovers
486/// [`poll_now`](Self::poll_now), which stays with the owner: send it a request rather than
487/// sharing the watcher.
488///
489/// The Linux threads read the process environment, and keep reading it: every
490/// notification re-reads the desktop and sandbox variables, at a moment the caller does
491/// not choose. [`std::env::set_var`] and [`std::env::remove_var`] are sound only while no
492/// other thread is reading the environment, so treat them as unavailable for as long as a
493/// watcher is alive — set what you need before constructing one. Windows is exempt;
494/// `std` documents both as always safe there.
495///
496/// ```no_run
497/// use std::future::poll_fn;
498/// use std::pin::Pin;
499///
500/// use futures_core::Stream;
501/// use proxy_watch::{ProxyWatcher, WatchEvent};
502///
503/// let mut watcher = ProxyWatcher::new()?;
504/// println!("current: {:?}", watcher.current().effective);
505///
506/// let next = futures_executor::block_on(poll_fn(|cx| Pin::new(&mut watcher).poll_next(cx)));
507/// match next {
508///     Some(WatchEvent::Snapshot { state, .. }) => {
509///         println!("now: {:?} (live: {})", state.config.effective, state.health.is_fully_live());
510///     }
511///     Some(WatchEvent::Error { error, .. }) => eprintln!("re-read failed: {error}"),
512///     _ => {}
513/// }
514/// # Ok::<(), proxy_watch::Error>(())
515/// ```
516pub struct ProxyWatcher {
517    // Declared first so that it is *dropped* first: the platform thread is stopped
518    // and joined before anything else this type owns goes away.
519    watch: sys::Watch,
520    shared: Arc<Shared>,
521}
522
523impl fmt::Debug for ProxyWatcher {
524    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
525        let state = self.state();
526        f.debug_struct("ProxyWatcher")
527            .field("current", &state.config.effective)
528            .field("health", &state.health)
529            .finish_non_exhaustive()
530    }
531}
532
533impl ProxyWatcher {
534    /// Start watching with [`WatchOptions::default`].
535    ///
536    /// # Errors
537    ///
538    /// [`Error::Unsupported`], [`Error::Io`], [`Error::Sandboxed`] (Linux, in a sandbox
539    /// with no dconf), or a parse error from malformed OS settings.
540    pub fn new() -> Result<Self, Error> {
541        Self::with_options(WatchOptions::default())
542    }
543
544    /// Start watching with explicit options.
545    ///
546    /// Blocks the way [`read`] does, and can pay that price twice: the notification route is
547    /// armed before the configuration is read, not after, so a change landing between the
548    /// two is already queued rather than lost. macOS is the exception on both counts — it
549    /// subscribes on the watcher thread instead, closing the same window with the re-read
550    /// that thread opens with, and that subscription and the read each run the same
551    /// five-second retry budget, so a `configd` that is still coming up can hold this call
552    /// for ten seconds.
553    ///
554    /// # Errors
555    ///
556    /// Same as [`ProxyWatcher::new`].
557    pub fn with_options(options: WatchOptions) -> Result<Self, Error> {
558        // By return, the re-read mechanism is in place (delivery may lag a moment).
559        crate::trace::debug!(
560            debounce = ?options.debounce,
561            group_policy = options.watch_group_policy,
562            poll_interval = ?options.poll_interval,
563            "starting a proxy watcher"
564        );
565        let mut watch = sys::Watch::armed(&options)?;
566        let initial = sys::read_config(&options)?;
567        crate::trace::initial(&initial);
568        let shared = Arc::new(Shared::new(initial));
569        watch.spawn(&options, Arc::clone(&shared))?;
570        // Routes are established by the time `spawn` returns `Ok` (`src/sys/mod.rs`).
571        let backend_health = watch.health();
572        // Written before this constructor hands out the `ProxyWatcher` that every reader
573        // of the health has to go through, so no caller can observe it unset.
574        shared.set_construction_health(WatchHealth {
575            degraded: backend_health.degraded,
576            has_live_notifications: backend_health.has_live_notifications,
577            poll_interval: options.poll_interval,
578            stopped: false,
579        });
580        Ok(Self { watch, shared })
581    }
582
583    /// The most recent snapshot (no OS I/O; may briefly wait for the internal mutex).
584    /// Equals the constructor read right after start.
585    #[must_use]
586    pub fn current(&self) -> ProxyConfig {
587        self.shared.current()
588    }
589
590    /// Construction-time routes plus any runtime degradation (see [`WatchHealth`]).
591    /// Does no OS I/O; it may briefly wait for the same internal mutex
592    /// [`ProxyWatcher::current`] uses.
593    #[must_use]
594    pub fn health(&self) -> WatchHealth {
595        self.shared.health()
596    }
597
598    /// Configuration and health copied from one shared-state observation.
599    ///
600    /// This does no OS I/O, though it may briefly wait for the internal mutex. Prefer it
601    /// over separate [`current`](Self::current) and [`health`](Self::health) calls when a
602    /// route may degrade or the backend thread may stop concurrently.
603    #[must_use]
604    pub fn state(&self) -> WatchState {
605        self.shared.state()
606    }
607
608    /// Request an immediate re-read (same debounce/equality path as a native notify).
609    ///
610    /// Call it when the caller knows something this crate cannot see: a connection attempt
611    /// failed in a way that might be proxy-related, or the caller's own network-change
612    /// watch fired. Watching the network itself is not this crate's job, and this is what
613    /// stands in for it.
614    ///
615    /// Returns immediately, and may deliver nothing. Sharing a notification's path means
616    /// sharing its equality skip: a re-read that finds the configuration and the health
617    /// unchanged publishes no [`WatchEvent`], and asking again does not force one. The
618    /// [`Stream`] reports change, not completion — and so does everything else:
619    /// [`current`](Self::current) and [`state`](Self::state) answer the same before and
620    /// after such a re-read, so they are where the current answer comes from, not evidence
621    /// that this request landed. [`WatchEvent::Error`] says the same for the recovery case.
622    pub fn poll_now(&self) {
623        self.watch.poll_now();
624    }
625}
626
627impl Stream for ProxyWatcher {
628    type Item = WatchEvent;
629
630    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
631        self.shared.poll_next(cx)
632    }
633}
634
635// The queue shared between the platform watcher thread and the [`Stream`].
636#[derive(Debug)]
637pub(crate) struct Shared {
638    state: Mutex<State>,
639    // Construction-time half of [`WatchHealth`]; the runtime half lives in `state`.
640    // It sits here, rather than on [`ProxyWatcher`], so that the health can be assembled
641    // wherever the shared state is held — including from inside the queue's own lock.
642    construction: OnceLock<WatchHealth>,
643}
644
645// One undelivered item. A snapshot carries no payload: [`Shared::poll_next`] stamps the
646// state at delivery from `current` and the merged health, so an entry only records *that*
647// something is owed, not what it said when it was queued.
648#[derive(Debug)]
649enum Queued {
650    Snapshot,
651    Error(Error),
652}
653
654#[derive(Debug)]
655struct State {
656    current: ProxyConfig,
657    // At most one `Queued::Snapshot` (folded by [`Shared::emit`]); failures keep their
658    // order and count, coalescing only at the tail via [`Shared::fail`], and are capped
659    // at [`MAX_QUEUED_CHANGES`].
660    queue: VecDeque<Queued>,
661    // Discards in the current overflow episode; reset when the consumer drains.
662    dropped: u64,
663    waker: Option<Waker>,
664    closed: bool,
665    // A health transition nobody has been told about. Nothing enqueues on a degrade, so
666    // without this a lost route would wait for a configuration change that may never
667    // come — [`Shared::poll_next`] synthesises a snapshot for it instead.
668    dirty: bool,
669    // Routes that degraded after construction ([`Shared::degrade`]).
670    runtime_degraded: Vec<ProxyConfigSource>,
671    // Set once every native route a backend started with has degraded; never clears.
672    runtime_no_live_notifications: bool,
673}
674
675impl State {
676    // Enforce [`MAX_QUEUED_CHANGES`] by discarding from the front.
677    fn trim_to_capacity(&mut self) -> Option<u64> {
678        if self.queue.len() <= MAX_QUEUED_CHANGES {
679            return None;
680        }
681        while self.queue.len() > MAX_QUEUED_CHANGES {
682            self.queue.pop_front();
683            self.dropped += 1;
684        }
685        Some(self.dropped)
686    }
687}
688
689// Announce what [`State::trim_to_capacity`] discarded, outside the mutex.
690#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
691fn report_discarded(discarded: Option<u64>) {
692    match discarded {
693        None => {}
694        Some(1) => crate::trace::warning!(
695            capacity = MAX_QUEUED_CHANGES,
696            "the change queue is full, so the oldest undelivered failure was discarded; \
697             the subscriber is not draining the stream. `ProxyWatcher::current` stays \
698             accurate — what is being lost is the record of individual read failures"
699        ),
700        Some(total) => crate::trace::debug!(
701            discarded = total,
702            capacity = MAX_QUEUED_CHANGES,
703            "the change queue is still full; discarding the oldest undelivered failure"
704        ),
705    }
706}
707
708impl Shared {
709    pub(crate) fn new(initial: ProxyConfig) -> Self {
710        let mut queue = VecDeque::new();
711        // The current value is delivered once, at subscription time — stamped with the
712        // construction-time health, so a watcher that started degraded says so in its
713        // very first event.
714        queue.push_back(Queued::Snapshot);
715        Self {
716            state: Mutex::new(State {
717                current: initial,
718                queue,
719                dropped: 0,
720                waker: None,
721                closed: false,
722                dirty: false,
723                runtime_degraded: Vec::new(),
724                runtime_no_live_notifications: false,
725            }),
726            construction: OnceLock::new(),
727        }
728    }
729
730    // Record what the backend established, once `spawn` has returned `Ok`.
731    pub(crate) fn set_construction_health(&self, health: WatchHealth) {
732        let already_set = self.construction.set(health).is_err();
733        debug_assert!(!already_set, "the constructor writes this exactly once");
734    }
735
736    // The construction-time half, or — only before the constructor has returned, which is
737    // the one window in which it is unset — a health that claims nothing: no route has
738    // been established yet, which is the safe direction to be wrong in.
739    fn construction(&self) -> &WatchHealth {
740        static NOT_ESTABLISHED: WatchHealth = WatchHealth {
741            degraded: Vec::new(),
742            has_live_notifications: false,
743            poll_interval: None,
744            stopped: false,
745        };
746        self.construction.get().unwrap_or(&NOT_ESTABLISHED)
747    }
748
749    // Record that `source`'s notification route stopped delivering after construction.
750    #[cfg_attr(
751        not(any(windows, all(target_os = "linux", feature = "linux-kde"))),
752        allow(dead_code)
753    )]
754    pub(crate) fn degrade(&self, source: ProxyConfigSource) {
755        let mut state = self.lock();
756        if state.runtime_degraded.contains(&source) {
757            return;
758        }
759        state.runtime_degraded.push(source);
760        // A degrade queues nothing, and the re-read that follows it may well be equal and
761        // skipped. Waking on the transition is what keeps the loss from being silent.
762        state.dirty = true;
763        let waker = state.waker.take();
764        drop(state);
765        if let Some(waker) = waker {
766            waker.wake();
767        }
768    }
769
770    // Record that no native notification route is live any more.
771    pub(crate) fn mark_no_live_notifications(&self) {
772        let mut state = self.lock();
773        if state.runtime_no_live_notifications {
774            return;
775        }
776        state.runtime_no_live_notifications = true;
777        state.dirty = true;
778        let waker = state.waker.take();
779        drop(state);
780        if let Some(waker) = waker {
781            waker.wake();
782        }
783    }
784
785    // Runtime additions folded into construction-time [`WatchHealth`].
786    //
787    // `closed` reaches a live [`ProxyWatcher`] only from [`ThreadGuard::drop`], i.e. the
788    // thread stopped on its own; the ordinary `Drop` path closes a watcher nobody can
789    // still ask.
790    pub(crate) fn health(&self) -> WatchHealth {
791        let state = self.lock();
792        self.merged_health(&state)
793    }
794
795    // Current config and health from one lock acquisition. This is the atomic
796    // observation boundary exposed by [`ProxyWatcher::state`] and stamped on every
797    // [`WatchEvent`].
798    pub(crate) fn state(&self) -> WatchState {
799        let state = self.lock();
800        self.stamp(&state)
801    }
802
803    fn merged_health(&self, state: &State) -> WatchHealth {
804        merge_runtime_health(
805            self.construction(),
806            state.runtime_degraded.clone(),
807            state.runtime_no_live_notifications,
808            state.closed,
809        )
810    }
811
812    fn stamp(&self, state: &State) -> WatchState {
813        WatchState {
814            config: state.current.clone(),
815            health: self.merged_health(state),
816        }
817    }
818
819    pub(crate) fn current(&self) -> ProxyConfig {
820        self.lock().current.clone()
821    }
822
823    // Publish `config` unless it is equal to the previous one (the equality skip).
824    //
825    // Of the publishing methods, only this one has a platform backend as its sole caller,
826    // so on a target that has none it is legitimately dead. `fail` and `close` are not,
827    // and neither is [`Error::io`]: [`ThreadGuard`]'s `Drop` calls them — that is the
828    // whole point of the guard — and a `Drop` impl is live on every target.
829    // This attribute is also the lint root that keeps `trim_to_capacity` and
830    // `report_discarded` from needing an attribute apiece, since
831    // `allow` marks the item it is written on live along with everything it reaches.
832    // `#[expect]` rather than `#[allow]` so a build for such a target says so if any of
833    // that stops holding, instead of silently keeping an attribute nobody needs.
834    //
835    // `not(test)` because the platform backend is only the *non-test* sole caller: the
836    // tests below drive `emit` directly, and they are compiled on every target. Without
837    // it, `cargo clippy --target <unsupported> --all-targets` builds the lib test, finds
838    // `emit` live, and reports the expectation itself as unfulfilled — which `-D warnings`
839    // turns into a hard error on a target this crate does not even claim to support.
840    #[cfg_attr(
841        all(not(any(windows, target_os = "macos", target_os = "linux")), not(test)),
842        expect(dead_code)
843    )]
844    pub(crate) fn emit(&self, config: ProxyConfig) {
845        let mut state = self.lock();
846        if state.current == config {
847            // Log after unlock: subscriber code must not run under this mutex.
848            drop(state);
849            crate::trace::debug!(
850                config = %crate::trace::ConfigSummary(&config),
851                "an unchanged snapshot was skipped (equality skip)"
852            );
853            return;
854        }
855        // Rendering the transition asks the subscriber whether `INFO` is enabled, and a level
856        // filter is consumer code exactly like the event hook above — so it waits for the
857        // unlock too, and the previous value is kept here to make that possible. The clone is
858        // the price, paid only when the configuration actually changed.
859        let previous = std::mem::replace(&mut state.current, config.clone());
860        // Fold: at most one undelivered snapshot, and it is always the newest one. The entry
861        // carries no payload, so the one already queued renders whatever `current` says when
862        // it is taken — and `current` was just replaced above. Moving it to the back is what
863        // keeps failures queued before it ahead of it.
864        //
865        // The front entry is the exception: it is the next thing the consumer takes, and on a
866        // subscription that has not polled yet it is the subscription-time snapshot every
867        // consumer's first item is documented to be (`README.md`, `examples/watch.rs`, the
868        // type doc above). Moving it puts a failure that arrived after subscription in front
869        // of it, so the first thing the stream ever says is an error on a configuration it
870        // has not once reported.
871        let pending = state
872            .queue
873            .iter()
874            .position(|i| matches!(i, Queued::Snapshot));
875        let folded = match pending {
876            Some(0) => true,
877            Some(at) => {
878                state.queue.remove(at);
879                state.queue.push_back(Queued::Snapshot);
880                true
881            }
882            None => {
883                state.queue.push_back(Queued::Snapshot);
884                false
885            }
886        };
887        // The snapshot about to be delivered carries the health as it will stand then,
888        // so it answers whatever transition `dirty` was holding.
889        state.dirty = false;
890        let discarded = state.trim_to_capacity();
891        let waker = state.waker.take();
892        drop(state);
893        crate::trace::changed(&previous, &config);
894        if folded {
895            crate::trace::debug!(
896                "a newer snapshot replaced one the subscriber had not taken yet; \
897                 what it will read is the current configuration, not the intermediate one"
898            );
899        }
900        report_discarded(discarded);
901        if let Some(waker) = waker {
902            waker.wake();
903        }
904    }
905
906    pub(crate) fn fail(&self, error: Error) {
907        // Log before lock: `error` moves into the queue; subscriber must not run under mutex.
908        crate::trace::warning!(
909            error = %crate::trace::SafeError(&error),
910            "reading the proxy configuration failed; the subscription stays open"
911        );
912        let mut state = self.lock();
913        // Non-empty queue ⇒ no waker (`poll_next` only stores one while empty).
914        if !state.queue.is_empty() {
915            debug_assert!(
916                state.waker.is_none(),
917                "a waker must not be registered while the queue is non-empty"
918            );
919        }
920        if let Some(Queued::Error(last)) = state.queue.back_mut() {
921            *last = error;
922            return;
923        }
924        state.queue.push_back(Queued::Error(error));
925        let waker = state.waker.take();
926        drop(state);
927        if let Some(waker) = waker {
928            waker.wake();
929        }
930    }
931
932    // Idempotent: only the first call ends the stream, and only it owes a snapshot.
933    pub(crate) fn close(&self) {
934        let mut state = self.lock();
935        if state.closed {
936            return;
937        }
938        state.closed = true;
939        // `stopped` is a health transition like any other, so the consumer is owed one
940        // last snapshot saying so before the stream ends. That holds for a panic, a fatal
941        // return and an ordinary finish alike, without any of them being contracted
942        // separately.
943        state.dirty = true;
944        let waker = state.waker.take();
945        drop(state);
946        crate::trace::debug!("the watcher thread finished; the stream will end");
947        if let Some(waker) = waker {
948            waker.wake();
949        }
950    }
951
952    fn poll_next(&self, cx: &mut Context<'_>) -> Poll<Option<WatchEvent>> {
953        // Cloned before the lock is taken, for the same reason `emit` logs after releasing
954        // it: executor code must not run under this mutex. It cannot wait until the park is
955        // decided, because unlocking to take the clone would open a gap in which an `emit`
956        // could queue an event and find no waker to wake. The ready paths below pay for one
957        // unused clone, and drop it after `state` — locals unwind in reverse declaration
958        // order, and `state` is declared second.
959        let waker = cx.waker().clone();
960        let mut state = self.lock();
961        if let Some(item) = state.queue.pop_front() {
962            if state.queue.is_empty() {
963                // The consumer has caught up: the next overflow is a new episode and
964                // gets its own `WARN` (see `State::dropped`).
965                state.dropped = 0;
966            }
967            let event = match item {
968                Queued::Snapshot => {
969                    // Stamped now, not when it was queued, so it reports whatever the
970                    // health has become since — which is what `dirty` was for.
971                    state.dirty = false;
972                    WatchEvent::Snapshot {
973                        state: self.stamp(&state),
974                    }
975                }
976                // A failed read says nothing about the routes, so it leaves `dirty`
977                // standing and the health transition still gets its own snapshot.
978                Queued::Error(error) => WatchEvent::Error {
979                    error,
980                    state: self.stamp(&state),
981                },
982            };
983            return Poll::Ready(Some(event));
984        }
985        // Before the `closed` check: closing is itself the last health transition, and
986        // an unreported one must not be swallowed by the end of the stream.
987        if state.dirty {
988            state.dirty = false;
989            return Poll::Ready(Some(WatchEvent::Snapshot {
990                state: self.stamp(&state),
991            }));
992        }
993        if state.closed {
994            return Poll::Ready(None);
995        }
996        let previous = state.waker.replace(waker);
997        drop(state);
998        // Not dropped by `replace` under the lock: `Waker::from(Arc<W>)` makes this `W`'s own
999        // `Drop`, so it is ordinary consumer code, and one that reads back from the watcher
1000        // would block on a lock this very thread holds.
1001        drop(previous);
1002        Poll::Pending
1003    }
1004
1005    // Recover from a poisoned mutex instead of propagating the panic.
1006    fn lock(&self) -> std::sync::MutexGuard<'_, State> {
1007        self.state.lock().unwrap_or_else(|e| e.into_inner())
1008    }
1009}
1010
1011// Ensures [`Shared::close`] runs even if the backend thread panics.
1012pub(crate) struct ThreadGuard {
1013    shared: Arc<Shared>,
1014}
1015
1016// `new` is what a target with no backend never calls; the struct is not, because `allow`
1017// makes the item it is written on a lint root and this one names `Self`. That is also why
1018// the attribute has to sit here rather than on the struct — it does not reach out of the
1019// item it is written on, and the struct is a separate item.
1020#[cfg_attr(
1021    not(any(windows, target_os = "macos", target_os = "linux")),
1022    allow(dead_code)
1023)]
1024impl ThreadGuard {
1025    pub(crate) fn new(shared: Arc<Shared>) -> Self {
1026        Self { shared }
1027    }
1028}
1029
1030impl Drop for ThreadGuard {
1031    fn drop(&mut self) {
1032        if std::thread::panicking() {
1033            crate::trace::error!("the proxy-watch backend thread panicked; closing the stream");
1034            // Before the failure, not after: `fail` wakes the consumer, and a consumer that
1035            // reads the health on being woken must not still be told a route is live.
1036            self.shared.mark_no_live_notifications();
1037            self.shared.fail(Error::io(
1038                "running the proxy-watch backend thread",
1039                std::io::Error::other("the watcher thread panicked"),
1040            ));
1041        }
1042        self.shared.close();
1043    }
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048    use super::*;
1049    use std::sync::atomic::{AtomicBool, Ordering};
1050    use std::task::Wake;
1051
1052    // `Send` so a spawned task can hold the watcher; `Sync` is deliberately *not*
1053    // required — a single consumer owns the stream.
1054    const _: fn() = || {
1055        fn assert_send<T: Send>() {}
1056        assert_send::<ProxyWatcher>();
1057        assert_send::<WatchOptions>();
1058    };
1059
1060    // Type-checked only, never run: `poll_now` takes `&self` and returns nothing, so nothing
1061    // here can tell a working one apart from a no-op. That needs a change the native
1062    // notification does not already deliver by itself, and only one test arranges one —
1063    // `tests/linux_watch.rs`'s `poll_now_re_reads_a_change_no_watch_can_see`, which writes
1064    // through a symlink pointing out of the watched directory. Windows and macOS have no
1065    // equivalent.
1066    const _: fn(&ProxyWatcher) = |watcher| watcher.poll_now();
1067    const _: fn(&ProxyWatcher) -> WatchState = ProxyWatcher::state;
1068
1069    #[test]
1070    fn the_initial_value_is_queued_once_and_equality_skips_repeats() {
1071        let initial = ProxyConfig::direct();
1072        let shared = Shared::new(initial.clone());
1073
1074        // Exactly one queued item at subscription time.
1075        assert_eq!(shared.lock().queue.len(), 1);
1076
1077        // An identical snapshot does not even reach the queue; a different one replaces
1078        // the entry that is already there rather than adding to it.
1079        shared.emit(ProxyConfig::direct());
1080        assert_eq!(shared.lock().queue.len(), 1);
1081        assert!(!shared.lock().dirty, "an equal read changes nothing");
1082        shared.emit(ProxyConfig::from_source(
1083            crate::config::ProxyConfigSource::Registry,
1084            crate::mode::ProxyMode::WpadAutoDetect,
1085        ));
1086        assert_eq!(shared.lock().queue.len(), 1);
1087        assert_eq!(
1088            shared.current().effective,
1089            crate::mode::ProxyMode::WpadAutoDetect
1090        );
1091    }
1092
1093    // Why [`ProxyConfig::fallbacks`] is compared by `PartialEq` rather than left beside
1094    // `captured_at`. Two reads can agree on every mode and still not be the same answer,
1095    // because one of them was assembled without a source it could not read. The skip above
1096    // does not merely stay quiet about that — it *keeps* `current`, so the second half of
1097    // this test is the one that matters: with the field excluded, a degradation that healed
1098    // would go on being reported for the rest of the watcher's life.
1099    #[test]
1100    fn a_snapshot_that_differs_only_in_fallbacks_is_published_rather_than_skipped() {
1101        let complete = ProxyConfig::from_source(
1102            crate::config::ProxyConfigSource::Registry,
1103            crate::mode::ProxyMode::Direct,
1104        );
1105        let degraded = complete
1106            .clone()
1107            .with_fallbacks(vec![crate::config::ProxyConfigSource::GroupPolicy]);
1108        let shared = Shared::new(complete.clone());
1109
1110        shared.emit(degraded);
1111        assert_eq!(
1112            shared.current().fallbacks,
1113            [crate::config::ProxyConfigSource::GroupPolicy],
1114            "a read that lost a source is not the snapshot that never had one"
1115        );
1116
1117        shared.emit(complete);
1118        assert!(
1119            shared.current().fallbacks.is_empty(),
1120            "and the recovery is a change too"
1121        );
1122    }
1123
1124    // Every consumer's first item is documented to be the subscription-time snapshot —
1125    // `README.md`, `examples/watch.rs`, and this type's own doc all say so, and
1126    // `tests/mac_watch.rs` asserts it against a live backend. A read that fails between
1127    // construction and the first poll queues an `Error` behind that snapshot, and the fold
1128    // in `emit` must not lift the snapshot out of the queue and push it back on — that lands
1129    // it *behind* the failure, and the first item off the stream is then the error, on a
1130    // subscription that has not yet once said what the configuration was.
1131    #[test]
1132    fn a_failure_before_the_first_poll_stays_behind_the_subscription_snapshot() {
1133        let shared = Shared::new(ProxyConfig::direct());
1134        shared.fail(Error::io(
1135            "reading the proxy configuration",
1136            std::io::Error::other("a read that failed before the first poll"),
1137        ));
1138        shared.emit(ProxyConfig::from_source(
1139            ProxyConfigSource::Registry,
1140            crate::mode::ProxyMode::WpadAutoDetect,
1141        ));
1142
1143        // Read it the way a consumer does: the configuration first, the failure after.
1144        let mut cx = Context::from_waker(Waker::noop());
1145        match shared.poll_next(&mut cx) {
1146            Poll::Ready(Some(WatchEvent::Snapshot { state })) => {
1147                assert_eq!(
1148                    state.config.effective,
1149                    crate::mode::ProxyMode::WpadAutoDetect
1150                );
1151            }
1152            other => panic!("the first item must be the subscription snapshot, got {other:?}"),
1153        }
1154        assert!(matches!(
1155            shared.poll_next(&mut cx),
1156            Poll::Ready(Some(WatchEvent::Error { .. }))
1157        ));
1158        assert!(shared.poll_next(&mut cx).is_pending());
1159    }
1160
1161    // The construction-time half a live backend would have written, so that the runtime
1162    // half has something to override rather than agreeing with the unset default.
1163    fn live_construction_health() -> WatchHealth {
1164        WatchHealth {
1165            degraded: Vec::new(),
1166            has_live_notifications: true,
1167            poll_interval: None,
1168            stopped: false,
1169        }
1170    }
1171
1172    #[test]
1173    fn one_shared_state_read_contains_config_and_runtime_health() {
1174        let shared = Shared::new(ProxyConfig::direct());
1175        shared.set_construction_health(live_construction_health());
1176        shared.emit(ProxyConfig::from_source(
1177            ProxyConfigSource::Registry,
1178            crate::mode::ProxyMode::WpadAutoDetect,
1179        ));
1180        shared.degrade(ProxyConfigSource::Registry);
1181        shared.mark_no_live_notifications();
1182        shared.close();
1183
1184        let state = shared.state();
1185        assert_eq!(
1186            state.config.effective,
1187            crate::mode::ProxyMode::WpadAutoDetect
1188        );
1189        assert_eq!(state.health.degraded, vec![ProxyConfigSource::Registry]);
1190        assert!(!state.health.has_live_notifications);
1191        assert!(state.health.stopped);
1192    }
1193
1194    // Nothing can reach `Shared` through a `ProxyWatcher` before the constructor writes
1195    // the construction-time health, so the unset answer is unobservable in practice. It
1196    // still has to be the conservative one rather than a reassuring default.
1197    #[test]
1198    fn an_unwritten_construction_health_claims_no_route() {
1199        let shared = Shared::new(ProxyConfig::direct());
1200        let health = shared.health();
1201        assert!(!health.has_live_notifications);
1202        assert!(health.degraded.is_empty());
1203        assert_eq!(health.poll_interval, None);
1204        assert!(!health.stopped);
1205        assert!(!health.is_fully_live());
1206    }
1207
1208    // Alternating between these clears the equality skip on every `emit`.
1209    fn two_distinct_configs() -> (ProxyConfig, ProxyConfig) {
1210        (
1211            ProxyConfig::direct(),
1212            ProxyConfig::from_source(
1213                crate::config::ProxyConfigSource::Registry,
1214                crate::mode::ProxyMode::WpadAutoDetect,
1215            ),
1216        )
1217    }
1218
1219    // Alternate a change and a failure so the queue actually grows: on its own, a run of
1220    // changes folds into one entry and a run of failures coalesces into one.
1221    //
1222    // Take the subscription snapshot first, and fail once with the queue empty. `emit`
1223    // leaves a snapshot alone while it is the front entry — that entry is the next thing
1224    // the consumer takes — so what grows the queue is a stall that begins with an
1225    // undelivered failure in front, not one that begins before the first poll.
1226    fn fill_past_capacity(shared: &Shared, rounds: usize) {
1227        let (direct, wpad) = two_distinct_configs();
1228        let mut cx = Context::from_waker(Waker::noop());
1229        assert!(shared.poll_next(&mut cx).is_ready());
1230        shared.fail(sandboxed_error("filler"));
1231        for index in 0..rounds {
1232            shared.emit(if index % 2 == 0 {
1233                wpad.clone()
1234            } else {
1235                direct.clone()
1236            });
1237            shared.fail(sandboxed_error("filler"));
1238        }
1239    }
1240
1241    // A stalled consumer must not turn an unbounded number of failures into unbounded
1242    // memory: [`MAX_QUEUED_CHANGES`] is the ceiling. The `+ 1` is not slack — `emit` is
1243    // where `trim_to_capacity` runs, so a `fail` that lands on a just-trimmed queue sits
1244    // one past the ceiling until the next snapshot. `ProxyWatcher`'s stream doc says so.
1245    #[test]
1246    fn a_stalled_consumer_cannot_grow_the_queue_without_bound() {
1247        let shared = Shared::new(ProxyConfig::direct());
1248        fill_past_capacity(&shared, MAX_QUEUED_CHANGES * 2);
1249
1250        let state = shared.lock();
1251        assert!(
1252            state.queue.len() <= MAX_QUEUED_CHANGES + 1,
1253            "queue grew to {}",
1254            state.queue.len()
1255        );
1256        assert!(state.dropped > 0, "the cap must have discarded something");
1257    }
1258
1259    // Snapshots do not queue up behind a stalled consumer at all: the newest replaces the
1260    // one it has not taken yet, so what it eventually reads is the current answer and not
1261    // a backlog of history.
1262    #[test]
1263    fn undelivered_snapshots_fold_into_the_newest_one() {
1264        let (direct, wpad) = two_distinct_configs();
1265        let shared = Shared::new(direct.clone());
1266        let mut cx = Context::from_waker(Waker::noop());
1267
1268        for index in 0..64 {
1269            shared.emit(if index % 2 == 0 {
1270                wpad.clone()
1271            } else {
1272                direct.clone()
1273            });
1274        }
1275
1276        // One entry, not 65: the initial snapshot and every change folded together.
1277        assert_eq!(shared.lock().queue.len(), 1);
1278        match shared.poll_next(&mut cx) {
1279            Poll::Ready(Some(WatchEvent::Snapshot { state })) => {
1280                assert_eq!(state.config, shared.current());
1281                assert_eq!(state.config, direct);
1282            }
1283            other => panic!("expected the newest snapshot, got {other:?}"),
1284        }
1285        assert!(shared.poll_next(&mut cx).is_pending());
1286    }
1287
1288    // The counter behind the `WARN`-once rule tracks *episodes*: draining resets it, so
1289    // a later overflow is announced again instead of being swallowed by the first one.
1290    #[test]
1291    fn draining_the_queue_resets_the_discard_counter() {
1292        let shared = Shared::new(ProxyConfig::direct());
1293        let mut cx = Context::from_waker(Waker::noop());
1294
1295        fill_past_capacity(&shared, MAX_QUEUED_CHANGES + 5);
1296        assert!(shared.lock().dropped > 0);
1297
1298        // Drain everything; the reset happens on the poll that empties the queue.
1299        while shared.poll_next(&mut cx).is_ready() {
1300            if shared.lock().queue.is_empty() {
1301                break;
1302            }
1303        }
1304        assert_eq!(shared.lock().dropped, 0);
1305        assert_eq!(shared.lock().queue.len(), 0);
1306    }
1307
1308    // The regression this whole item type exists for. A route dies, the re-read that
1309    // follows returns the *same* configuration, and the equality skip therefore publishes
1310    // nothing. Before delivery-time stamping the subscriber was never told: only someone
1311    // pulling `health()` could find out. A degrade must reach the stream on its own.
1312    #[test]
1313    fn a_degrade_reaches_the_stream_even_when_every_later_read_is_equal() {
1314        let shared = Shared::new(ProxyConfig::direct());
1315        shared.set_construction_health(live_construction_health());
1316        let mut cx = Context::from_waker(Waker::noop());
1317
1318        // Take the subscription-time snapshot; the stream is now quiet.
1319        assert!(matches!(
1320            shared.poll_next(&mut cx),
1321            Poll::Ready(Some(WatchEvent::Snapshot { .. }))
1322        ));
1323        assert!(shared.poll_next(&mut cx).is_pending());
1324
1325        shared.degrade(ProxyConfigSource::Registry);
1326        // The re-read the backend does next finds nothing new, so it publishes nothing.
1327        shared.emit(ProxyConfig::direct());
1328
1329        match shared.poll_next(&mut cx) {
1330            Poll::Ready(Some(WatchEvent::Snapshot { state })) => {
1331                assert_eq!(state.health.degraded, vec![ProxyConfigSource::Registry]);
1332                assert_eq!(state.config, ProxyConfig::direct());
1333            }
1334            other => panic!("the lost route must reach the stream, got {other:?}"),
1335        }
1336        // Once told, the subscriber is not told again.
1337        assert!(shared.poll_next(&mut cx).is_pending());
1338        // Nor when the same route degrades a second time. A backend retrying an arm it
1339        // cannot get back calls this on every attempt, and `merge_runtime_health`'s dedupe
1340        // only keeps the *health* from growing; the wake and the synthesised snapshot are
1341        // this guard's, and without it one loss repeats for as long as the retries do.
1342        shared.degrade(ProxyConfigSource::Registry);
1343        assert!(shared.poll_next(&mut cx).is_pending());
1344    }
1345
1346    // A parked consumer has to be *woken* for the synthesised snapshot to be of any use:
1347    // a level-triggered stream nobody polls again is still silent.
1348    #[test]
1349    fn losing_the_last_route_wakes_a_parked_consumer() {
1350        struct Signal(AtomicBool);
1351        impl Wake for Signal {
1352            fn wake(self: Arc<Self>) {
1353                self.wake_by_ref();
1354            }
1355            fn wake_by_ref(self: &Arc<Self>) {
1356                self.0.store(true, Ordering::SeqCst);
1357            }
1358        }
1359
1360        let shared = Shared::new(ProxyConfig::direct());
1361        shared.set_construction_health(live_construction_health());
1362        let signal = Arc::new(Signal(AtomicBool::new(false)));
1363        let waker = Waker::from(Arc::clone(&signal));
1364        let mut cx = Context::from_waker(&waker);
1365
1366        assert!(shared.poll_next(&mut cx).is_ready());
1367        assert!(shared.poll_next(&mut cx).is_pending());
1368        assert!(!signal.0.load(Ordering::SeqCst));
1369
1370        shared.mark_no_live_notifications();
1371        assert!(
1372            signal.0.load(Ordering::SeqCst),
1373            "losing the last route must wake the parked consumer"
1374        );
1375        match shared.poll_next(&mut cx) {
1376            Poll::Ready(Some(WatchEvent::Snapshot { state })) => {
1377                assert!(!state.health.has_live_notifications);
1378            }
1379            other => panic!("expected a snapshot reporting the loss, got {other:?}"),
1380        }
1381        // The flag never clears, so a second call has nothing left to say. A backend that
1382        // finds the route still gone on every retry calls this each time; waking again
1383        // would report a loss the subscriber has already been told about.
1384        assert!(shared.poll_next(&mut cx).is_pending());
1385        signal.0.store(false, Ordering::SeqCst);
1386        shared.mark_no_live_notifications();
1387        assert!(!signal.0.load(Ordering::SeqCst));
1388        assert!(shared.poll_next(&mut cx).is_pending());
1389    }
1390
1391    // The one waker slot, from the losing side. [`ProxyWatcher`]'s doc says a second poll
1392    // overwrites the first task's waker and leaves it parked; that is the failure mode a
1393    // caller who puts the watcher behind a lock and polls it from two tasks actually hits,
1394    // so it is worth pinning rather than leaving as prose.
1395    #[test]
1396    fn a_second_poll_takes_the_waker_slot_from_the_first_consumer() {
1397        struct Signal(AtomicBool);
1398        impl Wake for Signal {
1399            fn wake(self: Arc<Self>) {
1400                self.wake_by_ref();
1401            }
1402            fn wake_by_ref(self: &Arc<Self>) {
1403                self.0.store(true, Ordering::SeqCst);
1404            }
1405        }
1406
1407        let shared = Shared::new(ProxyConfig::direct());
1408        shared.set_construction_health(live_construction_health());
1409        let first = Arc::new(Signal(AtomicBool::new(false)));
1410        let second = Arc::new(Signal(AtomicBool::new(false)));
1411        let first_waker = Waker::from(Arc::clone(&first));
1412        let second_waker = Waker::from(Arc::clone(&second));
1413
1414        // The construction snapshot, then the first consumer parks.
1415        assert!(
1416            shared
1417                .poll_next(&mut Context::from_waker(&first_waker))
1418                .is_ready()
1419        );
1420        assert!(
1421            shared
1422                .poll_next(&mut Context::from_waker(&first_waker))
1423                .is_pending()
1424        );
1425        // A second consumer polls the same watcher and takes the slot.
1426        assert!(
1427            shared
1428                .poll_next(&mut Context::from_waker(&second_waker))
1429                .is_pending()
1430        );
1431
1432        shared.mark_no_live_notifications();
1433        assert!(
1434            second.0.load(Ordering::SeqCst),
1435            "the most recently registered waker is the one that is kept"
1436        );
1437        assert!(
1438            !first.0.load(Ordering::SeqCst),
1439            "the first consumer stays parked: nothing on this watcher is left to wake it"
1440        );
1441    }
1442
1443    // Closing is the last health transition there is, so it owes a snapshot before the
1444    // stream ends — otherwise `None` would be the only notice a subscriber ever got that
1445    // the thread had stopped, and a subscriber reading health would never see `stopped`.
1446    #[test]
1447    fn closing_delivers_a_stopped_snapshot_exactly_once_before_the_end() {
1448        let shared = Shared::new(ProxyConfig::direct());
1449        shared.set_construction_health(live_construction_health());
1450        let mut cx = Context::from_waker(Waker::noop());
1451
1452        assert!(shared.poll_next(&mut cx).is_ready());
1453        shared.close();
1454        // Idempotent: a second close owes nothing further.
1455        shared.close();
1456
1457        match shared.poll_next(&mut cx) {
1458            Poll::Ready(Some(WatchEvent::Snapshot { state })) => {
1459                assert!(state.health.stopped);
1460                assert!(state.health.is_frozen());
1461            }
1462            other => panic!("expected a stopped snapshot before the end, got {other:?}"),
1463        }
1464        assert!(matches!(shared.poll_next(&mut cx), Poll::Ready(None)));
1465        assert!(matches!(shared.poll_next(&mut cx), Poll::Ready(None)));
1466    }
1467
1468    // Queued failures come out before the terminal snapshot, and a failure on its own
1469    // never answers a health transition: the `stopped` snapshot still follows it.
1470    #[test]
1471    fn a_failure_does_not_stand_in_for_the_terminal_snapshot() {
1472        let shared = Shared::new(ProxyConfig::direct());
1473        shared.set_construction_health(live_construction_health());
1474        let mut cx = Context::from_waker(Waker::noop());
1475
1476        assert!(shared.poll_next(&mut cx).is_ready());
1477        shared.fail(sandboxed_error("dying"));
1478        shared.close();
1479
1480        assert!(matches!(
1481            shared.poll_next(&mut cx),
1482            Poll::Ready(Some(WatchEvent::Error { .. }))
1483        ));
1484        match shared.poll_next(&mut cx) {
1485            Poll::Ready(Some(WatchEvent::Snapshot { state })) => assert!(state.health.stopped),
1486            other => panic!("expected the terminal snapshot after the failure, got {other:?}"),
1487        }
1488        assert!(matches!(shared.poll_next(&mut cx), Poll::Ready(None)));
1489    }
1490
1491    // Whichever variant arrives, it answers the same question — which is what makes an
1492    // event comparable with a `ProxyWatcher::state()` taken beside it.
1493    #[test]
1494    fn every_event_carries_the_state_it_was_delivered_with() {
1495        let shared = Shared::new(ProxyConfig::direct());
1496        shared.set_construction_health(live_construction_health());
1497        let mut cx = Context::from_waker(Waker::noop());
1498
1499        let Poll::Ready(Some(snapshot)) = shared.poll_next(&mut cx) else {
1500            panic!("the subscription-time snapshot is always ready");
1501        };
1502        assert_eq!(snapshot.state(), &shared.state());
1503
1504        shared.fail(sandboxed_error("only a read failed"));
1505        let Poll::Ready(Some(failure)) = shared.poll_next(&mut cx) else {
1506            panic!("the queued failure is always ready");
1507        };
1508        assert!(matches!(failure, WatchEvent::Error { .. }));
1509        // A failed read is not a lost route: the health it carries still says so.
1510        assert_eq!(failure.state(), &shared.state());
1511        assert!(failure.state().health.has_live_notifications);
1512    }
1513
1514    // A distinguishable error so coalescing tests can tell which queued failure
1515    // survived; [`Error::Unsupported`] carries no payload to tell two instances apart.
1516    fn sandboxed_error(reason: &str) -> Error {
1517        Error::Sandboxed {
1518            sandbox: "Flatpak".to_owned(),
1519            reason: reason.to_owned(),
1520        }
1521    }
1522
1523    // A persistent read failure must not queue one `Err` per tick forever: a run of
1524    // [`Shared::fail`] calls collapses into the single tail entry, newest surviving.
1525    #[test]
1526    fn consecutive_fails_coalesce_into_the_newest_error() {
1527        let shared = Shared::new(ProxyConfig::direct());
1528        // Drain the initial snapshot so only the failure sequence is counted.
1529        assert!(matches!(
1530            shared.lock().queue.pop_front(),
1531            Some(Queued::Snapshot)
1532        ));
1533
1534        shared.fail(sandboxed_error("first"));
1535        assert_eq!(shared.lock().queue.len(), 1);
1536        shared.fail(sandboxed_error("second"));
1537        assert_eq!(shared.lock().queue.len(), 1);
1538        shared.fail(sandboxed_error("third"));
1539        assert_eq!(shared.lock().queue.len(), 1);
1540
1541        let state = shared.lock();
1542        match &state.queue[0] {
1543            Queued::Error(Error::Sandboxed { reason, .. }) => assert_eq!(reason, "third"),
1544            other => panic!("expected the newest coalesced Sandboxed error, got {other:?}"),
1545        }
1546    }
1547
1548    // Coalescing only applies to a run of failures with nothing successful in between:
1549    // a success in the middle must not swallow, or be swallowed by, either failure.
1550    #[test]
1551    fn fail_after_an_intervening_emit_is_not_coalesced() {
1552        let shared = Shared::new(ProxyConfig::direct());
1553        assert!(matches!(
1554            shared.lock().queue.pop_front(),
1555            Some(Queued::Snapshot)
1556        ));
1557
1558        shared.fail(sandboxed_error("before"));
1559        shared.emit(ProxyConfig::from_source(
1560            crate::config::ProxyConfigSource::Registry,
1561            crate::mode::ProxyMode::WpadAutoDetect,
1562        ));
1563        shared.fail(sandboxed_error("after"));
1564
1565        let state = shared.lock();
1566        assert_eq!(state.queue.len(), 3);
1567        match &state.queue[0] {
1568            Queued::Error(Error::Sandboxed { reason, .. }) => assert_eq!(reason, "before"),
1569            other => panic!("expected the first Sandboxed error, got {other:?}"),
1570        }
1571        assert!(matches!(state.queue[1], Queued::Snapshot));
1572        match &state.queue[2] {
1573            Queued::Error(Error::Sandboxed { reason, .. }) => assert_eq!(reason, "after"),
1574            other => panic!("expected the second Sandboxed error, got {other:?}"),
1575        }
1576    }
1577
1578    // A consumer parked on a registered waker must still be woken by the *first* of a
1579    // run of failures, and must then see exactly one `Err` — not zero, not two.
1580    #[test]
1581    fn a_parked_consumer_is_woken_once_and_sees_one_coalesced_error() {
1582        struct Signal(AtomicBool);
1583        impl Wake for Signal {
1584            fn wake(self: Arc<Self>) {
1585                self.wake_by_ref();
1586            }
1587            fn wake_by_ref(self: &Arc<Self>) {
1588                self.0.store(true, Ordering::SeqCst);
1589            }
1590        }
1591
1592        let shared = Shared::new(ProxyConfig::direct());
1593        assert!(matches!(
1594            shared.lock().queue.pop_front(),
1595            Some(Queued::Snapshot)
1596        ));
1597
1598        let signal = Arc::new(Signal(AtomicBool::new(false)));
1599        let waker = Waker::from(Arc::clone(&signal));
1600        let mut cx = Context::from_waker(&waker);
1601
1602        // Queue empty, stream open: registers the waker and returns Pending.
1603        assert!(shared.poll_next(&mut cx).is_pending());
1604        assert!(!signal.0.load(Ordering::SeqCst));
1605
1606        shared.fail(sandboxed_error("first"));
1607        assert!(
1608            signal.0.load(Ordering::SeqCst),
1609            "the first failure must wake the parked consumer"
1610        );
1611        // A second failure while the first is undrained coalesces in place rather than
1612        // needing a second wake.
1613        shared.fail(sandboxed_error("second"));
1614
1615        match shared.poll_next(&mut cx) {
1616            Poll::Ready(Some(WatchEvent::Error {
1617                error: Error::Sandboxed { reason, .. },
1618                ..
1619            })) => {
1620                assert_eq!(reason, "second");
1621            }
1622            other => panic!("expected exactly one coalesced Sandboxed error, got {other:?}"),
1623        }
1624        assert!(shared.lock().queue.is_empty());
1625    }
1626
1627    // The clamp tests below are written in terms of `MIN_POLL_INTERVAL` and `MAX_DEBOUNCE`
1628    // rather than the numbers they hold, which is what lets them describe the shape of the
1629    // clamp without repeating a literal in six places. It also means both bounds can move
1630    // and the expectations move with them. The numbers are in the rendered documentation —
1631    // `WatchOptions::poll_interval` says "anything under 200 ms is raised to it" and
1632    // `WatchOptions::debounce` says "anything over 24 h is lowered to it" — so moving one
1633    // makes the doc wrong without making any test red: `xtask/tests/claim_counts.rs` counts
1634    // nouns, not durations.
1635    //
1636    // `WatchOptions::new()`'s own defaults are here for the same reason and one more:
1637    // `poll_interval` is not a tuning knob but a fork. Unset, a primary notification route
1638    // that will not arm is fatal and `with_options` refuses to build a watcher; set, that
1639    // same failure only reaches `WatchHealth::degraded` on a watcher that starts. A default
1640    // of `Some` would turn every such refusal into a silent degradation, and `true` for
1641    // `watch_group_policy` is what makes a machine-wide setting read at all.
1642    #[test]
1643    fn the_documented_defaults_and_bounds_are_the_stated_numbers() {
1644        let options = WatchOptions::new();
1645        assert_eq!(options.debounce, DEFAULT_DEBOUNCE);
1646        assert!(options.watch_group_policy);
1647        assert_eq!(options.poll_interval, None);
1648
1649        assert_eq!(DEFAULT_DEBOUNCE, Duration::from_millis(200));
1650        assert_eq!(MIN_POLL_INTERVAL, Duration::from_millis(200));
1651        assert_eq!(MAX_DEBOUNCE, Duration::from_secs(24 * 60 * 60));
1652    }
1653
1654    // The floor: zero, below, exactly at, and above `MIN_POLL_INTERVAL`.
1655    #[test]
1656    fn effective_poll_interval_clamps_to_the_floor() {
1657        let cases = [
1658            // zero
1659            (Duration::ZERO, MIN_POLL_INTERVAL),
1660            // below the floor
1661            (Duration::from_millis(1), MIN_POLL_INTERVAL),
1662            (
1663                MIN_POLL_INTERVAL - Duration::from_millis(1),
1664                MIN_POLL_INTERVAL,
1665            ),
1666            // exactly at the floor
1667            (MIN_POLL_INTERVAL, MIN_POLL_INTERVAL),
1668            // above the floor
1669            (
1670                MIN_POLL_INTERVAL + Duration::from_secs(1),
1671                MIN_POLL_INTERVAL + Duration::from_secs(1),
1672            ),
1673            (Duration::from_secs(30), Duration::from_secs(30)),
1674        ];
1675        for (requested, expected) in cases {
1676            assert_eq!(
1677                effective_poll_interval(requested),
1678                expected,
1679                "requested {requested:?}"
1680            );
1681        }
1682    }
1683
1684    // The ceiling: zero, the default, just below, exactly at, and above `MAX_DEBOUNCE`.
1685    #[test]
1686    fn effective_debounce_clamps_to_the_ceiling() {
1687        let cases = [
1688            // below the ceiling
1689            (Duration::ZERO, Duration::ZERO),
1690            (DEFAULT_DEBOUNCE, DEFAULT_DEBOUNCE),
1691            (
1692                MAX_DEBOUNCE - Duration::from_millis(1),
1693                MAX_DEBOUNCE - Duration::from_millis(1),
1694            ),
1695            // exactly at the ceiling
1696            (MAX_DEBOUNCE, MAX_DEBOUNCE),
1697            // above the ceiling
1698            (MAX_DEBOUNCE + Duration::from_millis(1), MAX_DEBOUNCE),
1699            (Duration::MAX, MAX_DEBOUNCE),
1700        ];
1701        for (requested, expected) in cases {
1702            assert_eq!(
1703                effective_debounce(requested),
1704                expected,
1705                "requested {requested:?}"
1706            );
1707        }
1708    }
1709
1710    // What the ceiling is for. Unlike [`MIN_POLL_INTERVAL`], which keeps a backend from
1711    // spinning, this one keeps it from *panicking*: every backend opens its window as
1712    // `Instant::now() + debounce`, and that addition panics rather than saturating, which
1713    // would leave the watcher thread stopped and the subscription frozen.
1714    #[test]
1715    fn a_clamped_debounce_keeps_the_deadline_representable() {
1716        for requested in [Duration::MAX, MAX_DEBOUNCE + Duration::from_secs(1)] {
1717            let options = WatchOptions::new().with_debounce(requested);
1718            assert!(
1719                std::time::Instant::now()
1720                    .checked_add(effective_debounce(options.debounce))
1721                    .is_some(),
1722                "requested {requested:?}"
1723            );
1724        }
1725    }
1726
1727    #[test]
1728    fn watch_health_live_and_frozen_flags() {
1729        let cases = [
1730            // Healthy: live native route, nothing degraded.
1731            (Vec::new(), true, None, false, true, false),
1732            // Degraded secondary source: not fully live, not frozen.
1733            (
1734                vec![ProxyConfigSource::GroupPolicy],
1735                true,
1736                None,
1737                false,
1738                false,
1739                false,
1740            ),
1741            // No native route and no poll: frozen snapshot only.
1742            (Vec::new(), false, None, false, false, true),
1743            // Poll interval rescues a watcher with no native notifications.
1744            (
1745                Vec::new(),
1746                false,
1747                Some(Duration::from_secs(30)),
1748                false,
1749                false,
1750                false,
1751            ),
1752            // ...but only while there is a thread left to do the polling.
1753            (
1754                Vec::new(),
1755                false,
1756                Some(Duration::from_secs(30)),
1757                true,
1758                false,
1759                true,
1760            ),
1761            // A stopped thread is not fully live either, however healthy the routes were
1762            // while it ran. The Windows backend reaches exactly this state: a fatal `run`
1763            // error closes the stream without ever marking the routes lost, so
1764            // `has_live_notifications` is still the `true` it was armed with.
1765            (Vec::new(), true, None, true, false, true),
1766        ];
1767        for (degraded, has_live, poll_interval, stopped, fully_live, frozen) in cases {
1768            let health = WatchHealth {
1769                degraded,
1770                has_live_notifications: has_live,
1771                poll_interval,
1772                stopped,
1773            };
1774            assert_eq!(health.is_fully_live(), fully_live, "{health:?}");
1775            assert_eq!(health.is_frozen(), frozen, "{health:?}");
1776        }
1777    }
1778
1779    #[test]
1780    fn a_panicking_watcher_thread_still_closes_the_stream() {
1781        let shared = Arc::new(Shared::new(ProxyConfig::direct()));
1782
1783        // The panic below is expected; suppress the default hook so the test output does
1784        // not print a spurious backtrace.
1785        let previous_hook = std::panic::take_hook();
1786        std::panic::set_hook(Box::new(|_| {}));
1787        let result = {
1788            let shared = Arc::clone(&shared);
1789            std::thread::spawn(move || {
1790                let _guard = ThreadGuard::new(Arc::clone(&shared));
1791                panic!("simulated backend panic");
1792            })
1793            .join()
1794        };
1795        std::panic::set_hook(previous_hook);
1796
1797        assert!(result.is_err());
1798
1799        let state = shared.lock();
1800        // The initial snapshot, then exactly one failure pushed by the panicking guard.
1801        assert_eq!(state.queue.len(), 2);
1802        assert!(matches!(state.queue[0], Queued::Snapshot));
1803        assert!(matches!(state.queue[1], Queued::Error(_)));
1804        assert!(state.closed);
1805        // `degraded` stays empty because a dead thread has no single route to blame.
1806        assert!(state.runtime_no_live_notifications);
1807        assert!(state.runtime_degraded.is_empty());
1808    }
1809
1810    // The sibling above panics with no lock held, so the mutex it leaves behind is clean.
1811    // This one panics *inside* the critical section, which poisons it — and `ThreadGuard`'s
1812    // `Drop` then takes that same lock again in `mark_no_live_notifications`, in `fail` and
1813    // in `close`, all while `std::thread::panicking()` is still true. `Shared::lock` recovers
1814    // from the poison instead of propagating it; a `lock().unwrap()` there would panic during
1815    // unwinding, and a panic while panicking aborts the process rather than closing the
1816    // stream — so the loss is the whole program, not one wrong answer.
1817    //
1818    // Nothing else in the tree stands here. `Shared::lock` is private, so an integration test
1819    // cannot hold the guard, and no other test panics with it held — the recovery answers to
1820    // this test alone.
1821    #[test]
1822    fn a_thread_that_panicked_holding_the_lock_still_gets_its_failure_delivered() {
1823        let shared = Arc::new(Shared::new(ProxyConfig::direct()));
1824
1825        let previous_hook = std::panic::take_hook();
1826        std::panic::set_hook(Box::new(|_| {}));
1827        let result = {
1828            let shared = Arc::clone(&shared);
1829            std::thread::spawn(move || {
1830                let _guard = ThreadGuard::new(Arc::clone(&shared));
1831                // Declared after the guard, so it unwinds first and the guard's `Drop` finds
1832                // the mutex already poisoned.
1833                let _held = shared.lock();
1834                panic!("simulated backend panic inside the critical section");
1835            })
1836            .join()
1837        };
1838        std::panic::set_hook(previous_hook);
1839
1840        assert!(result.is_err());
1841        // Not vacuous: a fixture that failed to poison would hold nothing below.
1842        assert!(shared.state.is_poisoned());
1843
1844        let state = shared.lock();
1845        assert_eq!(state.queue.len(), 2);
1846        assert!(matches!(state.queue[0], Queued::Snapshot));
1847        assert!(matches!(state.queue[1], Queued::Error(_)));
1848        assert!(state.closed);
1849        assert!(state.runtime_no_live_notifications);
1850    }
1851
1852    // `emit` drops the guard before it logs, with `// Log after unlock: subscriber code must
1853    // not run under this mutex.` written on the line. A `tracing` subscriber is arbitrary
1854    // consumer code, and `std::sync::Mutex` is not reentrant, so a subscriber that reaches
1855    // back for `ProxyWatcher::state` from inside its own event handler would block on a lock
1856    // its own thread is holding — a hang rather than a wrong answer, which no assertion
1857    // about the emitted value can see.
1858    //
1859    // The witness has to be `try_lock` rather than a real re-entry, or the test would hang
1860    // instead of failing.
1861    #[cfg(feature = "tracing")]
1862    #[test]
1863    fn no_subscriber_ever_runs_while_the_state_mutex_is_held() {
1864        use tracing::subscriber::Interest;
1865
1866        struct Probe {
1867            shared: Arc<Shared>,
1868            held: Mutex<Vec<(&'static str, bool)>>,
1869        }
1870
1871        impl Probe {
1872            fn note(&self, hook: &'static str) {
1873                let held = self.shared.state.try_lock().is_err();
1874                self.held.lock().unwrap().push((hook, held));
1875            }
1876        }
1877
1878        // Written out rather than borrowed from `tracing-subscriber` so that both hooks are
1879        // reachable: a `MakeWriter` sees only what an event *wrote*, and the level filter is
1880        // the other place consumer code runs.
1881        struct Wired(Arc<Probe>);
1882
1883        impl tracing::Subscriber for Wired {
1884            // Without this the default returns `always`/`never` and the answer is cached at
1885            // the callsite, so `enabled` is asked once and never again.
1886            fn register_callsite(&self, _: &tracing::Metadata<'_>) -> Interest {
1887                Interest::sometimes()
1888            }
1889
1890            fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
1891                self.0.note("enabled");
1892                true
1893            }
1894
1895            fn event(&self, _: &tracing::Event<'_>) {
1896                self.0.note("event");
1897            }
1898
1899            fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::Id {
1900                tracing::Id::from_u64(1)
1901            }
1902
1903            fn record(&self, _: &tracing::Id, _: &tracing::span::Record<'_>) {}
1904
1905            fn record_follows_from(&self, _: &tracing::Id, _: &tracing::Id) {}
1906
1907            fn enter(&self, _: &tracing::Id) {}
1908
1909            fn exit(&self, _: &tracing::Id) {}
1910        }
1911
1912        let shared = Arc::new(Shared::new(ProxyConfig::direct()));
1913        let probe = Arc::new(Probe {
1914            shared: Arc::clone(&shared),
1915            held: Mutex::new(Vec::new()),
1916        });
1917        tracing::subscriber::with_default(Wired(Arc::clone(&probe)), || {
1918            // Every branch `Shared` logs from: the equality skip, a real change, and the
1919            // close. Each is reached from inside a method that took the lock on its first
1920            // line.
1921            shared.emit(ProxyConfig::direct());
1922            shared.emit(ProxyConfig::from_source(
1923                crate::config::ProxyConfigSource::Registry,
1924                crate::mode::ProxyMode::WpadAutoDetect,
1925            ));
1926            shared.close();
1927        });
1928
1929        let observed = probe.held.lock().unwrap();
1930        // Not vacuous, and not half-vacuous: both hooks have to have been reached, or one of
1931        // the two claims below is about a call that never happened.
1932        assert!(
1933            observed.iter().any(|(hook, _)| *hook == "enabled"),
1934            "the level filter has to have been consulted"
1935        );
1936        assert!(
1937            observed.iter().any(|(hook, _)| *hook == "event"),
1938            "an event has to have been emitted"
1939        );
1940        assert!(observed.iter().all(|(_, held)| !held), "{observed:?}");
1941    }
1942
1943    // The guard marks the route lost *before* it queues the failure, and the comment beside
1944    // it says why: `fail` wakes the consumer, and a consumer that reads the health on being
1945    // woken must not still be told a route is live. This test is the only thing holding the
1946    // order — swapped, every assertion above still passes, because they all read the state
1947    // after the thread has been joined, by which point both calls have run either way.
1948    //
1949    // What moves is what the woken consumer is told. Only one of the two calls finds a
1950    // waker (the first one takes it), so the wake happens inside whichever runs first, and
1951    // that is the one moment at which the two orders disagree. Reading `health()` from
1952    // inside `wake` is how a test gets to stand there.
1953    //
1954    // It is not only a `health()` read that is at stake: the woken task's next act is to
1955    // poll, and `poll_next` stamps the failure with the health as it stands then. Swapped,
1956    // a consumer quick enough to poll before the second call lands takes an
1957    // `Error` reporting a live notification route from a thread that has already died.
1958    #[test]
1959    fn a_panicking_guard_marks_the_route_lost_before_it_wakes_anyone() {
1960        struct Probe {
1961            shared: Arc<Shared>,
1962            live_at_wake: Mutex<Vec<bool>>,
1963        }
1964        impl Wake for Probe {
1965            fn wake(self: Arc<Self>) {
1966                self.wake_by_ref();
1967            }
1968            fn wake_by_ref(self: &Arc<Self>) {
1969                let live = self.shared.health().has_live_notifications;
1970                self.live_at_wake.lock().unwrap().push(live);
1971            }
1972        }
1973
1974        let shared = Arc::new(Shared::new(ProxyConfig::direct()));
1975        shared.set_construction_health(live_construction_health());
1976        let probe = Arc::new(Probe {
1977            shared: Arc::clone(&shared),
1978            live_at_wake: Mutex::new(Vec::new()),
1979        });
1980        let waker = Waker::from(Arc::clone(&probe));
1981        let mut cx = Context::from_waker(&waker);
1982
1983        // Take the subscription snapshot, then park with the waker registered.
1984        assert!(shared.poll_next(&mut cx).is_ready());
1985        assert!(shared.poll_next(&mut cx).is_pending());
1986
1987        let previous_hook = std::panic::take_hook();
1988        std::panic::set_hook(Box::new(|_| {}));
1989        let result = {
1990            let shared = Arc::clone(&shared);
1991            std::thread::spawn(move || {
1992                let _guard = ThreadGuard::new(shared);
1993                panic!("simulated backend panic");
1994            })
1995            .join()
1996        };
1997        std::panic::set_hook(previous_hook);
1998        assert!(result.is_err());
1999
2000        // Exactly one wake, and the route was already lost when it arrived. The count is
2001        // part of the claim: a second entry would mean the first call did not take the
2002        // waker, and then the order below is not the one this test stands on.
2003        assert_eq!(
2004            *probe.live_at_wake.lock().unwrap(),
2005            vec![false],
2006            "the consumer must be woken once, and told the route is gone"
2007        );
2008    }
2009
2010    // The sibling above stands on one half of a rule the crate states at `emit`: foreign code
2011    // must not run under this mutex. `degrade`, `fail` and `mark_no_live_notifications` all
2012    // `drop(state)` before `waker.wake()`, and that test holds it by reading `health()` from
2013    // inside `wake`.
2014    //
2015    // The other half is `poll_next`. It parks by storing the waker, and the store runs two
2016    // pieces of executor code with the lock held: `Waker::clone`, and the drop of whatever
2017    // waker was parked before. `Waker::from(Arc<W>)` makes the second one `W`'s own `Drop`,
2018    // so it is not exotic executor internals — it is ordinary user code. One that reaches
2019    // back for `ProxyWatcher::state` blocks on a lock its own thread holds, and
2020    // `std::sync::Mutex` is not reentrant: a deadlock, not a wrong answer.
2021    //
2022    // The probe has to be `try_lock` rather than a real re-entry, or the test would hang
2023    // instead of failing. Only the drop is observable from safe code — `Waker::clone` on an
2024    // `Arc`-backed waker is a refcount bump with no user hook — but the same two lines move
2025    // both out from under the lock.
2026    #[test]
2027    fn parking_never_runs_executor_code_under_the_state_mutex() {
2028        struct Probe {
2029            shared: Arc<Shared>,
2030            held_at_drop: Arc<Mutex<Vec<bool>>>,
2031        }
2032        impl Wake for Probe {
2033            fn wake(self: Arc<Self>) {
2034                self.wake_by_ref();
2035            }
2036            fn wake_by_ref(self: &Arc<Self>) {}
2037        }
2038        impl Drop for Probe {
2039            fn drop(&mut self) {
2040                let held = self.shared.state.try_lock().is_err();
2041                self.held_at_drop.lock().unwrap().push(held);
2042            }
2043        }
2044
2045        let shared = Arc::new(Shared::new(ProxyConfig::direct()));
2046        let held_at_drop = Arc::new(Mutex::new(Vec::new()));
2047        let probe = Arc::new(Probe {
2048            shared: Arc::clone(&shared),
2049            held_at_drop: Arc::clone(&held_at_drop),
2050        });
2051
2052        let waker = Waker::from(Arc::clone(&probe));
2053        // Take the subscription snapshot, then park: the second poll is the one that stores
2054        // a clone of the probe's waker.
2055        assert!(
2056            shared
2057                .poll_next(&mut Context::from_waker(&waker))
2058                .is_ready()
2059        );
2060        assert!(
2061            shared
2062                .poll_next(&mut Context::from_waker(&waker))
2063                .is_pending()
2064        );
2065        // Leave the parked clone as the only reference, so replacing it is what runs `Drop`.
2066        drop(waker);
2067        drop(probe);
2068
2069        assert!(
2070            shared
2071                .poll_next(&mut Context::from_waker(Waker::noop()))
2072                .is_pending()
2073        );
2074
2075        let observed = held_at_drop.lock().unwrap();
2076        // Not vacuous: a probe that was never dropped asserts nothing below.
2077        assert_eq!(
2078            observed.len(),
2079            1,
2080            "the parked waker has to have been dropped"
2081        );
2082        assert!(observed.iter().all(|held| !held), "{observed:?}");
2083    }
2084
2085    // A cheap-to-construct error: its variant does not matter to [`watch_fail_soft`],
2086    // only whether establishing the route returned `Err` at all.
2087    fn some_error() -> Error {
2088        Error::Unsupported
2089    }
2090
2091    #[test]
2092    fn watch_fail_soft_outcomes() {
2093        enum Input {
2094            Ok,
2095            Err,
2096        }
2097        enum Expect {
2098            Live,
2099            Degraded,
2100            Fatal,
2101        }
2102
2103        let cases: &[(bool, Option<Duration>, Input, Expect)] = &[
2104            (true, None, Input::Ok, Expect::Live),
2105            (false, None, Input::Ok, Expect::Live),
2106            (true, Some(Duration::from_secs(1)), Input::Ok, Expect::Live),
2107            (false, Some(Duration::from_secs(1)), Input::Ok, Expect::Live),
2108            (false, None, Input::Err, Expect::Degraded),
2109            (
2110                false,
2111                Some(Duration::from_secs(1)),
2112                Input::Err,
2113                Expect::Degraded,
2114            ),
2115            (
2116                true,
2117                Some(Duration::from_secs(30)),
2118                Input::Err,
2119                Expect::Degraded,
2120            ),
2121            (true, None, Input::Err, Expect::Fatal),
2122        ];
2123        for (leading, poll_interval, input, expect) in cases {
2124            let outcome = watch_fail_soft(
2125                *leading,
2126                *poll_interval,
2127                match input {
2128                    Input::Ok => Ok(42),
2129                    Input::Err => Err(some_error()),
2130                },
2131            );
2132            match expect {
2133                Expect::Live => assert!(
2134                    matches!(outcome, WatchFailSoft::Live(42)),
2135                    "leading = {leading}, poll_interval = {poll_interval:?}"
2136                ),
2137                Expect::Degraded => assert!(
2138                    matches!(outcome, WatchFailSoft::Degraded(_)),
2139                    "leading = {leading}, poll_interval = {poll_interval:?}"
2140                ),
2141                Expect::Fatal => assert!(
2142                    matches!(outcome, WatchFailSoft::Fatal(_)),
2143                    "leading = {leading}, poll_interval = {poll_interval:?}"
2144                ),
2145            }
2146        }
2147    }
2148
2149    // [`fatal_watch_error`] must actually say what to do about it, not just that it
2150    // failed, and must not lose either the caller-supplied `why_no_fallback` or `what`.
2151    #[test]
2152    fn fatal_watch_error_names_the_fix_and_keeps_the_context() {
2153        let error = fatal_watch_error(
2154            "opening or arming the HKCU Internet Settings key",
2155            "HKCU is Windows' only mandatory proxy source",
2156            some_error(),
2157        );
2158        let message = error.to_string();
2159        assert!(
2160            message.contains("poll_interval"),
2161            "the error should point at the fix: {message}"
2162        );
2163        assert!(
2164            message.contains("opening or arming the HKCU Internet Settings key"),
2165            "the error should still say what failed: {message}"
2166        );
2167        assert!(
2168            message.contains("HKCU is Windows' only mandatory proxy source"),
2169            "the error should still say why there is no fallback: {message}"
2170        );
2171    }
2172
2173    // A source reported through [`Shared::degrade`] shows up in [`Shared::health`], and
2174    // reporting it twice does not duplicate it.
2175    #[test]
2176    fn shared_degrade_is_idempotent() {
2177        let shared = Shared::new(ProxyConfig::direct());
2178        shared.set_construction_health(live_construction_health());
2179        let health = shared.health();
2180        assert!(health.degraded.is_empty());
2181        assert!(health.has_live_notifications);
2182        assert!(!health.stopped);
2183
2184        shared.degrade(ProxyConfigSource::GroupPolicy);
2185        shared.degrade(ProxyConfigSource::GroupPolicy);
2186        let health = shared.health();
2187        assert_eq!(health.degraded, vec![ProxyConfigSource::GroupPolicy]);
2188        // One route degrading is not every route degrading, and degrading is not
2189        // closing: the thread reporting the loss is still running.
2190        assert!(health.has_live_notifications);
2191        assert!(!health.stopped);
2192    }
2193
2194    // [`Shared::mark_no_live_notifications`] is a one-way flag — nothing ever needs
2195    // to clear it, since no backend re-establishes a route once it has degraded.
2196    #[test]
2197    fn shared_mark_no_live_notifications_is_reflected_in_health() {
2198        let shared = Shared::new(ProxyConfig::direct());
2199        shared.set_construction_health(live_construction_health());
2200        assert!(shared.health().has_live_notifications);
2201        shared.mark_no_live_notifications();
2202        assert!(!shared.health().has_live_notifications);
2203    }
2204
2205    // [`merge_runtime_health`] must fold what [`Shared`] recorded into the
2206    // construction-time result without disturbing an already-degraded source or ever
2207    // flipping `has_live_notifications` back to `true`.
2208    #[test]
2209    fn merge_runtime_health_folds_in_new_state_without_disturbing_the_old() {
2210        let construction = WatchHealth {
2211            degraded: vec![ProxyConfigSource::GroupPolicy],
2212            has_live_notifications: true,
2213            poll_interval: None,
2214            stopped: false,
2215        };
2216
2217        // Nothing runtime-side yet: the construction-time value passes through.
2218        let health = merge_runtime_health(&construction, Vec::new(), false, false);
2219        assert_eq!(health.degraded, vec![ProxyConfigSource::GroupPolicy]);
2220        assert!(health.has_live_notifications);
2221
2222        // A runtime degrade adds an entry; a duplicate is not added twice.
2223        let health = merge_runtime_health(
2224            &construction,
2225            vec![ProxyConfigSource::Registry, ProxyConfigSource::GroupPolicy],
2226            false,
2227            false,
2228        );
2229        assert_eq!(
2230            health.degraded,
2231            vec![ProxyConfigSource::GroupPolicy, ProxyConfigSource::Registry]
2232        );
2233        assert!(health.has_live_notifications);
2234
2235        // Once every native route has degraded, `has_live_notifications` flips to
2236        // `false` — the construction-time `true` never wins over it.
2237        let health = merge_runtime_health(&construction, Vec::new(), true, false);
2238        assert!(!health.has_live_notifications);
2239    }
2240
2241    // The polling a `poll_interval` buys runs *on* the backend thread, so a thread that
2242    // stopped takes it with it. Without `stopped`, a watcher whose thread panicked went
2243    // on reporting `is_frozen() == false` on the strength of an interval nobody was
2244    // waiting out any more — the reassuring answer, which is the wrong way to be wrong.
2245    #[test]
2246    fn a_poll_interval_stops_counting_once_the_thread_is_gone() {
2247        let construction = WatchHealth {
2248            degraded: Vec::new(),
2249            has_live_notifications: true,
2250            poll_interval: Some(Duration::from_secs(30)),
2251            stopped: false,
2252        };
2253
2254        let live = merge_runtime_health(&construction, Vec::new(), false, false);
2255        assert!(!live.is_frozen());
2256
2257        // What `ThreadGuard::drop` leaves behind after a panic: no live notifications,
2258        // and the stream closed.
2259        let panicked = merge_runtime_health(&construction, Vec::new(), true, true);
2260        assert!(panicked.is_frozen());
2261        assert_eq!(
2262            panicked.poll_interval,
2263            Some(Duration::from_secs(30)),
2264            "the interval is still what the caller asked for"
2265        );
2266    }
2267}