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