use std::sync::Arc;
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, SyncSender, TrySendError};
use std::thread::{self, JoinHandle};
#[cfg_attr(
not(any(feature = "linux-gnome", feature = "linux-kde")),
allow(unused_imports)
)]
use std::time::Duration;
use std::time::Instant;
use crate::error::Error;
#[cfg_attr(
not(any(feature = "linux-gnome", feature = "linux-kde")),
allow(unused_imports)
)]
use crate::watch::{
BackendHealth, Shared, WatchFailSoft, WatchOptions, effective_debounce, fatal_watch_error,
watch_fail_soft,
};
use super::Env;
use super::backend::{Route, read_config_in, route};
use super::desktop;
use super::sandbox;
pub(crate) struct Watch {
trigger: Option<SyncSender<()>>,
triggers: Option<Receiver<()>>,
portal_route: bool,
#[cfg(feature = "linux-gnome")]
gsettings_is_leading: bool,
#[cfg(feature = "linux-gnome")]
gnome: Option<GnomeWatch>,
#[cfg(feature = "linux-kde")]
kde: Option<KdeWatch>,
poll: Option<Poll>,
coordinator: Option<JoinHandle<()>>,
env: Env,
}
#[cfg(feature = "linux-kde")]
enum KdeWatch {
Live(super::kde::FileWatch),
NoDirectory,
Degraded,
}
#[cfg(feature = "linux-kde")]
impl KdeWatch {
fn is_live(&self) -> bool {
matches!(self, KdeWatch::Live(watch) if watch.is_live())
}
}
#[cfg(feature = "linux-gnome")]
enum GnomeWatch {
Live(#[allow(dead_code)] super::gnome::Handle),
NoSchema,
Degraded,
}
#[cfg(feature = "linux-gnome")]
impl GnomeWatch {
fn is_live(&self) -> bool {
matches!(self, GnomeWatch::Live(_))
}
}
impl std::fmt::Debug for Watch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Watch")
.field("portal_route", &self.portal_route)
.field("polling", &self.poll.is_some())
.field("running", &self.coordinator.is_some())
.finish_non_exhaustive()
}
}
pub(super) fn wake(trigger: &SyncSender<()>) -> bool {
!matches!(trigger.try_send(()), Err(TrySendError::Disconnected(())))
}
#[cfg(feature = "linux-kde")]
pub(super) fn wake_delivered(trigger: &SyncSender<()>) {
let _ = trigger.send(());
}
impl Watch {
#[cfg_attr(not(feature = "linux-kde"), allow(unused_variables))]
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
pub(crate) fn armed(options: &WatchOptions, env: &Env) -> Result<Self, Error> {
let (trigger, triggers) = mpsc::sync_channel(1);
let env = env.clone();
let desktop = desktop::current(&env);
let route = route(sandbox::detect(&env), desktop);
let portal_route = route == Route::Portal;
#[cfg(feature = "linux-kde")]
let kde = if !matches!(route, Route::Stores { kioslaverc: true }) {
None
} else {
Some(kde_watch_fail_soft(
trigger.clone(),
is_leading_store(desktop, desktop::Store::Kioslaverc),
options.poll_interval,
&env,
)?)
};
#[cfg(feature = "linux-kde")]
let kioslaverc_watch = matches!(&kde, Some(kde) if kde.is_live());
#[cfg(not(feature = "linux-kde"))]
let kioslaverc_watch = false;
crate::trace::debug!(
portal_route,
kioslaverc_watch,
"armed the Linux change notifications"
);
Ok(Self {
trigger: Some(trigger),
triggers: Some(triggers),
portal_route,
#[cfg(feature = "linux-gnome")]
gsettings_is_leading: is_leading_store(desktop, desktop::Store::GSettings),
#[cfg(feature = "linux-gnome")]
gnome: None,
#[cfg(feature = "linux-kde")]
kde,
poll: None,
coordinator: None,
env,
})
}
pub(crate) fn spawn(
&mut self,
options: &WatchOptions,
shared: Arc<Shared>,
) -> Result<(), Error> {
let triggers = self
.triggers
.take()
.expect("Watch::spawn must be called exactly once");
let trigger = self
.trigger
.clone()
.expect("the trigger is only taken by Drop");
#[cfg(feature = "linux-gnome")]
if !self.portal_route {
self.gnome = Some(gnome_start_fail_soft(
trigger.clone(),
self.gsettings_is_leading,
options.poll_interval,
)?);
if matches!(&self.gnome, Some(gnome) if gnome.is_live()) {
wake(&trigger);
}
}
if let Some(interval) = options.poll_interval {
let interval = poll_wait(interval);
crate::trace::debug!(interval = ?interval, "starting the polling fallback");
self.poll = Some(Poll::start(trigger.clone(), interval)?);
} else if !self.has_live_notification_source() {
#[cfg_attr(not(feature = "tracing"), allow(clippy::if_same_then_else))]
if self.portal_route {
crate::trace::warning!(
"the portal route has no change notification; set \
WatchOptions::poll_interval or this watcher will never update"
);
} else {
crate::trace::warning!(
"every Linux change notification source is degraded or absent; set \
WatchOptions::poll_interval or this watcher will never update"
);
}
}
#[cfg(feature = "linux-kde")]
let kde_loss = match &self.kde {
Some(KdeWatch::Live(watch)) => Some(watch.loss_flags()),
_ => None,
};
#[cfg(feature = "linux-kde")]
let kde_is_the_last_live_route = {
#[cfg(feature = "linux-gnome")]
{
!matches!(&self.gnome, Some(gnome) if gnome.is_live())
}
#[cfg(not(feature = "linux-gnome"))]
{
true
}
};
let options = options.clone();
let env = self.env.clone();
self.coordinator = Some(
thread::Builder::new()
.name("proxy-watch-linux".to_owned())
.spawn(move || {
let _guard = crate::watch::ThreadGuard::new(Arc::clone(&shared));
crate::trace::debug!("the Linux coordinator thread started");
coordinate(
&options,
&env,
&shared,
&triggers,
#[cfg(feature = "linux-kde")]
LossReport {
flags: kde_loss,
last_live_route: kde_is_the_last_live_route,
degraded: false,
},
);
})
.map_err(|source| Error::io("spawning the proxy-watch Linux thread", source))?,
);
Ok(())
}
fn has_live_notification_source(&self) -> bool {
if self.portal_route {
return false;
}
#[cfg(feature = "linux-gnome")]
if matches!(&self.gnome, Some(gnome) if gnome.is_live()) {
return true;
}
#[cfg(feature = "linux-kde")]
if matches!(&self.kde, Some(kde) if kde.is_live()) {
return true;
}
false
}
#[cfg_attr(
not(any(feature = "linux-gnome", feature = "linux-kde")),
allow(unused_mut)
)]
pub(crate) fn health(&self) -> BackendHealth {
let mut degraded = Vec::new();
#[cfg(feature = "linux-gnome")]
if matches!(&self.gnome, Some(GnomeWatch::Degraded)) {
degraded.push(crate::config::ProxyConfigSource::GSettings);
}
#[cfg(feature = "linux-kde")]
if matches!(&self.kde, Some(KdeWatch::Degraded))
|| matches!(&self.kde, Some(KdeWatch::Live(watch)) if watch.any_lost())
{
degraded.push(crate::config::ProxyConfigSource::Kioslaverc);
}
BackendHealth {
degraded,
has_live_notifications: self.has_live_notification_source(),
}
}
pub(crate) fn poll_now(&self) {
if let Some(trigger) = &self.trigger {
wake(trigger);
}
}
}
impl Drop for Watch {
fn drop(&mut self) {
crate::trace::debug!("stopping the Linux watcher");
#[cfg(feature = "linux-gnome")]
drop(self.gnome.take());
#[cfg(feature = "linux-kde")]
drop(self.kde.take());
drop(self.poll.take());
drop(self.trigger.take());
if let Some(thread) = self.coordinator.take() {
let _ = thread.join();
}
}
}
#[cfg_attr(
not(any(feature = "linux-gnome", feature = "linux-kde")),
allow(dead_code)
)]
fn is_leading_store(desktop: desktop::Desktop, store: desktop::Store) -> bool {
let leading = desktop::order(desktop)[0];
if desktop::is_compiled_in(leading) {
leading == store
} else {
leading != store
}
}
#[cfg_attr(
not(any(feature = "linux-gnome", feature = "linux-kde")),
allow(dead_code)
)]
const LEADING_STORE_HAS_NO_FALLBACK: &str = "this is the leading Linux desktop store — \
either the store belonging to the desktop XDG_CURRENT_DESKTOP says is actually \
running, or, when that desktop's store is not compiled into this build, the store \
that inherits the position — so there is no other source left to notice a later \
change with";
#[cfg(feature = "linux-kde")]
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
fn kde_watch_fail_soft(
trigger: SyncSender<()>,
leading: bool,
poll_interval: Option<Duration>,
env: &Env,
) -> Result<KdeWatch, Error> {
match watch_fail_soft(leading, poll_interval, super::kde::watch(trigger, env)) {
WatchFailSoft::Live(Some(watch)) => Ok(KdeWatch::Live(watch)),
WatchFailSoft::Live(None) => Ok(KdeWatch::NoDirectory),
WatchFailSoft::Degraded(error) => {
crate::trace::warning!(
error = %crate::trace::SafeError(&error),
"failed to watch kioslaverc for changes (for example, a hit \
fs.inotify.max_user_watches limit); continuing without kioslaverc \
change notifications"
);
Ok(KdeWatch::Degraded)
}
WatchFailSoft::Fatal(error) => Err(fatal_watch_error(
"watching kioslaverc for changes",
LEADING_STORE_HAS_NO_FALLBACK,
error,
)),
}
}
#[cfg(feature = "linux-gnome")]
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
fn gnome_start_fail_soft(
trigger: SyncSender<()>,
leading: bool,
poll_interval: Option<Duration>,
) -> Result<GnomeWatch, Error> {
match watch_fail_soft(leading, poll_interval, super::gnome::Handle::start(trigger)) {
WatchFailSoft::Live(Some(handle)) => Ok(GnomeWatch::Live(handle)),
WatchFailSoft::Live(None) => Ok(GnomeWatch::NoSchema),
WatchFailSoft::Degraded(error) => {
crate::trace::warning!(
error = %crate::trace::SafeError(&error),
"failed to start the GSettings change subscription (for example, a \
GMainContext that could not be acquired); continuing without GSettings \
change notifications"
);
Ok(GnomeWatch::Degraded)
}
WatchFailSoft::Fatal(error) => Err(fatal_watch_error(
"starting the GSettings change subscription",
LEADING_STORE_HAS_NO_FALLBACK,
error,
)),
}
}
#[cfg(feature = "linux-kde")]
struct LossReport {
flags: Option<super::kde::LossFlags>,
last_live_route: bool,
degraded: bool,
}
#[cfg(feature = "linux-kde")]
impl LossReport {
fn check(&mut self, shared: &Shared) {
let Some(flags) = &self.flags else {
return;
};
if !flags.any_lost() {
return;
}
if !self.degraded {
shared.degrade(crate::config::ProxyConfigSource::Kioslaverc);
self.degraded = true;
}
if flags.is_live() {
return;
}
if self.last_live_route {
shared.mark_no_live_notifications();
}
self.flags = None;
}
}
fn coordinate(
options: &WatchOptions,
env: &Env,
shared: &Shared,
triggers: &Receiver<()>,
#[cfg(feature = "linux-kde")] mut kde_loss: LossReport,
) {
while triggers.recv().is_ok() {
let deadline = Instant::now() + effective_debounce(options.debounce);
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
match triggers.recv_timeout(remaining) {
Ok(()) => {}
Err(RecvTimeoutError::Timeout) => break,
Err(RecvTimeoutError::Disconnected) => return,
}
}
#[cfg(feature = "linux-kde")]
kde_loss.check(shared);
crate::trace::debug!("the debounce window closed; re-reading every Linux source");
publish(env, shared);
}
}
fn publish(env: &Env, shared: &Shared) {
match read_config_in(env) {
Ok(config) => shared.emit(config),
Err(error) => shared.fail(error),
}
}
fn poll_wait(requested: Duration) -> Duration {
const CEILING: Duration = Duration::from_secs(24 * 60 * 60);
crate::watch::effective_poll_interval(requested).min(CEILING)
}
struct Poll {
stop: Option<Sender<()>>,
thread: Option<JoinHandle<()>>,
}
impl Poll {
fn start(trigger: SyncSender<()>, interval: std::time::Duration) -> Result<Self, Error> {
let (stop, stopped) = mpsc::channel::<()>();
let thread = thread::Builder::new()
.name("proxy-watch-poll".to_owned())
.spawn(move || {
while matches!(
stopped.recv_timeout(interval),
Err(RecvTimeoutError::Timeout)
) {
if !wake(&trigger) {
return;
}
}
})
.map_err(|source| Error::io("spawning the proxy-watch polling thread", source))?;
Ok(Self {
stop: Some(stop),
thread: Some(thread),
})
}
}
impl Drop for Poll {
fn drop(&mut self) {
drop(self.stop.take());
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn some_error() -> Error {
Error::Unsupported
}
#[test]
fn the_fatal_error_names_the_fix() {
let error = fatal_watch_error(
"watching kioslaverc for changes",
LEADING_STORE_HAS_NO_FALLBACK,
some_error(),
);
let message = error.to_string();
assert!(
message.contains("poll_interval"),
"the error should point at the fix: {message}"
);
assert!(
message.contains("watching kioslaverc for changes"),
"the error should still say what failed: {message}"
);
assert!(
message.contains("leading Linux desktop store"),
"the error should still say why there is no fallback: {message}"
);
}
#[cfg(any(feature = "linux-gnome", feature = "linux-kde"))]
#[test]
fn one_store_this_build_can_read_leads_every_desktop() {
for desktop in [
desktop::Desktop::Gnome,
desktop::Desktop::Kde,
desktop::Desktop::Unknown,
] {
let leading: Vec<_> = [desktop::Store::GSettings, desktop::Store::Kioslaverc]
.into_iter()
.filter(|store| is_leading_store(desktop, *store))
.collect();
assert_eq!(leading.len(), 1, "{desktop:?}");
assert!(desktop::is_compiled_in(leading[0]), "{desktop:?}");
#[cfg(not(feature = "linux-kde"))]
assert_eq!(leading[0], desktop::Store::GSettings, "{desktop:?}");
#[cfg(not(feature = "linux-gnome"))]
assert_eq!(leading[0], desktop::Store::Kioslaverc, "{desktop:?}");
}
}
#[cfg(any(feature = "linux-gnome", feature = "linux-kde"))]
#[test]
fn a_fail_softened_source_does_not_count_as_live() {
#[cfg(feature = "linux-gnome")]
{
assert!(!GnomeWatch::Degraded.is_live());
assert!(!GnomeWatch::NoSchema.is_live());
}
#[cfg(feature = "linux-kde")]
{
assert!(!KdeWatch::Degraded.is_live());
assert!(!KdeWatch::NoDirectory.is_live());
}
}
#[cfg(feature = "linux-kde")]
#[test]
fn a_kioslaverc_watch_that_lost_a_directory_is_degraded_while_the_rest_still_report() {
for (lost, live_directories, expect_degraded, expect_live) in [
(false, 2, false, true),
(true, 1, true, true),
(true, 0, true, false),
] {
let watch = Watch {
trigger: None,
triggers: None,
portal_route: false,
#[cfg(feature = "linux-gnome")]
gsettings_is_leading: false,
#[cfg(feature = "linux-gnome")]
gnome: None,
kde: Some(KdeWatch::Live(
super::super::kde::FileWatch::with_flags_for_test(lost, live_directories),
)),
poll: None,
coordinator: None,
env: Env::default(),
};
let health = watch.health();
let case = format!("lost={lost} live_directories={live_directories}");
assert_eq!(
health
.degraded
.contains(&crate::config::ProxyConfigSource::Kioslaverc),
expect_degraded,
"{case}: {:?}",
health.degraded
);
assert_eq!(health.has_live_notifications, expect_live, "{case}");
}
}
#[cfg(feature = "linux-kde")]
#[test]
fn a_survivor_of_the_cascade_keeps_the_route_out_of_the_dark() {
let shared = Shared::new(crate::config::ProxyConfig::direct());
shared.set_construction_health(crate::WatchHealth {
degraded: Vec::new(),
has_live_notifications: true,
poll_interval: None,
stopped: false,
});
let watch = super::super::kde::FileWatch::with_flags_for_test(false, 2);
let mut report = LossReport {
flags: Some(watch.loss_flags()),
last_live_route: true,
degraded: false,
};
report.check(&shared);
assert!(shared.health().degraded.is_empty(), "nothing lost yet");
assert!(shared.health().has_live_notifications);
watch.loss_flags().lose_one_for_test();
report.check(&shared);
assert_eq!(
shared.health().degraded,
vec![crate::config::ProxyConfigSource::Kioslaverc]
);
assert!(
shared.health().has_live_notifications,
"a cascade with a live directory left still reports changes to it"
);
watch.loss_flags().lose_one_for_test();
report.check(&shared);
assert!(!shared.health().has_live_notifications);
assert_eq!(
shared.health().degraded,
vec![crate::config::ProxyConfigSource::Kioslaverc],
"the degrade is reported once, not once per loss"
);
}
#[test]
fn a_wake_nobody_has_drained_yet_is_not_a_dead_coordinator() {
let (trigger, triggers) = mpsc::sync_channel::<()>(1);
assert!(wake(&trigger), "an empty buffer takes the wake");
assert!(
wake(&trigger),
"a wake already pending is the coalescing this channel is sized for"
);
drop(triggers);
assert!(!wake(&trigger), "no coordinator left to ask");
}
#[test]
fn every_poll_wait_stays_a_deadline_recv_timeout_can_represent() {
assert!(Instant::now().checked_add(Duration::MAX).is_none());
for requested in [
Duration::ZERO,
Duration::from_secs(30),
Duration::from_secs(u64::from(u32::MAX)),
Duration::MAX,
] {
assert!(
Instant::now().checked_add(poll_wait(requested)).is_some(),
"requested {requested:?}"
);
}
}
}