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}