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