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