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    /// [`current`](Self::current) without copying the configuration: the snapshot itself,
687    /// shared. The mutex is held only to clone the [`Arc`], so this suits a caller that asks
688    /// on every connection. A later change replaces the snapshot the watcher holds and
689    /// leaves the one returned here as it was.
690    ///
691    /// ```no_run
692    /// # fn main() -> Result<(), proxy_watch::Error> {
693    /// let watcher = proxy_watch::ProxyWatcher::new()?;
694    /// let snapshot = watcher.current_shared();
695    /// assert_eq!(*snapshot, watcher.current());
696    /// # Ok(())
697    /// # }
698    /// ```
699    #[must_use]
700    pub fn current_shared(&self) -> Arc<ProxyConfig> {
701        self.shared.current_shared()
702    }
703
704    /// Construction-time routes plus any runtime degradation (see [`WatchHealth`]).
705    /// Does no OS I/O; it may briefly wait for the same internal mutex
706    /// [`ProxyWatcher::current`] uses.
707    #[must_use]
708    pub fn health(&self) -> WatchHealth {
709        self.shared.health()
710    }
711
712    /// Configuration and health copied from one shared-state observation.
713    ///
714    /// This does no OS I/O, though it may briefly wait for the internal mutex. Prefer it
715    /// over separate [`current`](Self::current) and [`health`](Self::health) calls when a
716    /// route may degrade or the backend thread may stop concurrently.
717    #[must_use]
718    pub fn state(&self) -> WatchState {
719        self.shared.state()
720    }
721
722    /// Request an immediate re-read (same debounce/equality path as a native notify).
723    ///
724    /// Call it when the caller knows something this crate cannot see: a connection attempt
725    /// failed in a way that might be proxy-related, or the caller's own network-change
726    /// watch fired. This crate does not watch the network itself; this call is the hook for
727    /// that signal.
728    ///
729    /// Returns immediately, and may deliver nothing. A re-read shares a notification's
730    /// equality skip: one that finds the configuration and the health unchanged publishes
731    /// no [`WatchEvent`], and asking again does not force one. The [`Stream`] reports
732    /// change, not completion, and [`current`](Self::current) and [`state`](Self::state)
733    /// answer the same before and after such a re-read, so neither shows that this request
734    /// landed. [`WatchEvent::Error`] says the same for the recovery case.
735    pub fn poll_now(&self) {
736        self.watch.poll_now();
737    }
738}
739
740impl Stream for ProxyWatcher {
741    type Item = WatchEvent;
742
743    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
744        self.shared.poll_next(cx)
745    }
746}
747
748// The queue shared between the platform watcher thread and the [`Stream`].
749#[derive(Debug)]
750pub(crate) struct Shared {
751    state: Mutex<State>,
752    // Construction-time half of [`WatchHealth`]; the runtime half lives in `state`.
753    // It sits here, rather than on [`ProxyWatcher`], so that the health can be assembled
754    // wherever the shared state is held: including from inside the queue's own lock.
755    construction: OnceLock<WatchHealth>,
756}
757
758// One undelivered item. A snapshot carries no payload: [`Shared::poll_next`] stamps the
759// state at delivery from `current` and the merged health, so an entry only records *that*
760// something is owed, not what it said when it was queued.
761#[derive(Debug)]
762enum Queued {
763    Snapshot,
764    Error(Error),
765}
766
767#[derive(Debug)]
768struct State {
769    // Shared, so `current_shared` hands it out without copying the configuration.
770    current: Arc<ProxyConfig>,
771    // At most two `Queued::Snapshot`s, the front one and one behind a failure queued after
772    // it (folded by [`Shared::emit`]); failures keep their
773    // order and count, coalescing only at the tail via [`Shared::fail`], and are trimmed
774    // to [`MAX_QUEUED_CHANGES`] on each snapshot.
775    queue: VecDeque<Queued>,
776    // Discards in the current overflow episode; reset when the consumer drains.
777    dropped: u64,
778    waker: Option<Waker>,
779    closed: bool,
780    // A health transition not yet reported to the consumer. Nothing enqueues on a degrade,
781    // so without this a lost route would wait for a configuration change that may never
782    // come; [`Shared::poll_next`] synthesises a snapshot for it instead.
783    dirty: bool,
784    // Routes that degraded after construction ([`Shared::degrade`]).
785    runtime_degraded: Vec<ProxyConfigSource>,
786    // Set once every native route a backend started with has degraded; never clears.
787    runtime_no_live_notifications: bool,
788}
789
790impl State {
791    // Enforce [`MAX_QUEUED_CHANGES`] by discarding from the front.
792    fn trim_to_capacity(&mut self) -> Option<u64> {
793        if self.queue.len() <= MAX_QUEUED_CHANGES {
794            return None;
795        }
796        while self.queue.len() > MAX_QUEUED_CHANGES {
797            self.queue.pop_front();
798            self.dropped += 1;
799        }
800        Some(self.dropped)
801    }
802}
803
804// Announce what [`State::trim_to_capacity`] discarded, outside the mutex.
805#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
806fn report_discarded(discarded: Option<u64>) {
807    match discarded {
808        None => {}
809        Some(1) => crate::trace::warning!(
810            capacity = MAX_QUEUED_CHANGES,
811            "the change queue is full, so the oldest undelivered failure was discarded; \
812             the subscriber is not draining the stream. `ProxyWatcher::current` stays \
813             accurate; what is being lost is the record of individual read failures"
814        ),
815        Some(total) => crate::trace::debug!(
816            discarded = total,
817            capacity = MAX_QUEUED_CHANGES,
818            "the change queue is still full; discarding the oldest undelivered failure"
819        ),
820    }
821}
822
823impl Shared {
824    pub(crate) fn new(initial: ProxyConfig) -> Self {
825        let mut queue = VecDeque::new();
826        // The current value is delivered once, at subscription time: stamped with the
827        // construction-time health, so a watcher that started degraded says so in its
828        // very first event.
829        queue.push_back(Queued::Snapshot);
830        Self {
831            state: Mutex::new(State {
832                current: Arc::new(initial),
833                queue,
834                dropped: 0,
835                waker: None,
836                closed: false,
837                dirty: false,
838                runtime_degraded: Vec::new(),
839                runtime_no_live_notifications: false,
840            }),
841            construction: OnceLock::new(),
842        }
843    }
844
845    // Record what the backend established, once `spawn` has returned `Ok`.
846    pub(crate) fn set_construction_health(&self, health: WatchHealth) {
847        let already_set = self.construction.set(health).is_err();
848        debug_assert!(!already_set, "the constructor writes this exactly once");
849    }
850
851    // The construction-time half, or (only before the constructor has returned, which is
852    // the one window in which it is unset) a health that claims nothing: no route has been
853    // established yet, so an unset health cannot falsely report a live route.
854    fn construction(&self) -> &WatchHealth {
855        static NOT_ESTABLISHED: WatchHealth = WatchHealth {
856            degraded: Vec::new(),
857            has_live_notifications: false,
858            poll_interval: None,
859            stopped: false,
860        };
861        self.construction.get().unwrap_or(&NOT_ESTABLISHED)
862    }
863
864    // Record that `source`'s notification route stopped delivering after construction.
865    #[cfg_attr(
866        not(any(windows, all(target_os = "linux", feature = "linux-kde"))),
867        allow(dead_code)
868    )]
869    pub(crate) fn degrade(&self, source: ProxyConfigSource) {
870        let mut state = self.lock();
871        // Already in the health construction reported, so the merged health cannot move and
872        // there is nothing to republish. Only once that health is established: `spawn` starts
873        // the thread that calls this before the constructor records it.
874        if state.runtime_degraded.contains(&source)
875            || self
876                .construction
877                .get()
878                .is_some_and(|health| health.degraded.contains(&source))
879        {
880            return;
881        }
882        state.runtime_degraded.push(source);
883        // A degrade queues nothing, and the re-read that follows it may well be equal and
884        // skipped. Waking on the transition is what keeps the loss from being silent.
885        state.dirty = true;
886        let waker = state.waker.take();
887        drop(state);
888        if let Some(waker) = waker {
889            waker.wake();
890        }
891    }
892
893    // Record that no native notification route is live any more.
894    pub(crate) fn mark_no_live_notifications(&self) {
895        let mut state = self.lock();
896        // As in `degrade`: nothing to republish when construction already reported no live
897        // route, and only once it has reported anything.
898        if state.runtime_no_live_notifications
899            || self
900                .construction
901                .get()
902                .is_some_and(|health| !health.has_live_notifications)
903        {
904            return;
905        }
906        state.runtime_no_live_notifications = true;
907        state.dirty = true;
908        let waker = state.waker.take();
909        drop(state);
910        if let Some(waker) = waker {
911            waker.wake();
912        }
913    }
914
915    // Runtime additions folded into construction-time [`WatchHealth`].
916    //
917    // `closed` reaches a live [`ProxyWatcher`] only from [`ThreadGuard::drop`], i.e. the
918    // thread stopped on its own; the ordinary `Drop` path closes a watcher no caller can
919    // still query.
920    pub(crate) fn health(&self) -> WatchHealth {
921        let state = self.lock();
922        self.merged_health(&state)
923    }
924
925    // Current config and health from one lock acquisition. This is the atomic
926    // observation boundary exposed by [`ProxyWatcher::state`] and stamped on every
927    // [`WatchEvent`].
928    pub(crate) fn state(&self) -> WatchState {
929        let state = self.lock();
930        self.stamp(&state)
931    }
932
933    fn merged_health(&self, state: &State) -> WatchHealth {
934        merge_runtime_health(
935            self.construction(),
936            state.runtime_degraded.clone(),
937            state.runtime_no_live_notifications,
938            state.closed,
939        )
940    }
941
942    fn stamp(&self, state: &State) -> WatchState {
943        WatchState {
944            config: ProxyConfig::clone(&state.current),
945            health: self.merged_health(state),
946        }
947    }
948
949    pub(crate) fn current(&self) -> ProxyConfig {
950        ProxyConfig::clone(&self.current_shared())
951    }
952
953    pub(crate) fn current_shared(&self) -> Arc<ProxyConfig> {
954        Arc::clone(&self.lock().current)
955    }
956
957    // Publish `config` unless it is equal to the previous one (the equality skip).
958    //
959    // Of the publishing methods, only this one has a platform backend as its sole caller,
960    // so on a target that has none it is legitimately dead. `fail` and `close` are not, and
961    // neither is [`Error::io`]: [`ThreadGuard`]'s `Drop` calls them, which is what the
962    // guard is for, and a `Drop` impl is live on every target. This attribute is also the
963    // lint root for `trim_to_capacity` and `report_discarded`. `#[expect]` reports when
964    // this target no longer needs the suppression.
965    //
966    // `not(test)` because the platform backend is only the *non-test* sole caller: the
967    // tests below drive `emit` directly, and they are compiled on every target. Without
968    // it, `cargo clippy --target <unsupported> --all-targets` builds the lib test, finds
969    // `emit` live, and reports the expectation itself as unfulfilled, which `-D warnings`
970    // turns into a hard error on a target this crate does not even claim to support.
971    #[cfg_attr(
972        all(
973            not(any(
974                windows,
975                target_os = "macos",
976                target_os = "linux",
977                target_os = "android",
978                target_os = "ios"
979            )),
980            not(test)
981        ),
982        expect(dead_code)
983    )]
984    pub(crate) fn emit(&self, config: ProxyConfig) {
985        let mut state = self.lock();
986        if *state.current == config {
987            // Log after unlock: subscriber code must not run under this mutex.
988            drop(state);
989            crate::trace::debug!(
990                config = %crate::trace::ConfigSummary(&config),
991                "an unchanged snapshot was skipped (equality skip)"
992            );
993            return;
994        }
995        // Rendering the transition asks the subscriber whether `INFO` is enabled, and a
996        // level filter is consumer code like the event hook above, so it waits for the
997        // unlock too, and the previous value is kept here to make that possible; holding
998        // both behind `Arc` keeps them without copying either.
999        let config = Arc::new(config);
1000        let previous = std::mem::replace(&mut state.current, Arc::clone(&config));
1001        // Fold: at most one undelivered snapshot past the front, and it is always the newest
1002        // one. The entry carries no payload, so the one already queued renders whatever
1003        // `current` says when it is taken, and `current` was just replaced above. Moving it
1004        // to the back is what keeps failures queued before it ahead of it.
1005        //
1006        // The front entry is the exception: it is the next thing the consumer takes, and on a
1007        // subscription that has not polled yet it is the subscription-time snapshot every
1008        // consumer's first item is documented to be (`README.md`, `examples/watch.rs`, the
1009        // type doc above). Moving it puts a failure that arrived after subscription in front
1010        // of it, so the first thing the stream ever says is an error on a configuration it
1011        // has not once reported. It stays, and a failure queued behind it gets a second
1012        // snapshot after it: `WatchEvent::Error` promises one follows when the
1013        // configuration moved, and the front one precedes the failure.
1014        let last = state
1015            .queue
1016            .iter()
1017            .rposition(|i| matches!(i, Queued::Snapshot));
1018        let folded = match last {
1019            Some(at) if at + 1 == state.queue.len() => true,
1020            Some(at) if at > 0 => {
1021                state.queue.remove(at);
1022                state.queue.push_back(Queued::Snapshot);
1023                true
1024            }
1025            _ => {
1026                state.queue.push_back(Queued::Snapshot);
1027                false
1028            }
1029        };
1030        // The snapshot about to be delivered carries the health as it will stand then,
1031        // so it answers whatever transition `dirty` was holding.
1032        state.dirty = false;
1033        let discarded = state.trim_to_capacity();
1034        let waker = state.waker.take();
1035        drop(state);
1036        crate::trace::changed(&previous, &config);
1037        if folded {
1038            crate::trace::debug!(
1039                "a newer snapshot replaced one the subscriber had not taken yet; \
1040                 what it will read is the current configuration, not the intermediate one"
1041            );
1042        }
1043        report_discarded(discarded);
1044        if let Some(waker) = waker {
1045            waker.wake();
1046        }
1047    }
1048
1049    pub(crate) fn fail(&self, error: Error) {
1050        // Log before lock: `error` moves into the queue; subscriber must not run under mutex.
1051        crate::trace::warning!(
1052            error = %crate::trace::SafeError(&error),
1053            "reading the proxy configuration failed; the subscription stays open"
1054        );
1055        let mut state = self.lock();
1056        // Non-empty queue ⇒ no waker (`poll_next` only stores one while empty).
1057        if !state.queue.is_empty() {
1058            debug_assert!(
1059                state.waker.is_none(),
1060                "a waker must not be registered while the queue is non-empty"
1061            );
1062        }
1063        if let Some(Queued::Error(last)) = state.queue.back_mut() {
1064            *last = error;
1065            return;
1066        }
1067        state.queue.push_back(Queued::Error(error));
1068        let waker = state.waker.take();
1069        drop(state);
1070        if let Some(waker) = waker {
1071            waker.wake();
1072        }
1073    }
1074
1075    // Idempotent: only the first call ends the stream, and only it owes a snapshot.
1076    pub(crate) fn close(&self) {
1077        let mut state = self.lock();
1078        if state.closed {
1079            return;
1080        }
1081        state.closed = true;
1082        // `stopped` is a health transition like any other, so the consumer is owed one
1083        // last snapshot saying so before the stream ends. That holds for a panic, a fatal
1084        // return and an ordinary finish alike, without any of them being contracted
1085        // separately.
1086        state.dirty = true;
1087        let waker = state.waker.take();
1088        drop(state);
1089        crate::trace::debug!("the watcher thread finished; the stream will end");
1090        if let Some(waker) = waker {
1091            waker.wake();
1092        }
1093    }
1094
1095    fn poll_next(&self, cx: &mut Context<'_>) -> Poll<Option<WatchEvent>> {
1096        // Cloned before the lock is taken, for the same reason `emit` logs after releasing
1097        // it: executor code must not run under this mutex. It cannot wait until the park is
1098        // decided, because unlocking to take the clone would open a gap in which an `emit`
1099        // could queue an event and find no waker to wake. The ready paths below pay for one
1100        // unused clone, and drop it after `state`; locals unwind in reverse declaration
1101        // order, and `state` is declared second.
1102        let waker = cx.waker().clone();
1103        let mut state = self.lock();
1104        if let Some(item) = state.queue.pop_front() {
1105            if state.queue.is_empty() {
1106                // The consumer has caught up: the next overflow is a new episode and
1107                // gets its own `WARN` (see `State::dropped`).
1108                state.dropped = 0;
1109            }
1110            let event = match item {
1111                Queued::Snapshot => {
1112                    // Stamped now, not when it was queued, so it reports whatever the
1113                    // health has become since, which is what `dirty` was for.
1114                    state.dirty = false;
1115                    WatchEvent::Snapshot {
1116                        state: self.stamp(&state),
1117                    }
1118                }
1119                // A failed read says nothing about the routes, so it leaves `dirty`
1120                // standing and the health transition still gets its own snapshot.
1121                Queued::Error(error) => WatchEvent::Error {
1122                    error,
1123                    state: self.stamp(&state),
1124                },
1125            };
1126            return Poll::Ready(Some(event));
1127        }
1128        // Before the `closed` check: closing is itself the last health transition, and
1129        // an unreported one must not be swallowed by the end of the stream.
1130        if state.dirty {
1131            state.dirty = false;
1132            return Poll::Ready(Some(WatchEvent::Snapshot {
1133                state: self.stamp(&state),
1134            }));
1135        }
1136        if state.closed {
1137            return Poll::Ready(None);
1138        }
1139        let previous = state.waker.replace(waker);
1140        drop(state);
1141        // Not dropped by `replace` under the lock: `Waker::from(Arc<W>)` makes this `W`'s own
1142        // `Drop`, so it is ordinary consumer code, and one that reads back from the watcher
1143        // would block on a lock this very thread holds.
1144        drop(previous);
1145        Poll::Pending
1146    }
1147
1148    // Recover from a poisoned mutex instead of propagating the panic.
1149    fn lock(&self) -> std::sync::MutexGuard<'_, State> {
1150        self.state.lock().unwrap_or_else(|e| e.into_inner())
1151    }
1152}
1153
1154// Ensures [`Shared::close`] runs even if the backend thread panics.
1155pub(crate) struct ThreadGuard {
1156    shared: Arc<Shared>,
1157}
1158
1159// `new` is what a target with no backend never calls; the struct is not, because `allow`
1160// makes the item it is written on a lint root and this one names `Self`. That is also why
1161// the attribute has to sit here rather than on the struct; it does not reach out of the
1162// item it is written on, and the struct is a separate item.
1163#[cfg_attr(
1164    not(any(windows, target_os = "macos", target_os = "linux")),
1165    allow(dead_code)
1166)]
1167impl ThreadGuard {
1168    pub(crate) fn new(shared: Arc<Shared>) -> Self {
1169        Self { shared }
1170    }
1171}
1172
1173impl Drop for ThreadGuard {
1174    fn drop(&mut self) {
1175        if std::thread::panicking() {
1176            crate::trace::error!("the proxy-watch backend thread panicked; closing the stream");
1177            // Before the failure, not after: `fail` wakes the consumer, and a consumer that
1178            // reads the health on being woken must not still be told a route is live.
1179            self.shared.mark_no_live_notifications();
1180            self.shared.fail(Error::io(
1181                "running the proxy-watch backend thread",
1182                std::io::Error::other("the watcher thread panicked"),
1183            ));
1184        }
1185        self.shared.close();
1186    }
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191    use super::*;
1192    use std::sync::atomic::{AtomicBool, Ordering};
1193    use std::task::Wake;
1194
1195    // `Send` so a spawned task can hold the watcher; `Sync` is *not*
1196    // required: a single consumer owns the stream.
1197    const _: fn() = || {
1198        fn assert_send<T: Send>() {}
1199        assert_send::<ProxyWatcher>();
1200        assert_send::<WatchOptions>();
1201    };
1202
1203    // Not `Sync`, on every target: the inference below is ambiguous, and fails to compile,
1204    // exactly when `ProxyWatcher: Sync`.
1205    const _: fn() = || {
1206        trait AmbiguousIfSync<A> {
1207            fn item() {}
1208        }
1209        impl<T: ?Sized> AmbiguousIfSync<()> for T {}
1210        impl<T: ?Sized + Sync> AmbiguousIfSync<u8> for T {}
1211        let _ = <ProxyWatcher as AmbiguousIfSync<_>>::item;
1212    };
1213
1214    // Type-checked only, never run: `poll_now` takes `&self` and returns nothing, so
1215    // nothing here can tell a working one apart from a no-op. That needs a change the
1216    // native notification does not already deliver by itself, and only one test arranges
1217    // one:
1218    // `tests/linux_watch.rs`'s `poll_now_re_reads_a_change_no_watch_can_see`, which writes
1219    // through a symlink pointing out of the watched directory. Windows and macOS have no
1220    // equivalent.
1221    const _: fn(&ProxyWatcher) = |watcher| watcher.poll_now();
1222    const _: fn(&ProxyWatcher) -> WatchState = ProxyWatcher::state;
1223
1224    #[test]
1225    fn the_initial_value_is_queued_once_and_equality_skips_repeats() {
1226        let initial = ProxyConfig::direct();
1227        let shared = Shared::new(initial.clone());
1228
1229        // Exactly one queued item at subscription time.
1230        assert_eq!(shared.lock().queue.len(), 1);
1231
1232        // An identical snapshot does not even reach the queue; a different one replaces
1233        // the entry that is already there rather than adding to it.
1234        shared.emit(ProxyConfig::direct());
1235        assert_eq!(shared.lock().queue.len(), 1);
1236        assert!(!shared.lock().dirty, "an equal read changes nothing");
1237        shared.emit(ProxyConfig::from_source(
1238            crate::config::ProxyConfigSource::Registry,
1239            crate::mode::ProxyMode::WpadAutoDetect,
1240        ));
1241        assert_eq!(shared.lock().queue.len(), 1);
1242        assert_eq!(
1243            shared.current().effective,
1244            crate::mode::ProxyMode::WpadAutoDetect
1245        );
1246    }
1247
1248    // A snapshot handed out stays the one it was; the watcher moves on to a new one. An
1249    // equal read keeps the very same allocation, so a caller comparing by pointer sees no
1250    // change where there was none.
1251    #[test]
1252    fn a_shared_snapshot_is_not_rewritten_by_a_later_change() {
1253        let shared = Shared::new(ProxyConfig::direct());
1254        let before = shared.current_shared();
1255
1256        shared.emit(ProxyConfig::direct());
1257        assert!(Arc::ptr_eq(&before, &shared.current_shared()));
1258
1259        shared.emit(ProxyConfig::from_source(
1260            crate::config::ProxyConfigSource::Registry,
1261            crate::mode::ProxyMode::WpadAutoDetect,
1262        ));
1263        assert_eq!(before.effective, crate::mode::ProxyMode::Direct);
1264        assert_eq!(
1265            shared.current_shared().effective,
1266            crate::mode::ProxyMode::WpadAutoDetect
1267        );
1268        assert_eq!(*shared.current_shared(), shared.current());
1269    }
1270
1271    // Why [`ProxyConfig::fallbacks`] is compared by `PartialEq` rather than left beside
1272    // `captured_at`. Two reads can agree on every mode and still not be the same answer,
1273    // because one of them was assembled without a source it could not read. The skip above
1274    // suppresses the notification and *keeps* `current`, so the second half of this test
1275    // checks that a healed degradation stops being reported: with the field excluded, it
1276    // would go on being reported for the rest of the watcher's life.
1277    #[test]
1278    fn a_snapshot_that_differs_only_in_fallbacks_is_published_rather_than_skipped() {
1279        let complete = ProxyConfig::from_source(
1280            crate::config::ProxyConfigSource::Registry,
1281            crate::mode::ProxyMode::Direct,
1282        );
1283        let degraded = complete
1284            .clone()
1285            .with_fallbacks(vec![crate::config::ProxyConfigSource::GroupPolicy]);
1286        let shared = Shared::new(complete.clone());
1287
1288        shared.emit(degraded);
1289        assert_eq!(
1290            shared.current().fallbacks,
1291            [crate::config::ProxyConfigSource::GroupPolicy],
1292            "a read that lost a source is not the snapshot that never had one"
1293        );
1294
1295        shared.emit(complete);
1296        assert!(
1297            shared.current().fallbacks.is_empty(),
1298            "and the recovery is a change too"
1299        );
1300    }
1301
1302    // A read that fails between construction and the first poll, then a change: the
1303    // subscription snapshot comes first, the error next, and a snapshot of the change last
1304    // (the fold in `Shared::emit` says why). `tests/mac_watch.rs` holds the first item
1305    // against a live backend.
1306    #[test]
1307    fn a_failure_before_the_first_poll_stays_behind_the_subscription_snapshot() {
1308        let shared = Shared::new(ProxyConfig::direct());
1309        shared.fail(Error::io(
1310            "reading the proxy configuration",
1311            std::io::Error::other("a read that failed before the first poll"),
1312        ));
1313        shared.emit(ProxyConfig::from_source(
1314            ProxyConfigSource::Registry,
1315            crate::mode::ProxyMode::WpadAutoDetect,
1316        ));
1317
1318        // Read it the way a consumer does: the configuration first, the failure after.
1319        let mut cx = Context::from_waker(Waker::noop());
1320        match shared.poll_next(&mut cx) {
1321            Poll::Ready(Some(WatchEvent::Snapshot { state })) => {
1322                assert_eq!(
1323                    state.config.effective,
1324                    crate::mode::ProxyMode::WpadAutoDetect
1325                );
1326            }
1327            other => panic!("the first item must be the subscription snapshot, got {other:?}"),
1328        }
1329        assert!(matches!(
1330            shared.poll_next(&mut cx),
1331            Poll::Ready(Some(WatchEvent::Error { .. }))
1332        ));
1333        assert!(matches!(
1334            shared.poll_next(&mut cx),
1335            Poll::Ready(Some(WatchEvent::Snapshot { .. }))
1336        ));
1337        assert!(shared.poll_next(&mut cx).is_pending());
1338    }
1339
1340    // The same order once the subscription snapshot is taken: a snapshot still queued in
1341    // front of a failure stays there, and the change gets its own snapshot behind it. A
1342    // second change folds into that one.
1343    #[test]
1344    fn a_change_after_a_queued_failure_gets_a_snapshot_behind_it() {
1345        let shared = Shared::new(ProxyConfig::direct());
1346        let mut cx = Context::from_waker(Waker::noop());
1347        assert!(matches!(
1348            shared.poll_next(&mut cx),
1349            Poll::Ready(Some(WatchEvent::Snapshot { .. }))
1350        ));
1351        let changed = |mode| ProxyConfig::from_source(ProxyConfigSource::Registry, mode);
1352        shared.emit(changed(crate::mode::ProxyMode::WpadAutoDetect));
1353        shared.fail(sandboxed_error("between"));
1354        shared.emit(changed(crate::mode::ProxyMode::Direct));
1355        shared.emit(changed(crate::mode::ProxyMode::WpadAutoDetect));
1356
1357        let kinds: Vec<&str> = std::iter::from_fn(|| match shared.poll_next(&mut cx) {
1358            Poll::Ready(Some(WatchEvent::Snapshot { .. })) => Some("snapshot"),
1359            Poll::Ready(Some(WatchEvent::Error { .. })) => Some("error"),
1360            _ => None,
1361        })
1362        .collect();
1363        assert_eq!(kinds, ["snapshot", "error", "snapshot"]);
1364    }
1365
1366    // The construction-time half a live backend would have written, so that the runtime
1367    // half has something to override rather than agreeing with the unset default.
1368    fn live_construction_health() -> WatchHealth {
1369        WatchHealth {
1370            degraded: Vec::new(),
1371            has_live_notifications: true,
1372            poll_interval: None,
1373            stopped: false,
1374        }
1375    }
1376
1377    #[test]
1378    fn one_shared_state_read_contains_config_and_runtime_health() {
1379        let shared = Shared::new(ProxyConfig::direct());
1380        shared.set_construction_health(live_construction_health());
1381        shared.emit(ProxyConfig::from_source(
1382            ProxyConfigSource::Registry,
1383            crate::mode::ProxyMode::WpadAutoDetect,
1384        ));
1385        shared.degrade(ProxyConfigSource::Registry);
1386        shared.mark_no_live_notifications();
1387        shared.close();
1388
1389        let state = shared.state();
1390        assert_eq!(
1391            state.config.effective,
1392            crate::mode::ProxyMode::WpadAutoDetect
1393        );
1394        assert_eq!(state.health.degraded, vec![ProxyConfigSource::Registry]);
1395        assert!(!state.health.has_live_notifications);
1396        assert!(state.health.stopped);
1397    }
1398
1399    // Nothing can reach `Shared` through a `ProxyWatcher` before the constructor writes
1400    // the construction-time health, so the unset answer is unobservable in practice. It
1401    // still has to be the conservative one rather than a reassuring default.
1402    #[test]
1403    fn an_unwritten_construction_health_claims_no_route() {
1404        let shared = Shared::new(ProxyConfig::direct());
1405        let health = shared.health();
1406        assert!(!health.has_live_notifications);
1407        assert!(health.degraded.is_empty());
1408        assert_eq!(health.poll_interval, None);
1409        assert!(!health.stopped);
1410        assert!(!health.is_fully_live());
1411    }
1412
1413    // Alternating between these clears the equality skip on every `emit`.
1414    fn two_distinct_configs() -> (ProxyConfig, ProxyConfig) {
1415        (
1416            ProxyConfig::direct(),
1417            ProxyConfig::from_source(
1418                crate::config::ProxyConfigSource::Registry,
1419                crate::mode::ProxyMode::WpadAutoDetect,
1420            ),
1421        )
1422    }
1423
1424    // Alternate a change and a failure so the queue grows: on its own, a run of
1425    // changes folds into one entry and a run of failures coalesces into one.
1426    //
1427    // Take the subscription snapshot first, and fail once with the queue empty. `emit`
1428    // leaves a snapshot alone while it is the front entry; that entry is the next thing
1429    // the consumer takes, so what grows the queue is a stall that begins with an
1430    // undelivered failure in front, not one that begins before the first poll.
1431    fn fill_past_capacity(shared: &Shared, rounds: usize) {
1432        let (direct, wpad) = two_distinct_configs();
1433        let mut cx = Context::from_waker(Waker::noop());
1434        assert!(shared.poll_next(&mut cx).is_ready());
1435        shared.fail(sandboxed_error("filler"));
1436        for index in 0..rounds {
1437            shared.emit(if index % 2 == 0 {
1438                wpad.clone()
1439            } else {
1440                direct.clone()
1441            });
1442            shared.fail(sandboxed_error("filler"));
1443        }
1444    }
1445
1446    // A stalled consumer must not turn an unbounded number of failures into unbounded
1447    // memory: [`MAX_QUEUED_CHANGES`] is the ceiling. The `+ 1` is not slack; `emit` is
1448    // where `trim_to_capacity` runs, so a `fail` that lands on a just-trimmed queue sits
1449    // one past the ceiling until the next snapshot. `ProxyWatcher`'s stream doc says so.
1450    #[test]
1451    fn a_stalled_consumer_cannot_grow_the_queue_without_bound() {
1452        let shared = Shared::new(ProxyConfig::direct());
1453        fill_past_capacity(&shared, MAX_QUEUED_CHANGES * 2);
1454
1455        let state = shared.lock();
1456        assert!(
1457            state.queue.len() <= MAX_QUEUED_CHANGES + 1,
1458            "queue grew to {}",
1459            state.queue.len()
1460        );
1461        assert!(state.dropped > 0, "the cap must have discarded something");
1462    }
1463
1464    // Snapshots do not queue up behind a stalled consumer at all: the newest replaces the
1465    // one it has not taken yet, so what it eventually reads is the current answer and not
1466    // a backlog of history.
1467    #[test]
1468    fn undelivered_snapshots_fold_into_the_newest_one() {
1469        let (direct, wpad) = two_distinct_configs();
1470        let shared = Shared::new(direct.clone());
1471        let mut cx = Context::from_waker(Waker::noop());
1472
1473        for index in 0..64 {
1474            shared.emit(if index % 2 == 0 {
1475                wpad.clone()
1476            } else {
1477                direct.clone()
1478            });
1479        }
1480
1481        // One entry, not 65: the initial snapshot and every change folded together.
1482        assert_eq!(shared.lock().queue.len(), 1);
1483        match shared.poll_next(&mut cx) {
1484            Poll::Ready(Some(WatchEvent::Snapshot { state })) => {
1485                assert_eq!(state.config, shared.current());
1486                assert_eq!(state.config, direct);
1487            }
1488            other => panic!("expected the newest snapshot, got {other:?}"),
1489        }
1490        assert!(shared.poll_next(&mut cx).is_pending());
1491    }
1492
1493    // The counter behind the `WARN`-once rule tracks *episodes*: draining resets it, so
1494    // a later overflow is announced again instead of being swallowed by the first one.
1495    #[test]
1496    fn draining_the_queue_resets_the_discard_counter() {
1497        let shared = Shared::new(ProxyConfig::direct());
1498        let mut cx = Context::from_waker(Waker::noop());
1499
1500        fill_past_capacity(&shared, MAX_QUEUED_CHANGES + 5);
1501        assert!(shared.lock().dropped > 0);
1502
1503        // The poll that empties the queue resets the discard count.
1504        while shared.poll_next(&mut cx).is_ready() {
1505            if shared.lock().queue.is_empty() {
1506                break;
1507            }
1508        }
1509        assert_eq!(shared.lock().dropped, 0);
1510        assert_eq!(shared.lock().queue.len(), 0);
1511    }
1512
1513    // A lost route reaches the stream even when an equal re-read skips the configuration.
1514    #[test]
1515    fn a_degrade_reaches_the_stream_even_when_every_later_read_is_equal() {
1516        let shared = Shared::new(ProxyConfig::direct());
1517        shared.set_construction_health(live_construction_health());
1518        let mut cx = Context::from_waker(Waker::noop());
1519
1520        assert!(matches!(
1521            shared.poll_next(&mut cx),
1522            Poll::Ready(Some(WatchEvent::Snapshot { .. }))
1523        ));
1524        assert!(shared.poll_next(&mut cx).is_pending());
1525
1526        shared.degrade(ProxyConfigSource::Registry);
1527        // The re-read the backend does next finds nothing new, so it publishes nothing.
1528        shared.emit(ProxyConfig::direct());
1529
1530        match shared.poll_next(&mut cx) {
1531            Poll::Ready(Some(WatchEvent::Snapshot { state })) => {
1532                assert_eq!(state.health.degraded, vec![ProxyConfigSource::Registry]);
1533                assert_eq!(state.config, ProxyConfig::direct());
1534            }
1535            other => panic!("the lost route must reach the stream, got {other:?}"),
1536        }
1537        // Once told, the subscriber is not told again.
1538        assert!(shared.poll_next(&mut cx).is_pending());
1539        // Nor when the same route degrades a second time. A backend retrying an arm it
1540        // cannot get back calls this on every attempt, and `merge_runtime_health`'s dedupe
1541        // only keeps the *health* from growing; the wake and the synthesised snapshot are
1542        // this guard's, and without it one loss repeats for as long as the retries do.
1543        shared.degrade(ProxyConfigSource::Registry);
1544        assert!(shared.poll_next(&mut cx).is_pending());
1545    }
1546
1547    // A parked consumer has to be *woken* for the synthesised snapshot to be of any use:
1548    // a level-triggered stream nobody polls again is still silent.
1549    #[test]
1550    fn losing_the_last_route_wakes_a_parked_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 signal = Arc::new(Signal(AtomicBool::new(false)));
1564        let waker = Waker::from(Arc::clone(&signal));
1565        let mut cx = Context::from_waker(&waker);
1566
1567        assert!(shared.poll_next(&mut cx).is_ready());
1568        assert!(shared.poll_next(&mut cx).is_pending());
1569        assert!(!signal.0.load(Ordering::SeqCst));
1570
1571        shared.mark_no_live_notifications();
1572        assert!(
1573            signal.0.load(Ordering::SeqCst),
1574            "losing the last route must wake the parked consumer"
1575        );
1576        match shared.poll_next(&mut cx) {
1577            Poll::Ready(Some(WatchEvent::Snapshot { state })) => {
1578                assert!(!state.health.has_live_notifications);
1579            }
1580            other => panic!("expected a snapshot reporting the loss, got {other:?}"),
1581        }
1582        // The flag never clears, so a second call has nothing left to say. A backend that
1583        // finds the route still gone on every retry calls this each time; waking again
1584        // would report a loss the subscriber has already been told about.
1585        assert!(shared.poll_next(&mut cx).is_pending());
1586        signal.0.store(false, Ordering::SeqCst);
1587        shared.mark_no_live_notifications();
1588        assert!(!signal.0.load(Ordering::SeqCst));
1589        assert!(shared.poll_next(&mut cx).is_pending());
1590    }
1591
1592    // The one waker slot, from the losing side. [`ProxyWatcher`]'s doc says a second poll
1593    // overwrites the first task's waker and leaves it parked; that is the failure mode a
1594    // caller who puts the watcher behind a lock and polls it from two tasks hits,
1595    // so it is worth pinning rather than leaving as prose.
1596    #[test]
1597    fn a_second_poll_takes_the_waker_slot_from_the_first_consumer() {
1598        struct Signal(AtomicBool);
1599        impl Wake for Signal {
1600            fn wake(self: Arc<Self>) {
1601                self.wake_by_ref();
1602            }
1603            fn wake_by_ref(self: &Arc<Self>) {
1604                self.0.store(true, Ordering::SeqCst);
1605            }
1606        }
1607
1608        let shared = Shared::new(ProxyConfig::direct());
1609        shared.set_construction_health(live_construction_health());
1610        let first = Arc::new(Signal(AtomicBool::new(false)));
1611        let second = Arc::new(Signal(AtomicBool::new(false)));
1612        let first_waker = Waker::from(Arc::clone(&first));
1613        let second_waker = Waker::from(Arc::clone(&second));
1614
1615        // The construction snapshot, then the first consumer parks.
1616        assert!(
1617            shared
1618                .poll_next(&mut Context::from_waker(&first_waker))
1619                .is_ready()
1620        );
1621        assert!(
1622            shared
1623                .poll_next(&mut Context::from_waker(&first_waker))
1624                .is_pending()
1625        );
1626        // A second consumer polls the same watcher and takes the slot.
1627        assert!(
1628            shared
1629                .poll_next(&mut Context::from_waker(&second_waker))
1630                .is_pending()
1631        );
1632
1633        shared.mark_no_live_notifications();
1634        assert!(
1635            second.0.load(Ordering::SeqCst),
1636            "the most recently registered waker is the one that is kept"
1637        );
1638        assert!(
1639            !first.0.load(Ordering::SeqCst),
1640            "the first consumer stays parked: nothing on this watcher is left to wake it"
1641        );
1642    }
1643
1644    // Closing is the last health transition there is, so it owes a snapshot before the
1645    // stream ends; otherwise `None` would be the only notice a subscriber ever got that
1646    // the thread had stopped, and a subscriber reading health would never see `stopped`.
1647    #[test]
1648    fn closing_delivers_a_stopped_snapshot_exactly_once_before_the_end() {
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        assert!(shared.poll_next(&mut cx).is_ready());
1654        shared.close();
1655        // Idempotent: a second close owes nothing further.
1656        shared.close();
1657
1658        match shared.poll_next(&mut cx) {
1659            Poll::Ready(Some(WatchEvent::Snapshot { state })) => {
1660                assert!(state.health.stopped);
1661                assert!(state.health.is_frozen());
1662            }
1663            other => panic!("expected a stopped snapshot before the end, got {other:?}"),
1664        }
1665        assert!(matches!(shared.poll_next(&mut cx), Poll::Ready(None)));
1666        assert!(matches!(shared.poll_next(&mut cx), Poll::Ready(None)));
1667    }
1668
1669    // Queued failures come out before the terminal snapshot, and a failure on its own
1670    // never answers a health transition: the `stopped` snapshot still follows it.
1671    #[test]
1672    fn a_failure_does_not_stand_in_for_the_terminal_snapshot() {
1673        let shared = Shared::new(ProxyConfig::direct());
1674        shared.set_construction_health(live_construction_health());
1675        let mut cx = Context::from_waker(Waker::noop());
1676
1677        assert!(shared.poll_next(&mut cx).is_ready());
1678        shared.fail(sandboxed_error("dying"));
1679        shared.close();
1680
1681        assert!(matches!(
1682            shared.poll_next(&mut cx),
1683            Poll::Ready(Some(WatchEvent::Error { .. }))
1684        ));
1685        match shared.poll_next(&mut cx) {
1686            Poll::Ready(Some(WatchEvent::Snapshot { state })) => assert!(state.health.stopped),
1687            other => panic!("expected the terminal snapshot after the failure, got {other:?}"),
1688        }
1689        assert!(matches!(shared.poll_next(&mut cx), Poll::Ready(None)));
1690    }
1691
1692    // Whichever variant arrives, it answers the same question, which is what makes an
1693    // event comparable with a `ProxyWatcher::state()` taken beside it.
1694    #[test]
1695    fn every_event_carries_the_state_it_was_delivered_with() {
1696        let shared = Shared::new(ProxyConfig::direct());
1697        shared.set_construction_health(live_construction_health());
1698        let mut cx = Context::from_waker(Waker::noop());
1699
1700        let Poll::Ready(Some(snapshot)) = shared.poll_next(&mut cx) else {
1701            panic!("the subscription-time snapshot is always ready");
1702        };
1703        assert_eq!(snapshot.state(), &shared.state());
1704
1705        shared.fail(sandboxed_error("only a read failed"));
1706        let Poll::Ready(Some(failure)) = shared.poll_next(&mut cx) else {
1707            panic!("the queued failure is always ready");
1708        };
1709        assert!(matches!(failure, WatchEvent::Error { .. }));
1710        // A failed read is not a lost route: the health it carries still says so.
1711        assert_eq!(failure.state(), &shared.state());
1712        assert!(failure.state().health.has_live_notifications);
1713    }
1714
1715    // A distinguishable error so coalescing tests can tell which queued failure
1716    // survived; [`Error::Unsupported`] carries no payload to tell two instances apart.
1717    fn sandboxed_error(reason: &str) -> Error {
1718        Error::Sandboxed {
1719            sandbox: "Flatpak".to_owned(),
1720            reason: reason.to_owned(),
1721        }
1722    }
1723
1724    // A persistent read failure must not queue one `Err` per tick forever: a run of
1725    // [`Shared::fail`] calls collapses into the single tail entry, newest surviving.
1726    #[test]
1727    fn consecutive_fails_coalesce_into_the_newest_error() {
1728        let shared = Shared::new(ProxyConfig::direct());
1729        // Drain the initial snapshot so only the failure sequence is counted.
1730        assert!(matches!(
1731            shared.lock().queue.pop_front(),
1732            Some(Queued::Snapshot)
1733        ));
1734
1735        shared.fail(sandboxed_error("first"));
1736        assert_eq!(shared.lock().queue.len(), 1);
1737        shared.fail(sandboxed_error("second"));
1738        assert_eq!(shared.lock().queue.len(), 1);
1739        shared.fail(sandboxed_error("third"));
1740        assert_eq!(shared.lock().queue.len(), 1);
1741
1742        let state = shared.lock();
1743        match &state.queue[0] {
1744            Queued::Error(Error::Sandboxed { reason, .. }) => assert_eq!(reason, "third"),
1745            other => panic!("expected the newest coalesced Sandboxed error, got {other:?}"),
1746        }
1747    }
1748
1749    // Coalescing only applies to a run of failures with nothing successful in between:
1750    // a success in the middle must not swallow, or be swallowed by, either failure.
1751    #[test]
1752    fn fail_after_an_intervening_emit_is_not_coalesced() {
1753        let shared = Shared::new(ProxyConfig::direct());
1754        assert!(matches!(
1755            shared.lock().queue.pop_front(),
1756            Some(Queued::Snapshot)
1757        ));
1758
1759        shared.fail(sandboxed_error("before"));
1760        shared.emit(ProxyConfig::from_source(
1761            crate::config::ProxyConfigSource::Registry,
1762            crate::mode::ProxyMode::WpadAutoDetect,
1763        ));
1764        shared.fail(sandboxed_error("after"));
1765
1766        let state = shared.lock();
1767        assert_eq!(state.queue.len(), 3);
1768        match &state.queue[0] {
1769            Queued::Error(Error::Sandboxed { reason, .. }) => assert_eq!(reason, "before"),
1770            other => panic!("expected the first Sandboxed error, got {other:?}"),
1771        }
1772        assert!(matches!(state.queue[1], Queued::Snapshot));
1773        match &state.queue[2] {
1774            Queued::Error(Error::Sandboxed { reason, .. }) => assert_eq!(reason, "after"),
1775            other => panic!("expected the second Sandboxed error, got {other:?}"),
1776        }
1777    }
1778
1779    // A consumer parked on a registered waker must still be woken by the *first* of a
1780    // run of failures, and must then see exactly one `Err`: not zero, not two.
1781    #[test]
1782    fn a_parked_consumer_is_woken_once_and_sees_one_coalesced_error() {
1783        struct Signal(AtomicBool);
1784        impl Wake for Signal {
1785            fn wake(self: Arc<Self>) {
1786                self.wake_by_ref();
1787            }
1788            fn wake_by_ref(self: &Arc<Self>) {
1789                self.0.store(true, Ordering::SeqCst);
1790            }
1791        }
1792
1793        let shared = Shared::new(ProxyConfig::direct());
1794        assert!(matches!(
1795            shared.lock().queue.pop_front(),
1796            Some(Queued::Snapshot)
1797        ));
1798
1799        let signal = Arc::new(Signal(AtomicBool::new(false)));
1800        let waker = Waker::from(Arc::clone(&signal));
1801        let mut cx = Context::from_waker(&waker);
1802
1803        // Queue empty, stream open: registers the waker and returns Pending.
1804        assert!(shared.poll_next(&mut cx).is_pending());
1805        assert!(!signal.0.load(Ordering::SeqCst));
1806
1807        shared.fail(sandboxed_error("first"));
1808        assert!(
1809            signal.0.load(Ordering::SeqCst),
1810            "the first failure must wake the parked consumer"
1811        );
1812        // A second failure while the first is undrained coalesces in place rather than
1813        // needing a second wake.
1814        shared.fail(sandboxed_error("second"));
1815
1816        match shared.poll_next(&mut cx) {
1817            Poll::Ready(Some(WatchEvent::Error {
1818                error: Error::Sandboxed { reason, .. },
1819                ..
1820            })) => {
1821                assert_eq!(reason, "second");
1822            }
1823            other => panic!("expected exactly one coalesced Sandboxed error, got {other:?}"),
1824        }
1825        assert!(shared.lock().queue.is_empty());
1826    }
1827
1828    // The clamp tests below are written in terms of `MIN_POLL_INTERVAL` and `MAX_DEBOUNCE`
1829    // rather than the numbers they hold, which is what lets them describe the shape of the
1830    // clamp without repeating a literal in six places. It also means both bounds can move
1831    // and those expectations move with them. The numbers are in the rendered documentation:
1832    // `WatchOptions::poll_interval` says "anything under 200 ms is raised to it" and
1833    // `WatchOptions::debounce` says "anything over 24 h is lowered to it"; and
1834    // `xtask/tests/claim_counts.rs` counts nouns, not durations, so the test below is what
1835    // turns red when a bound moves and the doc does not.
1836    //
1837    // `WatchOptions::new()`'s own defaults are here for the same reason and one more:
1838    // `poll_interval` is not a tuning knob but a fork. Unset, a primary notification route
1839    // that will not arm is fatal and `with_options` refuses to build a watcher; set, that
1840    // same failure only reaches `WatchHealth::degraded` on a watcher that starts. A default
1841    // of `Some` would turn every such refusal into a silent degradation, and `true` for
1842    // `watch_group_policy` is what makes a machine-wide setting read at all.
1843    #[test]
1844    fn the_documented_defaults_and_bounds_are_the_stated_numbers() {
1845        let options = WatchOptions::new();
1846        assert_eq!(options.debounce, DEFAULT_DEBOUNCE);
1847        assert!(options.watch_group_policy);
1848        assert_eq!(options.poll_interval, None);
1849
1850        assert_eq!(DEFAULT_DEBOUNCE, Duration::from_millis(200));
1851        assert_eq!(MIN_POLL_INTERVAL, Duration::from_millis(200));
1852        assert_eq!(MAX_DEBOUNCE, Duration::from_secs(24 * 60 * 60));
1853    }
1854
1855    // The floor: zero, below, exactly at, and above `MIN_POLL_INTERVAL`.
1856    #[test]
1857    fn effective_poll_interval_clamps_to_the_floor() {
1858        let cases = [
1859            // zero
1860            (Duration::ZERO, MIN_POLL_INTERVAL),
1861            // below the floor
1862            (Duration::from_millis(1), MIN_POLL_INTERVAL),
1863            (
1864                MIN_POLL_INTERVAL - Duration::from_millis(1),
1865                MIN_POLL_INTERVAL,
1866            ),
1867            // exactly at the floor
1868            (MIN_POLL_INTERVAL, MIN_POLL_INTERVAL),
1869            // above the floor
1870            (
1871                MIN_POLL_INTERVAL + Duration::from_secs(1),
1872                MIN_POLL_INTERVAL + Duration::from_secs(1),
1873            ),
1874            (Duration::from_secs(30), Duration::from_secs(30)),
1875        ];
1876        for (requested, expected) in cases {
1877            assert_eq!(
1878                effective_poll_interval(requested),
1879                expected,
1880                "requested {requested:?}"
1881            );
1882        }
1883    }
1884
1885    // The ceiling: zero, the default, just below, exactly at, and above `MAX_DEBOUNCE`.
1886    #[test]
1887    fn effective_debounce_clamps_to_the_ceiling() {
1888        let cases = [
1889            // below the ceiling
1890            (Duration::ZERO, Duration::ZERO),
1891            (DEFAULT_DEBOUNCE, DEFAULT_DEBOUNCE),
1892            (
1893                MAX_DEBOUNCE - Duration::from_millis(1),
1894                MAX_DEBOUNCE - Duration::from_millis(1),
1895            ),
1896            // exactly at the ceiling
1897            (MAX_DEBOUNCE, MAX_DEBOUNCE),
1898            // above the ceiling
1899            (MAX_DEBOUNCE + Duration::from_millis(1), MAX_DEBOUNCE),
1900            (Duration::MAX, MAX_DEBOUNCE),
1901        ];
1902        for (requested, expected) in cases {
1903            assert_eq!(
1904                effective_debounce(requested),
1905                expected,
1906                "requested {requested:?}"
1907            );
1908        }
1909    }
1910
1911    // See [`MAX_DEBOUNCE`]: an overflowing deadline would stop the watcher thread.
1912    #[test]
1913    fn a_clamped_debounce_keeps_the_deadline_representable() {
1914        for requested in [Duration::MAX, MAX_DEBOUNCE + Duration::from_secs(1)] {
1915            let options = WatchOptions::new().with_debounce(requested);
1916            assert!(
1917                std::time::Instant::now()
1918                    .checked_add(effective_debounce(options.debounce))
1919                    .is_some(),
1920                "requested {requested:?}"
1921            );
1922        }
1923    }
1924
1925    #[test]
1926    fn watch_health_live_and_frozen_flags() {
1927        let cases = [
1928            // Healthy: live native route, nothing degraded.
1929            (Vec::new(), true, None, false, true, false),
1930            // Degraded secondary source: not fully live, not frozen.
1931            (
1932                vec![ProxyConfigSource::GroupPolicy],
1933                true,
1934                None,
1935                false,
1936                false,
1937                false,
1938            ),
1939            // No native route and no poll: frozen snapshot only.
1940            (Vec::new(), false, None, false, false, true),
1941            // Poll interval rescues a watcher with no native notifications.
1942            (
1943                Vec::new(),
1944                false,
1945                Some(Duration::from_secs(30)),
1946                false,
1947                false,
1948                false,
1949            ),
1950            // ...but only while there is a thread left to do the polling.
1951            (
1952                Vec::new(),
1953                false,
1954                Some(Duration::from_secs(30)),
1955                true,
1956                false,
1957                true,
1958            ),
1959            // A stopped thread is not fully live either, however healthy the routes were
1960            // while it ran. The Windows backend reaches this state: a fatal `run`
1961            // error closes the stream without ever marking the routes lost, so
1962            // `has_live_notifications` is still the `true` it was armed with.
1963            (Vec::new(), true, None, true, false, true),
1964        ];
1965        for (degraded, has_live, poll_interval, stopped, fully_live, frozen) in cases {
1966            let health = WatchHealth {
1967                degraded,
1968                has_live_notifications: has_live,
1969                poll_interval,
1970                stopped,
1971            };
1972            assert_eq!(health.is_fully_live(), fully_live, "{health:?}");
1973            assert_eq!(health.is_frozen(), frozen, "{health:?}");
1974        }
1975    }
1976
1977    // Runs `body` on a new thread with the panic hook silenced, so an expected panic prints
1978    // no backtrace, and returns the join. The hook is process-wide and tests run in
1979    // parallel, so the swap is serialised: tests restoring each other's silent hook
1980    // would leave every later panic in the run unprinted.
1981    fn join_silently(body: impl FnOnce() + Send + 'static) -> std::thread::Result<()> {
1982        static HOOK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1983        let _serial = HOOK
1984            .lock()
1985            .unwrap_or_else(std::sync::PoisonError::into_inner);
1986        let previous = std::panic::take_hook();
1987        std::panic::set_hook(Box::new(|_| {}));
1988        let result = std::thread::spawn(body).join();
1989        std::panic::set_hook(previous);
1990        result
1991    }
1992
1993    #[test]
1994    fn a_panicking_watcher_thread_still_closes_the_stream() {
1995        let shared = Arc::new(Shared::new(ProxyConfig::direct()));
1996
1997        let result = {
1998            let shared = Arc::clone(&shared);
1999            join_silently(move || {
2000                let _guard = ThreadGuard::new(Arc::clone(&shared));
2001                panic!("simulated backend panic");
2002            })
2003        };
2004
2005        assert!(result.is_err());
2006
2007        let state = shared.lock();
2008        // The initial snapshot, then exactly one failure pushed by the panicking guard.
2009        assert_eq!(state.queue.len(), 2);
2010        assert!(matches!(state.queue[0], Queued::Snapshot));
2011        assert!(matches!(state.queue[1], Queued::Error(_)));
2012        assert!(state.closed);
2013        // `degraded` stays empty because a dead thread has no single route to blame.
2014        assert!(state.runtime_no_live_notifications);
2015        assert!(state.runtime_degraded.is_empty());
2016    }
2017
2018    // The sibling above panics with no lock held, so the mutex it leaves behind is clean.
2019    // This one panics *inside* the critical section, which poisons it, and `ThreadGuard`'s
2020    // `Drop` then takes that same lock again in `mark_no_live_notifications`, in `fail` and
2021    // in `close`, all while `std::thread::panicking()` is still true.
2022    // `Shared::lock` recovers from the poison instead of propagating it; a
2023    // `lock().unwrap()` there would panic during unwinding, and a panic while panicking
2024    // aborts the process rather than closing the stream, so the loss is the whole program,
2025    // not one wrong answer.
2026    //
2027    // Nothing else in the tree stands here. `Shared::lock` is private, so an integration
2028    // test cannot hold the guard, and no other test panics with it held; the recovery
2029    // answers to this test alone.
2030    #[test]
2031    fn a_thread_that_panicked_holding_the_lock_still_gets_its_failure_delivered() {
2032        let shared = Arc::new(Shared::new(ProxyConfig::direct()));
2033
2034        let result = {
2035            let shared = Arc::clone(&shared);
2036            join_silently(move || {
2037                let _guard = ThreadGuard::new(Arc::clone(&shared));
2038                // Declared after the guard, so it unwinds first and the guard's `Drop` finds
2039                // the mutex already poisoned.
2040                let _held = shared.lock();
2041                panic!("simulated backend panic inside the critical section");
2042            })
2043        };
2044
2045        assert!(result.is_err());
2046        // Not vacuous: a fixture that failed to poison would hold nothing below.
2047        assert!(shared.state.is_poisoned());
2048
2049        let state = shared.lock();
2050        assert_eq!(state.queue.len(), 2);
2051        assert!(matches!(state.queue[0], Queued::Snapshot));
2052        assert!(matches!(state.queue[1], Queued::Error(_)));
2053        assert!(state.closed);
2054        assert!(state.runtime_no_live_notifications);
2055    }
2056
2057    // `emit` drops the guard before it logs, with `// Log after unlock: subscriber code
2058    // must not run under this mutex.` written on the line. A `tracing` subscriber is
2059    // arbitrary consumer code, and `std::sync::Mutex` is not reentrant, so a subscriber
2060    // that reaches back for `ProxyWatcher::state` from inside its own event handler would
2061    // block on a lock its own thread is holding: a hang rather than a wrong answer, which
2062    // no assertion about the emitted value can see.
2063    //
2064    // The witness has to be `try_lock` rather than a real re-entry, or the test would hang
2065    // instead of failing.
2066    #[cfg(feature = "tracing")]
2067    #[test]
2068    fn no_subscriber_ever_runs_while_the_state_mutex_is_held() {
2069        use tracing::subscriber::Interest;
2070
2071        struct Probe {
2072            shared: Arc<Shared>,
2073            held: Mutex<Vec<(&'static str, bool)>>,
2074        }
2075
2076        impl Probe {
2077            fn note(&self, hook: &'static str) {
2078                let held = self.shared.state.try_lock().is_err();
2079                self.held.lock().unwrap().push((hook, held));
2080            }
2081        }
2082
2083        // Written out rather than borrowed from `tracing-subscriber` so that both hooks are
2084        // reachable: a `MakeWriter` sees only what an event *wrote*, and the level filter is
2085        // the other place consumer code runs.
2086        struct Wired(Arc<Probe>);
2087
2088        impl tracing::Subscriber for Wired {
2089            // Without this the default returns `always`/`never` and the answer is cached at
2090            // the callsite, so `enabled` is asked once and never again.
2091            fn register_callsite(&self, _: &tracing::Metadata<'_>) -> Interest {
2092                Interest::sometimes()
2093            }
2094
2095            fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
2096                self.0.note("enabled");
2097                true
2098            }
2099
2100            fn event(&self, _: &tracing::Event<'_>) {
2101                self.0.note("event");
2102            }
2103
2104            fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::Id {
2105                tracing::Id::from_u64(1)
2106            }
2107
2108            fn record(&self, _: &tracing::Id, _: &tracing::span::Record<'_>) {}
2109
2110            fn record_follows_from(&self, _: &tracing::Id, _: &tracing::Id) {}
2111
2112            fn enter(&self, _: &tracing::Id) {}
2113
2114            fn exit(&self, _: &tracing::Id) {}
2115        }
2116
2117        let shared = Arc::new(Shared::new(ProxyConfig::direct()));
2118        let probe = Arc::new(Probe {
2119            shared: Arc::clone(&shared),
2120            held: Mutex::new(Vec::new()),
2121        });
2122        tracing::subscriber::with_default(Wired(Arc::clone(&probe)), || {
2123            // Every branch `Shared` logs from: the equality skip, a real change, and the
2124            // close. Each is reached from inside a method that took the lock on its first
2125            // line.
2126            shared.emit(ProxyConfig::direct());
2127            shared.emit(ProxyConfig::from_source(
2128                crate::config::ProxyConfigSource::Registry,
2129                crate::mode::ProxyMode::WpadAutoDetect,
2130            ));
2131            shared.close();
2132        });
2133
2134        let observed = probe.held.lock().unwrap();
2135        // Not vacuous, and not half-vacuous: both hooks have to have been reached, or one of
2136        // the two claims below is about a call that never happened.
2137        assert!(
2138            observed.iter().any(|(hook, _)| *hook == "enabled"),
2139            "the level filter has to have been consulted"
2140        );
2141        assert!(
2142            observed.iter().any(|(hook, _)| *hook == "event"),
2143            "an event has to have been emitted"
2144        );
2145        assert!(observed.iter().all(|(_, held)| !held), "{observed:?}");
2146    }
2147
2148    // The guard marks the route lost *before* it queues the failure, and the comment beside
2149    // it says why: `fail` wakes the consumer, and a consumer that reads the health on being
2150    // woken must not still be told a route is live. No other test checks the order:
2151    // swapped, every assertion above still passes, because they all read the state after
2152    // the thread has been joined, by which point both calls have run either way.
2153    //
2154    // What moves is what the woken consumer is told. Only one of the two calls finds a
2155    // waker (the first one takes it), so the wake happens inside whichever runs first, and
2156    // that is the one moment at which the two orders disagree. Reading `health()` from
2157    // inside `wake` is how a test gets to stand there.
2158    //
2159    // It is not only a `health()` read that is at stake: the woken task's next act is to
2160    // poll, and `poll_next` stamps the failure with the health as it stands then. Swapped,
2161    // a consumer quick enough to poll before the second call lands takes an
2162    // `Error` reporting a live notification route from a thread that has already died.
2163    #[test]
2164    fn a_panicking_guard_marks_the_route_lost_before_it_wakes_anyone() {
2165        struct Probe {
2166            shared: Arc<Shared>,
2167            live_at_wake: Mutex<Vec<bool>>,
2168        }
2169        impl Wake for Probe {
2170            fn wake(self: Arc<Self>) {
2171                self.wake_by_ref();
2172            }
2173            fn wake_by_ref(self: &Arc<Self>) {
2174                let live = self.shared.health().has_live_notifications;
2175                self.live_at_wake.lock().unwrap().push(live);
2176            }
2177        }
2178
2179        let shared = Arc::new(Shared::new(ProxyConfig::direct()));
2180        shared.set_construction_health(live_construction_health());
2181        let probe = Arc::new(Probe {
2182            shared: Arc::clone(&shared),
2183            live_at_wake: Mutex::new(Vec::new()),
2184        });
2185        let waker = Waker::from(Arc::clone(&probe));
2186        let mut cx = Context::from_waker(&waker);
2187
2188        // Take the subscription snapshot, then park with the waker registered.
2189        assert!(shared.poll_next(&mut cx).is_ready());
2190        assert!(shared.poll_next(&mut cx).is_pending());
2191
2192        let result = {
2193            let shared = Arc::clone(&shared);
2194            join_silently(move || {
2195                let _guard = ThreadGuard::new(shared);
2196                panic!("simulated backend panic");
2197            })
2198        };
2199        assert!(result.is_err());
2200
2201        // Exactly one wake, and the route was already lost when it arrived. The count is
2202        // part of the claim: a second entry would mean the first call did not take the
2203        // waker, and then the order below is not the one this test stands on.
2204        assert_eq!(
2205            *probe.live_at_wake.lock().unwrap(),
2206            vec![false],
2207            "the consumer must be woken once, and told the route is gone"
2208        );
2209    }
2210
2211    // The sibling above stands on one half of a rule the crate states at `emit`: foreign code
2212    // must not run under this mutex. `degrade`, `fail` and `mark_no_live_notifications` all
2213    // `drop(state)` before `waker.wake()`, and that test holds it by reading `health()` from
2214    // inside `wake`.
2215    //
2216    // The other half is `poll_next`. It parks by storing the waker, and the store runs two
2217    // pieces of executor code with the lock held: `Waker::clone`, and the drop of whatever
2218    // waker was parked before. `Waker::from(Arc<W>)` makes the second one `W`'s own `Drop`,
2219    // so it is not exotic executor internals: it is ordinary user code. One that reaches
2220    // back for `ProxyWatcher::state` blocks on a lock its own thread holds, and
2221    // `std::sync::Mutex` is not reentrant: a deadlock, not a wrong answer.
2222    //
2223    // The probe has to be `try_lock` rather than a real re-entry, or the test would hang
2224    // instead of failing. Only the drop is observable from safe code; `Waker::clone` on an
2225    // `Arc`-backed waker is a refcount bump with no user hook, but the same two lines move
2226    // both out from under the lock.
2227    #[test]
2228    fn parking_never_runs_executor_code_under_the_state_mutex() {
2229        struct Probe {
2230            shared: Arc<Shared>,
2231            held_at_drop: Arc<Mutex<Vec<bool>>>,
2232        }
2233        impl Wake for Probe {
2234            fn wake(self: Arc<Self>) {
2235                self.wake_by_ref();
2236            }
2237            fn wake_by_ref(self: &Arc<Self>) {}
2238        }
2239        impl Drop for Probe {
2240            fn drop(&mut self) {
2241                let held = self.shared.state.try_lock().is_err();
2242                self.held_at_drop.lock().unwrap().push(held);
2243            }
2244        }
2245
2246        let shared = Arc::new(Shared::new(ProxyConfig::direct()));
2247        let held_at_drop = Arc::new(Mutex::new(Vec::new()));
2248        let probe = Arc::new(Probe {
2249            shared: Arc::clone(&shared),
2250            held_at_drop: Arc::clone(&held_at_drop),
2251        });
2252
2253        let waker = Waker::from(Arc::clone(&probe));
2254        // Take the subscription snapshot, then park: the second poll is the one that stores
2255        // a clone of the probe's waker.
2256        assert!(
2257            shared
2258                .poll_next(&mut Context::from_waker(&waker))
2259                .is_ready()
2260        );
2261        assert!(
2262            shared
2263                .poll_next(&mut Context::from_waker(&waker))
2264                .is_pending()
2265        );
2266        // Leave the parked clone as the only reference, so replacing it is what runs `Drop`.
2267        drop(waker);
2268        drop(probe);
2269
2270        assert!(
2271            shared
2272                .poll_next(&mut Context::from_waker(Waker::noop()))
2273                .is_pending()
2274        );
2275
2276        let observed = held_at_drop.lock().unwrap();
2277        // Not vacuous: a probe that was never dropped asserts nothing below.
2278        assert_eq!(
2279            observed.len(),
2280            1,
2281            "the parked waker has to have been dropped"
2282        );
2283        assert!(observed.iter().all(|held| !held), "{observed:?}");
2284    }
2285
2286    // A cheap-to-construct error: its variant does not matter to [`watch_fail_soft`],
2287    // only whether establishing the route returned `Err` at all.
2288    fn some_error() -> Error {
2289        Error::Unsupported
2290    }
2291
2292    #[test]
2293    fn an_unreachable_platform_fails_the_route_as_it_is_whatever_the_interval() {
2294        let unreachable = || Err(Error::io("attaching", std::io::Error::other("no JavaVM")));
2295        for poll_interval in [None, Some(Duration::from_secs(1))] {
2296            let mut established = false;
2297            let outcome = watch_fail_soft_reachable(unreachable, true, poll_interval, || {
2298                established = true;
2299                Ok(())
2300            });
2301            let Err(error) = outcome else {
2302                panic!("an unreachable platform was softened under {poll_interval:?}");
2303            };
2304            assert!(
2305                matches!(&error, Error::Io { context, .. } if context == "attaching"),
2306                "{error:?}"
2307            );
2308            assert!(
2309                !established,
2310                "the route was attempted with no platform to reach"
2311            );
2312        }
2313
2314        // Reachable, the route's own failure is `watch_fail_soft`'s to judge.
2315        let failed = || Err::<(), _>(Error::Unsupported);
2316        assert!(matches!(
2317            watch_fail_soft_reachable(|| Ok(()), true, None, failed),
2318            Ok(WatchFailSoft::Fatal(Error::Unsupported))
2319        ));
2320        assert!(matches!(
2321            watch_fail_soft_reachable(|| Ok(()), true, Some(Duration::from_secs(1)), failed),
2322            Ok(WatchFailSoft::Degraded(Error::Unsupported))
2323        ));
2324    }
2325
2326    #[test]
2327    fn watch_fail_soft_outcomes() {
2328        enum Input {
2329            Ok,
2330            Err,
2331        }
2332        enum Expect {
2333            Live,
2334            Degraded,
2335            Fatal,
2336        }
2337
2338        let cases: &[(bool, Option<Duration>, Input, Expect)] = &[
2339            (true, None, Input::Ok, Expect::Live),
2340            (false, None, Input::Ok, Expect::Live),
2341            (true, Some(Duration::from_secs(1)), Input::Ok, Expect::Live),
2342            (false, Some(Duration::from_secs(1)), Input::Ok, Expect::Live),
2343            (false, None, Input::Err, Expect::Degraded),
2344            (
2345                false,
2346                Some(Duration::from_secs(1)),
2347                Input::Err,
2348                Expect::Degraded,
2349            ),
2350            (
2351                true,
2352                Some(Duration::from_secs(30)),
2353                Input::Err,
2354                Expect::Degraded,
2355            ),
2356            (true, None, Input::Err, Expect::Fatal),
2357        ];
2358        for (leading, poll_interval, input, expect) in cases {
2359            let outcome = watch_fail_soft(
2360                *leading,
2361                *poll_interval,
2362                match input {
2363                    Input::Ok => Ok(42),
2364                    Input::Err => Err(some_error()),
2365                },
2366            );
2367            match expect {
2368                Expect::Live => assert!(
2369                    matches!(outcome, WatchFailSoft::Live(42)),
2370                    "leading = {leading}, poll_interval = {poll_interval:?}"
2371                ),
2372                Expect::Degraded => assert!(
2373                    matches!(outcome, WatchFailSoft::Degraded(_)),
2374                    "leading = {leading}, poll_interval = {poll_interval:?}"
2375                ),
2376                Expect::Fatal => assert!(
2377                    matches!(outcome, WatchFailSoft::Fatal(_)),
2378                    "leading = {leading}, poll_interval = {poll_interval:?}"
2379                ),
2380            }
2381        }
2382    }
2383
2384    // [`fatal_watch_error`] must say what to do about it, not just that it
2385    // failed, and must not lose either the caller-supplied `why_no_fallback` or `what`.
2386    #[test]
2387    fn fatal_watch_error_names_the_fix_and_keeps_the_context() {
2388        let error = fatal_watch_error(
2389            "opening or arming the HKCU Internet Settings key",
2390            "HKCU is Windows' only mandatory proxy source",
2391            some_error(),
2392        );
2393        let message = error.to_string();
2394        assert!(
2395            message.contains("poll_interval"),
2396            "the error should point at the fix: {message}"
2397        );
2398        assert!(
2399            message.contains("opening or arming the HKCU Internet Settings key"),
2400            "the error should still say what failed: {message}"
2401        );
2402        assert!(
2403            message.contains("HKCU is Windows' only mandatory proxy source"),
2404            "the error should still say why there is no fallback: {message}"
2405        );
2406    }
2407
2408    // A source reported through [`Shared::degrade`] shows up in [`Shared::health`], and
2409    // reporting it twice does not duplicate it.
2410    #[test]
2411    fn shared_degrade_is_idempotent() {
2412        let shared = Shared::new(ProxyConfig::direct());
2413        shared.set_construction_health(live_construction_health());
2414        let health = shared.health();
2415        assert!(health.degraded.is_empty());
2416        assert!(health.has_live_notifications);
2417        assert!(!health.stopped);
2418
2419        shared.degrade(ProxyConfigSource::GroupPolicy);
2420        shared.degrade(ProxyConfigSource::GroupPolicy);
2421        let health = shared.health();
2422        assert_eq!(health.degraded, vec![ProxyConfigSource::GroupPolicy]);
2423        // One route degrading is not every route degrading, and degrading is not
2424        // closing: the thread reporting the loss is still running.
2425        assert!(health.has_live_notifications);
2426        assert!(!health.stopped);
2427    }
2428
2429    // [`Shared::mark_no_live_notifications`] is a one-way flag: nothing ever needs
2430    // to clear it, since no backend re-establishes a route once it has degraded.
2431    #[test]
2432    fn shared_mark_no_live_notifications_is_reflected_in_health() {
2433        let shared = Shared::new(ProxyConfig::direct());
2434        shared.set_construction_health(live_construction_health());
2435        assert!(shared.health().has_live_notifications);
2436        shared.mark_no_live_notifications();
2437        assert!(!shared.health().has_live_notifications);
2438    }
2439
2440    // [`merge_runtime_health`] must fold what [`Shared`] recorded into the
2441    // construction-time result without disturbing an already-degraded source or ever
2442    // flipping `has_live_notifications` back to `true`.
2443    #[test]
2444    fn merge_runtime_health_folds_in_new_state_without_disturbing_the_old() {
2445        let construction = WatchHealth {
2446            degraded: vec![ProxyConfigSource::GroupPolicy],
2447            has_live_notifications: true,
2448            poll_interval: None,
2449            stopped: false,
2450        };
2451
2452        // Nothing runtime-side yet: the construction-time value passes through.
2453        let health = merge_runtime_health(&construction, Vec::new(), false, false);
2454        assert_eq!(health.degraded, vec![ProxyConfigSource::GroupPolicy]);
2455        assert!(health.has_live_notifications);
2456
2457        // A runtime degrade adds an entry; a duplicate is not added twice.
2458        let health = merge_runtime_health(
2459            &construction,
2460            vec![ProxyConfigSource::Registry, ProxyConfigSource::GroupPolicy],
2461            false,
2462            false,
2463        );
2464        assert_eq!(
2465            health.degraded,
2466            vec![ProxyConfigSource::GroupPolicy, ProxyConfigSource::Registry]
2467        );
2468        assert!(health.has_live_notifications);
2469
2470        // Once every native route has degraded, `has_live_notifications` flips to
2471        // `false`; the construction-time `true` never wins over it.
2472        let health = merge_runtime_health(&construction, Vec::new(), true, false);
2473        assert!(!health.has_live_notifications);
2474    }
2475
2476    // The polling configured by `poll_interval` runs *on* the backend thread, so a thread
2477    // that stopped takes it with it. Without `stopped`, a watcher whose thread panicked
2478    // went on reporting `is_frozen() == false` on the strength of an interval no thread was
2479    // waiting out any more, falsely reporting that changes could still be detected.
2480    #[test]
2481    fn a_poll_interval_stops_counting_once_the_thread_is_gone() {
2482        let construction = WatchHealth {
2483            degraded: Vec::new(),
2484            has_live_notifications: true,
2485            poll_interval: Some(Duration::from_secs(30)),
2486            stopped: false,
2487        };
2488
2489        let live = merge_runtime_health(&construction, Vec::new(), false, false);
2490        assert!(!live.is_frozen());
2491
2492        // What `ThreadGuard::drop` leaves behind after a panic: no live notifications,
2493        // and the stream closed.
2494        let panicked = merge_runtime_health(&construction, Vec::new(), true, true);
2495        assert!(panicked.is_frozen());
2496        assert_eq!(
2497            panicked.poll_interval,
2498            Some(Duration::from_secs(30)),
2499            "the interval is still what the caller asked for"
2500        );
2501    }
2502}