git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

commitd4df45b0cf627e81178dd912832809aa9d8212d5
parent57b1183852
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 22:47
perf: an idle settings app sleeps, and leaves the dGPU asleep

Measured in a scale-2 shadow, 15 s at rest (installed build -> this
one, both under strace):
  Accounts (the first page): frames 5 -> 0, ctxt switches 7191 -> 557
  System: spawns 9 -> 3 (uname, uptime gone; nvidia-smi only while the
          card is awake, which it was), ctxt 8407 -> 5622
  Power:  spawns 9 -> 6 (cce-power-apply gone), ctxt 7087 -> 4448

- Every worker refresh rebuilt the page, changed or not -- the ~3 fps
  the app idled at on its default page. poll_background_updates drops a
  snapshot equal to the last one its worker sent (fresh_snapshot, a
  hash of its Debug form). Processes is exempt: its refresh also
  settles pending kills. Any real action clears what was seen, so a
  refresh after an optimistic change (a Wi-Fi toggle that failed) still
  puts the page right.
- idle_poll_interval returned 250 ms unconditionally, and each of the
  14 page workers woke every 250 ms to check whether its page was up.
  Workers now wake the loop through the calloop sender (AppAction::Wake,
  which rebuilds nothing), as do the backup/package tasks that feed the
  two std channels, and a worker for a page not on screen sleeps on a
  tokio watch of the current page instead of polling an AtomicU8.
- nvidia-smi for the GPU temperature (System) and the power limits
  (Power) ran every 5 s with no dgpu_awake() guard, waking a runtime-
  suspended RTX 4080 -- processes.rs already guarded the same call. The
  temperature is now skipped while the card sleeps; the limits keep the
  last answer while it sleeps, unless the power source or the plan has
  changed since (the root helper re-applies a limit then), and a limit
  the page sets updates that answer.
- helper_speaks_modes ran /usr/bin/cce-power-apply on every Power
  refresh to read its usage text; the answer is kept per path + mtime.
- The System page reads the kernel and uptime from /proc instead of
  spawning uname/uptime, and takes CPU busy since the previous refresh
  instead of sleeping 100 ms inside every fetch.
- The Accounts page fetched every account's secret over D-Bus every
  3 s to learn whether one exists; the probe is reused while the
  account list is unchanged, up to 30 s, and dropped by any action on
  the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/app.rs               |   3 +
 src/input_handler.rs     |   4 +-
 src/main.rs              | 109 ++++++++++++++++++++++++++---
 src/pages/accounts.rs    |  33 ++++++++-
 src/pages/power.rs       |  52 +++++++++++---
 src/pages/system_info.rs |  68 +++++++++++++++---
 src/power_plan.rs        |  18 ++++-
 src/watchers.rs          | 177 +++++++++++++++++++++++++----------------------
 8 files changed, 351 insertions(+), 113 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 395e902..b13e9ee 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -107,6 +107,9 @@ impl AppState {
 #[derive(Debug, Clone)]
 pub enum AppAction {
     Exit,
+    /// A page worker sent a snapshot; the next tick drains it. Carries
+    /// nothing and rebuilds nothing by itself.
+    Wake,
     Audio(audio::AudioMessage),
     Browser(browser::BrowserMessage),
     DefaultApps(default_apps::DefaultAppsMessage),
diff --git a/src/input_handler.rs b/src/input_handler.rs
index ab12f31..53a662c 100644
--- a/src/input_handler.rs
+++ b/src/input_handler.rs
@@ -194,7 +194,7 @@ impl SystemInterface {
                     self.focused_section = None;
                     let new_page = Page::ALL[idx];
                     self.app.current_page = new_page;
-                    self.current_page_shared.store(idx as u8, std::sync::atomic::Ordering::SeqCst);
+                    self.current_page_shared.send_replace(idx as u8);
                     self.scroll_y = 0.0;
                 }
             }
@@ -547,7 +547,7 @@ impl SystemInterface {
                     cce_ui::widget::focus::clear_focus(Some(&mut self.ui_context));
                     self.focused_section = None;
                     self.app.current_page = Page::ALL[idx];
-                    self.current_page_shared.store(idx as u8, std::sync::atomic::Ordering::SeqCst);
+                    self.current_page_shared.send_replace(idx as u8);
                     self.scroll_y = 0.0;
                     self.needs_rebuild = true;
                     return true;
diff --git a/src/main.rs b/src/main.rs
index 0984b93..87b15ff 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -173,7 +173,11 @@ struct SystemInterface {
     sans_serif_family: String,
     serif_family: String,
     monospace_family: String,
-    current_page_shared: std::sync::Arc<std::sync::atomic::AtomicU8>,
+    /// The page on screen, as the page workers see it (`watchers::PageWatch`).
+    current_page_shared: tokio::sync::watch::Sender<u8>,
+    /// A hash of the last snapshot each page worker sent, so an unchanged
+    /// refresh is dropped instead of rebuilding the page (`fresh_snapshot`).
+    seen_snapshots: std::collections::HashMap<&'static str, u64>,
     sender: calloop::channel::Sender<AppAction>,
     ui_context: cce_ui::context::UiContext,
     scroll_logs: Vec<String>,
@@ -215,10 +219,16 @@ impl cce_ui::engine::Application for SystemInterface {
 
         // ── Background refresh channels ──
         let initial_page_idx = INITIAL_PAGE_INDEX.load(std::sync::atomic::Ordering::SeqCst);
-        let current_page_shared = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(initial_page_idx as u8));
+        let (current_page_shared, page_rx) = tokio::sync::watch::channel(initial_page_idx as u8);
+        let wake: cce_settings::watchers::Wake = {
+            let sender = sender.clone();
+            std::sync::Arc::new(move || {
+                let _ = sender.send(AppAction::Wake);
+            })
+        };
 
         let (watchers, tx_backup, rx_backup, tx_update, rx_update) =
-            cce_settings::watchers::spawn_all(current_page_shared.clone());
+            cce_settings::watchers::spawn_all(page_rx, wake);
 
         let (sans_family, serif_family, monospace_family, _) = cce_ui::layout::read_preferred_fonts();
 
@@ -301,6 +311,7 @@ impl cce_ui::engine::Application for SystemInterface {
             serif_family,
             monospace_family,
             current_page_shared,
+            seen_snapshots: std::collections::HashMap::new(),
             sender,
             ui_context: cce_ui::context::UiContext::new(),
             scroll_logs: Vec::new(),
@@ -333,18 +344,16 @@ impl cce_ui::engine::Application for SystemInterface {
             *exit = true;
             return;
         }
+        // Wakes the loop so `tick` drains the workers' channels; whether
+        // anything is redrawn is `poll_background_updates`' call.
+        if let AppAction::Wake = msg {
+            return;
+        }
         self.handle_action(&msg);
         *needs_rebuild = true;
         self.needs_rebuild = true;
     }
 
-    /// `poll_background_updates` drains sixteen std channels fed by the
-    /// page workers; the runner cannot see them, so it may not sleep past
-    /// this between ticks.
-    fn idle_poll_interval(&self) -> Option<std::time::Duration> {
-        Some(std::time::Duration::from_millis(250))
-    }
-
     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
         if self.needs_rebuild {
             self.rebuild_layout(self.width as f32, self.height as f32);
@@ -775,39 +784,72 @@ impl SystemInterface {
         moved || self.page_scroll_motion.is_animating()
     }
 
+    /// Whether a page worker's snapshot differs from the last one it sent.
+    /// The workers resend the whole state on every period whether or not
+    /// anything moved; applying an identical one changed nothing on screen
+    /// but rebuilt the page and redrew the window — the settings app sat at
+    /// ~3 fps at rest on its default page. Processes is not checked: its
+    /// refresh also settles pending kills (`killing.clear()`), and its CPU
+    /// column moves every time anyway.
+    fn fresh_snapshot<T: std::fmt::Debug>(&mut self, key: &'static str, snapshot: &T) -> bool {
+        use std::hash::{Hash, Hasher};
+        let mut h = std::collections::hash_map::DefaultHasher::new();
+        format!("{snapshot:?}").hash(&mut h);
+        let hash = h.finish();
+        self.seen_snapshots.insert(key, hash) != Some(hash)
+    }
+
     fn poll_background_updates(&mut self) {
         use pages::*;
         while let Ok(s) = self.rx_audio.try_recv() {
+            if !self.fresh_snapshot("audio", &s) {
+                continue;
+            }
             audio::update(&mut self.app.audio, audio::AudioMessage::Refreshed(s));
             if self.app.current_page == Page::Audio {
                 self.needs_rebuild = true;
             }
         }
         while let Ok(s) = self.rx_network.try_recv() {
+            if !self.fresh_snapshot("network", &s) {
+                continue;
+            }
             network::update(&mut self.app.network, network::NetworkMessage::Refreshed(s));
             if self.app.current_page == Page::Network {
                 self.needs_rebuild = true;
             }
         }
         while let Ok(s) = self.rx_timers.try_recv() {
+            if !self.fresh_snapshot("timers", &s) {
+                continue;
+            }
             pages::timers::update(&mut self.app.timers, pages::timers::TimersMessage::Refreshed(s));
             if self.app.current_page == Page::Timers {
                 self.needs_rebuild = true;
             }
         }
         while let Ok(s) = self.rx_bluetooth.try_recv() {
+            if !self.fresh_snapshot("bluetooth", &s) {
+                continue;
+            }
             pages::bluetooth::update(&mut self.app.bluetooth, pages::bluetooth::BluetoothMessage::Refreshed(s));
             if self.app.current_page == Page::Bluetooth {
                 self.needs_rebuild = true;
             }
         }
         while let Ok(s) = self.rx_power.try_recv() {
+            if !self.fresh_snapshot("power", &s) {
+                continue;
+            }
             pages::power::update(&mut self.app.power, pages::power::PowerMessage::Refreshed(s));
             if self.app.current_page == Page::Power {
                 self.needs_rebuild = true;
             }
         }
         while let Ok(s) = self.rx_system.try_recv() {
+            if !self.fresh_snapshot("system", &s) {
+                continue;
+            }
             system_info::update(&mut self.app.system_info, system_info::SystemMessage::Refreshed(s), &mut self.ui_context);
             if self.app.current_page == Page::System {
                 self.needs_rebuild = true;
@@ -820,30 +862,45 @@ impl SystemInterface {
             }
         }
         while let Ok(s) = self.rx_storage.try_recv() {
+            if !self.fresh_snapshot("storage", &s) {
+                continue;
+            }
             storage::update(&mut self.app.storage, storage::StorageMessage::Refreshed(s));
             if self.app.current_page == Page::Storage {
                 self.needs_rebuild = true;
             }
         }
         while let Ok(s) = self.rx_notifications.try_recv() {
+            if !self.fresh_snapshot("notifications", &s) {
+                continue;
+            }
             pages::notifications::update(&mut self.app.notifications, pages::notifications::NotificationsMessage::Refreshed(s));
             if self.app.current_page == Page::Notifications {
                 self.needs_rebuild = true;
             }
         }
         while let Ok(s) = self.rx_browser.try_recv() {
+            if !self.fresh_snapshot("browser", &s) {
+                continue;
+            }
             pages::browser::update(&mut self.app.browser, pages::browser::BrowserMessage::Refreshed(s));
             if self.app.current_page == Page::Browser {
                 self.needs_rebuild = true;
             }
         }
         while let Ok(s) = self.rx_default_apps.try_recv() {
+            if !self.fresh_snapshot("default_apps", &s) {
+                continue;
+            }
             pages::default_apps::update(&mut self.app.default_apps, pages::default_apps::DefaultAppsMessage::Refreshed(s));
             if self.app.current_page == Page::DefaultApps {
                 self.needs_rebuild = true;
             }
         }
         while let Ok(s) = self.rx_services.try_recv() {
+            if !self.fresh_snapshot("services", &s) {
+                continue;
+            }
             services::update(&mut self.app.services, services::ServicesMessage::Refreshed(s));
             if self.app.current_page == Page::Services {
                 self.needs_rebuild = true;
@@ -851,6 +908,9 @@ impl SystemInterface {
         }
 
         while let Ok(s) = self.rx_accounts.try_recv() {
+            if !self.fresh_snapshot("accounts", &s) {
+                continue;
+            }
             accounts::update(&mut self.app.accounts, accounts::AccountsMessage::Refreshed(s));
             if self.app.current_page == Page::Accounts {
                 self.needs_rebuild = true;
@@ -863,6 +923,9 @@ impl SystemInterface {
             }
         }
         while let Ok(s) = self.rx_packages.try_recv() {
+            if !self.fresh_snapshot("packages", &s) {
+                continue;
+            }
             pages::packages::update(&mut self.app.packages, pages::packages::PackagesMessage::Refreshed(s));
             if self.app.current_page == Page::Packages {
                 self.needs_rebuild = true;
@@ -878,8 +941,18 @@ impl SystemInterface {
 
     fn handle_action(&mut self, action: &AppAction) {
         use pages::*;
+        // An action may change page state ahead of the system (a Wi-Fi
+        // toggle flips `wifi_enabled` before nmcli answers). If it fails, the
+        // next refresh equals the last one sent — and must still be applied
+        // to put the page right, so forget what was seen.
+        if !matches!(action, AppAction::Exit | AppAction::Wake) {
+            self.seen_snapshots.clear();
+        }
+        if matches!(action, AppAction::Accounts(_)) {
+            accounts::invalidate_keyring_probe();
+        }
         match action {
-            AppAction::Exit => {}
+            AppAction::Exit | AppAction::Wake => {}
             AppAction::Audio(m) => audio::update(&mut self.app.audio, m.clone()),
             AppAction::Network(m) => network::update(&mut self.app.network, m.clone()),
             AppAction::Bluetooth(m) => pages::bluetooth::update(&mut self.app.bluetooth, m.clone()),
@@ -895,9 +968,11 @@ impl SystemInterface {
                 pages::storage::StorageMessage::StartBackup => {
                     pages::storage::update(&mut self.app.storage, pages::storage::StorageMessage::StartBackup);
                     let tx = self.tx_backup.clone();
+                    let wake = self.sender.clone();
                     tokio::spawn(async move {
                         let res = pages::storage::run_backup().await;
                         let _ = tx.send(pages::storage::StorageMessage::BackupFinished(res));
+                        let _ = wake.send(AppAction::Wake);
                     });
                 }
                 _ => pages::storage::update(&mut self.app.storage, m.clone()),
@@ -930,18 +1005,22 @@ impl SystemInterface {
                 pages::packages::PackagesMessage::StartUpdate => {
                     pages::packages::update(&mut self.app.packages, pages::packages::PackagesMessage::StartUpdate);
                     let tx = self.tx_update.clone();
+                    let wake = self.sender.clone();
                     tokio::spawn(async move {
                         let res = pages::packages::run_update().await;
                         let _ = tx.send(pages::packages::PackagesMessage::UpdateFinished(res));
+                        let _ = wake.send(AppAction::Wake);
                     });
                 }
                 pages::packages::PackagesMessage::UpdateFinished(res) => {
                     pages::packages::update(&mut self.app.packages, m.clone());
                     if res.is_ok() {
                         let tx = self.tx_update.clone();
+                        let wake = self.sender.clone();
                         tokio::spawn(async move {
                             let new_state = pages::packages::fetch_packages_state().await;
                             let _ = tx.send(pages::packages::PackagesMessage::Refreshed(new_state));
+                            let _ = wake.send(AppAction::Wake);
                         });
                     }
                 }
@@ -950,18 +1029,22 @@ impl SystemInterface {
                     let is_installed = self.app.packages.active_tab == pages::packages::PackageTab::Installed;
                     pages::packages::update(&mut self.app.packages, m.clone());
                     let tx = self.tx_update.clone();
+                    let wake = self.sender.clone();
                     tokio::spawn(async move {
                         let res = pages::packages::fetch_package_info(name_clone.clone(), is_installed).await;
                         let _ = tx.send(pages::packages::PackagesMessage::InfoFetched(name_clone, res));
+                        let _ = wake.send(AppAction::Wake);
                     });
                 }
                 pages::packages::PackagesMessage::SelectAndScrollPackage(ref name) => {
                     let name_clone = name.clone();
                     pages::packages::update(&mut self.app.packages, m.clone());
                     let tx = self.tx_update.clone();
+                    let wake = self.sender.clone();
                     tokio::spawn(async move {
                         let res = pages::packages::fetch_package_info(name_clone.clone(), true).await;
                         let _ = tx.send(pages::packages::PackagesMessage::InfoFetched(name_clone, res));
+                        let _ = wake.send(AppAction::Wake);
                     });
                 }
                 pages::packages::PackagesMessage::StartUninstall(ref name) => {
@@ -969,9 +1052,11 @@ impl SystemInterface {
                         pages::packages::update(&mut self.app.packages, m.clone());
                         let name_clone = name.clone();
                         let tx = self.tx_update.clone();
+                        let wake = self.sender.clone();
                         tokio::spawn(async move {
                             let res = pages::packages::run_uninstall(name_clone).await;
                             let _ = tx.send(pages::packages::PackagesMessage::UninstallFinished(res));
+                            let _ = wake.send(AppAction::Wake);
                         });
                     }
                 }
@@ -979,9 +1064,11 @@ impl SystemInterface {
                     pages::packages::update(&mut self.app.packages, m.clone());
                     if res.is_ok() {
                         let tx = self.tx_update.clone();
+                        let wake = self.sender.clone();
                         tokio::spawn(async move {
                             let new_state = pages::packages::fetch_packages_state().await;
                             let _ = tx.send(pages::packages::PackagesMessage::Refreshed(new_state));
+                            let _ = wake.send(AppAction::Wake);
                         });
                     }
                 }
diff --git a/src/pages/accounts.rs b/src/pages/accounts.rs
index 5f7dbad..7ee0759 100644
--- a/src/pages/accounts.rs
+++ b/src/pages/accounts.rs
@@ -219,12 +219,42 @@ fn commit(state: &mut AccountsState, change: impl FnOnce(&mut Vec<AccountInfo>))
     }
 }
 
+/// The last keyring probe: which accounts it covered (their Debug form,
+/// hashed), when, and what it found.
+static KEYRING_PROBE: std::sync::Mutex<Option<(u64, std::time::Instant, Vec<(String, KeyringStatus)>)>> =
+    std::sync::Mutex::new(None);
+
+/// How long a keyring probe stands while the account list is unchanged.
+const KEYRING_PROBE_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(30);
+
+/// Forget the last keyring probe, so the next refresh asks again — after
+/// anything this page does to an account or its secret.
+pub fn invalidate_keyring_probe() {
+    *KEYRING_PROBE.lock().unwrap() = None;
+}
+
 pub async fn fetch_accounts() -> AccountsSnapshot {
     let accounts = load_accounts();
+    // The probe fetches each account's secret over D-Bus to learn whether it
+    // exists, waking the keyring. This page refreshes every 3 s — it is the
+    // app's first page — so the probe is reused while the account list is
+    // the same, for up to KEYRING_PROBE_MAX_AGE; this page's own actions
+    // invalidate it (`invalidate_keyring_probe`).
+    let accounts_key = {
+        use std::hash::{Hash, Hasher};
+        let mut h = std::collections::hash_map::DefaultHasher::new();
+        format!("{accounts:?}").hash(&mut h);
+        h.finish()
+    };
+    if let Some((key, at, keyring)) = KEYRING_PROBE.lock().unwrap().as_ref() {
+        if *key == accounts_key && at.elapsed() < KEYRING_PROBE_MAX_AGE {
+            return AccountsSnapshot { accounts, keyring: keyring.clone() };
+        }
+    }
     // Secret Service lookups are synchronous DBus; keep them off the async
     // workers (a wedged provider used to block for 12s at a time).
     let probe = accounts.clone();
-    let keyring = tokio::task::spawn_blocking(move || {
+    let keyring: Vec<(String, KeyringStatus)> = tokio::task::spawn_blocking(move || {
         probe
             .iter()
             .filter_map(|acc| {
@@ -237,6 +267,7 @@ pub async fn fetch_accounts() -> AccountsSnapshot {
     })
     .await
     .unwrap_or_default();
+    *KEYRING_PROBE.lock().unwrap() = Some((accounts_key, std::time::Instant::now(), keyring.clone()));
     AccountsSnapshot { accounts, keyring }
 }
 
diff --git a/src/pages/power.rs b/src/pages/power.rs
index 0e31dc7..0e93a6f 100644
--- a/src/pages/power.rs
+++ b/src/pages/power.rs
@@ -408,8 +408,40 @@ pub async fn fetch_power_state() -> PowerFacts {
         f.governor = read_trim("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor").unwrap_or_default();
     }
 
-    // One nvidia-smi call for all three watt figures; any failure (no driver,
-    // module not loaded, no card) leaves them None and hides the dropdown.
+    (f.gpu_limit_w, f.gpu_default_w, f.gpu_min_w) = gpu_limits(gpu_limits_key(&f)).await;
+
+    f
+}
+
+/// The NVIDIA power limits `(current, default, minimum)` in watts, the last
+/// time nvidia-smi was asked, and what it was asked under.
+static GPU_LIMITS: std::sync::Mutex<Option<(u64, GpuLimits)>> = std::sync::Mutex::new(None);
+type GpuLimits = (Option<u32>, Option<u32>, Option<u32>);
+
+/// What decides the current limit: the power source and the plan — the
+/// root helper re-applies a mode's limit when either changes.
+fn gpu_limits_key(f: &PowerFacts) -> u64 {
+    use std::hash::{Hash, Hasher};
+    let mut h = std::collections::hash_map::DefaultHasher::new();
+    format!("{:?}|{:?}", f.source, f.plan).hash(&mut h);
+    h.finish()
+}
+
+/// The power limits, from one nvidia-smi call for all three; any failure
+/// (no driver, module not loaded, no card) leaves them None and hides the
+/// dropdown. The query wakes a runtime-suspended card, so while the card
+/// sleeps the last answer stands unless the source or the plan moved since —
+/// until 2026-10-05 the page asked every five seconds, which held the dGPU
+/// awake the whole time it was open.
+async fn gpu_limits(key: u64) -> GpuLimits {
+    if !crate::power_meter::dgpu_awake() {
+        if let Some((k, limits)) = *GPU_LIMITS.lock().unwrap() {
+            if k == key {
+                return limits;
+            }
+        }
+    }
+    let mut limits: GpuLimits = (None, None, None);
     if let Ok(out) = tokio::process::Command::new("nvidia-smi")
         .args(["--query-gpu=power.limit,power.default_limit,power.min_limit", "--format=csv,noheader,nounits"])
         .output()
@@ -422,14 +454,12 @@ pub async fn fetch_power_state() -> PowerFacts {
                     .split(',')
                     .map(|c| c.trim().parse::<f32>().ok().map(|v| v.round() as u32))
                     .collect();
-                f.gpu_limit_w = w.first().copied().flatten();
-                f.gpu_default_w = w.get(1).copied().flatten();
-                f.gpu_min_w = w.get(2).copied().flatten();
+                limits = (w.first().copied().flatten(), w.get(1).copied().flatten(), w.get(2).copied().flatten());
             }
         }
     }
-
-    f
+    *GPU_LIMITS.lock().unwrap() = Some((key, limits));
+    limits
 }
 
 /// One `<key>=<n>s` field of `ccectl idle status`.
@@ -569,7 +599,13 @@ fn set_live(lever: Lever, value: &str, f: &mut PowerFacts) {
         Lever::Turbo => f.turbo = Some(value == "on"),
         Lever::IgpuMaxMhz => f.igpu_mhz = value.parse().ok(),
         Lever::AudioIdleSecs => f.hda_idle_secs = value.parse().ok(),
-        Lever::GpuLimitW => f.gpu_limit_w = value.parse().ok(),
+        Lever::GpuLimitW => {
+            f.gpu_limit_w = value.parse().ok();
+            // The page set it: what a sleeping card's cached answer says now.
+            if let Some((_, limits)) = GPU_LIMITS.lock().unwrap().as_mut() {
+                limits.0 = f.gpu_limit_w;
+            }
+        }
         Lever::Animations => f.animations = value == "on",
         Lever::IdleDisplayOffSecs => f.idle_display_off_secs = value.parse().ok(),
         Lever::IdleSleepSecs => f.idle_sleep_secs = value.parse().ok(),
diff --git a/src/pages/system_info.rs b/src/pages/system_info.rs
index f75b79c..1b9caab 100644
--- a/src/pages/system_info.rs
+++ b/src/pages/system_info.rs
@@ -212,7 +212,33 @@ fn read_thinkpad_gpu_temp() -> Option<f32> {
     None
 }
 
+/// `uptime -p`'s wording ("1 day, 8 hours, 13 minutes"), from seconds.
+fn pretty_uptime(secs: u64) -> String {
+    let units = [("year", 365 * 86400), ("week", 7 * 86400), ("day", 86400), ("hour", 3600), ("minute", 60)];
+    let mut rest = secs;
+    let mut parts = Vec::new();
+    for (name, size) in units {
+        let n = rest / size;
+        rest %= size;
+        if n > 0 {
+            parts.push(format!("{n} {name}{}", if n == 1 { "" } else { "s" }));
+        }
+    }
+    if parts.is_empty() {
+        "0 minutes".to_string()
+    } else {
+        parts.join(", ")
+    }
+}
+
+/// nvidia-smi's temperature, asked for only while the card is awake — the
+/// query wakes a runtime-suspended card (processes.rs makes the same check
+/// for its draw figure). Until 2026-10-05 the System page asked every five
+/// seconds regardless, which kept the dGPU out of D3cold while it was open.
 async fn read_nvidia_gpu_temp() -> Option<f32> {
+    if !crate::power_meter::dgpu_awake() {
+        return None;
+    }
     let out = tokio::process::Command::new("nvidia-smi")
         .args(["--query-gpu=temperature.gpu", "--format=csv,noheader,nounits"])
         .output().await.ok()?;
@@ -452,14 +478,16 @@ pub async fn fetch_system_state() -> SystemInfo {
         .and_then(|h| h.trim().split('.').next().map(|s| s.to_string()))
         .unwrap_or_default();
 
-    let kernel = tokio::process::Command::new("uname")
-        .args(["-r"]).output().await.ok()
-        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
+    // Both from /proc rather than spawning `uname -r` and `uptime -p` on
+    // every refresh.
+    let kernel = std::fs::read_to_string("/proc/sys/kernel/osrelease")
+        .map(|k| k.trim().to_string())
         .unwrap_or_default();
 
-    let uptime = tokio::process::Command::new("uptime")
-        .args(["-p"]).output().await.ok()
-        .map(|o| String::from_utf8_lossy(&o.stdout).trim().trim_start_matches("up ").to_string())
+    let uptime = std::fs::read_to_string("/proc/uptime")
+        .ok()
+        .and_then(|u| u.split_whitespace().next()?.parse::<f64>().ok())
+        .map(|secs| pretty_uptime(secs as u64))
         .unwrap_or_default();
 
     static CPU_INFO: std::sync::OnceLock<(String, u32)> = std::sync::OnceLock::new();
@@ -493,9 +521,20 @@ pub async fn fetch_system_state() -> SystemInfo {
             let idle = vals.get(3).copied().unwrap_or(0);
             Some((idle, total))
         };
-        let (idle1, total1) = read_stat().unwrap_or((0, 1));
-        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+        // Busy share since the previous refresh; the first one samples a
+        // 100 ms window, as every refresh used to.
+        static LAST: std::sync::Mutex<Option<(u64, u64)>> = std::sync::Mutex::new(None);
+        let prev = *LAST.lock().unwrap();
+        let (idle1, total1) = match prev {
+            Some(p) => p,
+            None => {
+                let first = read_stat().unwrap_or((0, 1));
+                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+                first
+            }
+        };
         let (idle2, total2) = read_stat().unwrap_or((0, 1));
+        *LAST.lock().unwrap() = Some((idle2, total2));
         let d_idle = idle2.saturating_sub(idle1);
         let d_total = total2.saturating_sub(total1);
         if d_total > 0 {
@@ -960,3 +999,16 @@ mod tests {
         assert!(!pc.rects.is_empty() || !pc.texts.is_empty());
     }
 }
+
+#[cfg(test)]
+mod uptime_tests {
+    use super::pretty_uptime;
+
+    #[test]
+    fn reads_like_uptime_p() {
+        assert_eq!(pretty_uptime(30), "0 minutes");
+        assert_eq!(pretty_uptime(60), "1 minute");
+        assert_eq!(pretty_uptime(86400 + 8 * 3600 + 13 * 60 + 5), "1 day, 8 hours, 13 minutes");
+        assert_eq!(pretty_uptime(15 * 86400), "2 weeks, 1 day");
+    }
+}
diff --git a/src/power_plan.rs b/src/power_plan.rs
index d616c33..232e7a5 100644
--- a/src/power_plan.rs
+++ b/src/power_plan.rs
@@ -491,10 +491,24 @@ pub enum Automation {
 /// authentication and changes nothing. A helper that knows modes but
 /// predates a lever fails the same way for that lever alone, which is why
 /// the lever list is part of the check.
+///
+/// The answer is kept per path and modification time: the Power page asks
+/// on every five-second refresh, and until 2026-10-05 each one ran the
+/// helper. A reinstalled helper has a new mtime and is asked again.
 pub fn helper_speaks_modes(path: &Path) -> bool {
-    std::process::Command::new(path)
+    type Seen = Vec<(PathBuf, Option<std::time::SystemTime>, bool)>;
+    static SEEN: std::sync::Mutex<Seen> = std::sync::Mutex::new(Vec::new());
+    let mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
+    if let Some(&(_, _, ok)) = SEEN.lock().unwrap().iter().find(|(p, t, _)| p == path && *t == mtime) {
+        return ok;
+    }
+    let ok = std::process::Command::new(path)
         .output()
-        .is_ok_and(|out| usage_speaks_modes(&String::from_utf8_lossy(&out.stderr)))
+        .is_ok_and(|out| usage_speaks_modes(&String::from_utf8_lossy(&out.stderr)));
+    let mut seen = SEEN.lock().unwrap();
+    seen.retain(|(p, _, _)| p != path);
+    seen.push((path.to_path_buf(), mtime, ok));
+    ok
 }
 
 /// The usage text of a helper that knows about modes names `apply-mode`
diff --git a/src/watchers.rs b/src/watchers.rs
index fa1bcce..7dafce1 100644
--- a/src/watchers.rs
+++ b/src/watchers.rs
@@ -1,8 +1,16 @@
 use std::sync::Arc;
-use std::sync::atomic::{AtomicU8, Ordering};
 use std::sync::mpsc::{channel, Receiver, Sender};
 use crate::pages::{Page, audio, bluetooth, browser, default_apps, network, processes, services, system_info, storage, packages, accounts, notifications, timers, power};
 
+/// The page on screen, published by the UI (`watch::Sender::send_replace`)
+/// to every page worker.
+pub type PageWatch = tokio::sync::watch::Receiver<u8>;
+
+/// Wakes the UI loop after a worker sends: the runner cannot see the std
+/// channels below, and until 2026-10-05 the app made it poll them every
+/// 250 ms (`idle_poll_interval`) whatever page was open.
+pub type Wake = Arc<dyn Fn() + Send + Sync>;
+
 pub struct Watchers {
     pub rx_audio: Receiver<audio::AudioState>,
     pub rx_network: Receiver<network::NetworkState>,
@@ -20,40 +28,95 @@ pub struct Watchers {
     pub rx_packages: Receiver<packages::PackagesState>,
 }
 
-fn spawn_bg_active<T, F>(
-    current_page_shared: Arc<AtomicU8>,
-    target_page_idx: u8,
+/// One page's worker: while `target_page` is on screen, fetch every
+/// `period_secs` (and at once on arriving, if the last fetch is that old),
+/// send the result, wake the UI. While another page is up it sleeps until the
+/// page changes — until 2026-10-05 each of the fourteen workers woke every
+/// 250 ms to look, whatever was open. `None` from `f` sends nothing.
+fn spawn_page_worker<T, F, Fut>(
+    mut page: PageWatch,
+    target_page: Page,
     period_secs: u64,
-    f: fn() -> F,
+    wake: Wake,
+    f: F,
 ) -> Receiver<T>
 where
     T: Send + 'static,
-    F: std::future::Future<Output = T> + Send + 'static,
+    F: Fn() -> Fut + Send + 'static,
+    Fut: std::future::Future<Output = Option<T>> + Send + 'static,
 {
+    let target = target_page.index() as u8;
+    let period = std::time::Duration::from_secs(period_secs);
     let (tx, rx) = channel::<T>();
     tokio::spawn(async move {
         let mut last_fetch: Option<std::time::Instant> = None;
         loop {
-            let current_page = current_page_shared.load(Ordering::SeqCst);
-            if current_page == target_page_idx {
-                let should_fetch = match last_fetch {
-                    None => true,
-                    Some(t) => t.elapsed() >= std::time::Duration::from_secs(period_secs),
-                };
-                if should_fetch {
-                    let val = f().await;
-                    if tx.send(val).is_err() { break; }
-                    last_fetch = Some(std::time::Instant::now());
+            if *page.borrow_and_update() != target {
+                if page.changed().await.is_err() {
+                    break;
+                }
+                continue;
+            }
+            if last_fetch.is_none_or(|t| t.elapsed() >= period) {
+                if let Some(val) = f().await {
+                    if tx.send(val).is_err() {
+                        break;
+                    }
+                    wake();
+                }
+                last_fetch = Some(std::time::Instant::now());
+            }
+            let due = period.saturating_sub(last_fetch.map_or(period, |t| t.elapsed()));
+            tokio::select! {
+                _ = tokio::time::sleep(due) => {}
+                changed = page.changed() => {
+                    if changed.is_err() {
+                        break;
+                    }
                 }
             }
-            tokio::time::sleep(std::time::Duration::from_millis(250)).await;
         }
     });
     rx
 }
 
+/// [`spawn_page_worker`] over an async fetch that always answers.
+fn spawn_bg_active<T, F>(
+    page: PageWatch,
+    target_page: Page,
+    period_secs: u64,
+    wake: Wake,
+    f: fn() -> F,
+) -> Receiver<T>
+where
+    T: Send + 'static,
+    F: std::future::Future<Output = T> + Send + 'static,
+{
+    spawn_page_worker(page, target_page, period_secs, wake, move || {
+        let fut = f();
+        async move { Some(fut.await) }
+    })
+}
+
+/// [`spawn_page_worker`] over a blocking read, run off the runtime's workers.
+fn spawn_bg_blocking<T>(
+    page: PageWatch,
+    target_page: Page,
+    period_secs: u64,
+    wake: Wake,
+    f: fn() -> T,
+) -> Receiver<T>
+where
+    T: Send + 'static,
+{
+    spawn_page_worker(page, target_page, period_secs, wake, move || async move {
+        tokio::task::spawn_blocking(f).await.ok()
+    })
+}
+
 pub fn spawn_all(
-    current_page_shared: Arc<AtomicU8>,
+    page: PageWatch,
+    wake: Wake,
 ) -> (
     Watchers,
     Sender<storage::StorageMessage>,
@@ -61,76 +124,28 @@ pub fn spawn_all(
     Sender<packages::PackagesMessage>,
     Receiver<packages::PackagesMessage>,
 ) {
-    let rx_audio = spawn_bg_active(current_page_shared.clone(), Page::Audio.index() as u8, 3, || audio::fetch_audio_state());
-    let rx_network = spawn_bg_active(current_page_shared.clone(), Page::Network.index() as u8, 5, || network::fetch_network_state());
-    let rx_bluetooth = spawn_bg_active(current_page_shared.clone(), Page::Bluetooth.index() as u8, 5, || bluetooth::fetch_bluetooth_page_state());
-    let rx_power = spawn_bg_active(current_page_shared.clone(), Page::Power.index() as u8, 5, || power::fetch_power_state());
-
-    let rx_system = spawn_bg_active(current_page_shared.clone(), Page::System.index() as u8, 5, || system_info::fetch_system_state());
-    let rx_processes = spawn_bg_active(current_page_shared.clone(), Page::Processes.index() as u8, 3, || processes::fetch_processes_state());
-    let rx_storage = spawn_bg_active(current_page_shared.clone(), Page::Storage.index() as u8, 10, || storage::fetch_storage_state());
+    let w = || (page.clone(), wake.clone());
+    let (p, k) = w(); let rx_audio = spawn_bg_active(p, Page::Audio, 3, k, || audio::fetch_audio_state());
+    let (p, k) = w(); let rx_network = spawn_bg_active(p, Page::Network, 5, k, || network::fetch_network_state());
+    let (p, k) = w(); let rx_bluetooth = spawn_bg_active(p, Page::Bluetooth, 5, k, || bluetooth::fetch_bluetooth_page_state());
+    let (p, k) = w(); let rx_power = spawn_bg_active(p, Page::Power, 5, k, || power::fetch_power_state());
 
-    let rx_notifications = {
-        let (tx, rx) = channel::<notifications::NotificationsConfig>();
-        let current_page_shared = current_page_shared.clone();
-        tokio::spawn(async move {
-            let mut last_fetch: Option<std::time::Instant> = None;
-            loop {
-                let current_page = current_page_shared.load(Ordering::SeqCst);
-                if current_page == Page::Notifications.index() as u8 {
-                    let should_fetch = match last_fetch {
-                        None => true,
-                        Some(t) => t.elapsed() >= std::time::Duration::from_secs(30),
-                    };
-                    if should_fetch {
-                        let val = tokio::task::spawn_blocking(|| notifications::read_notifications_config()).await;
-                        if let Ok(val) = val {
-                            if tx.send(val).is_err() { break; }
-                        }
-                        last_fetch = Some(std::time::Instant::now());
-                    }
-                }
-                tokio::time::sleep(std::time::Duration::from_millis(250)).await;
-            }
-        });
-        rx
-    };
+    let (p, k) = w(); let rx_system = spawn_bg_active(p, Page::System, 5, k, || system_info::fetch_system_state());
+    let (p, k) = w(); let rx_processes = spawn_bg_active(p, Page::Processes, 3, k, || processes::fetch_processes_state());
+    let (p, k) = w(); let rx_storage = spawn_bg_active(p, Page::Storage, 10, k, || storage::fetch_storage_state());
 
+    let (p, k) = w(); let rx_notifications = spawn_bg_blocking(p, Page::Notifications, 30, k, notifications::read_notifications_config);
     // Config-file poll while the Browser page is open: catches edits made
     // outside this app (the browser itself, cce-data-editor).
-    let rx_browser = {
-        let (tx, rx) = channel::<browser::BrowserConfig>();
-        let current_page_shared = current_page_shared.clone();
-        tokio::spawn(async move {
-            let mut last_fetch: Option<std::time::Instant> = None;
-            loop {
-                let current_page = current_page_shared.load(Ordering::SeqCst);
-                if current_page == Page::Browser.index() as u8 {
-                    let should_fetch = match last_fetch {
-                        None => true,
-                        Some(t) => t.elapsed() >= std::time::Duration::from_secs(5),
-                    };
-                    if should_fetch {
-                        let val = tokio::task::spawn_blocking(browser::read_browser_config).await;
-                        if let Ok(val) = val {
-                            if tx.send(val).is_err() { break; }
-                        }
-                        last_fetch = Some(std::time::Instant::now());
-                    }
-                }
-                tokio::time::sleep(std::time::Duration::from_millis(250)).await;
-            }
-        });
-        rx
-    };
+    let (p, k) = w(); let rx_browser = spawn_bg_blocking(p, Page::Browser, 5, k, browser::read_browser_config);
 
-    let rx_services = spawn_bg_active(current_page_shared.clone(), Page::Services.index() as u8, 3, || services::fetch_services());
-    let rx_default_apps = spawn_bg_active(current_page_shared.clone(), Page::DefaultApps.index() as u8, 10, || default_apps::fetch_default_apps());
-    let rx_timers = spawn_bg_active(current_page_shared.clone(), Page::Timers.index() as u8, 5, || timers::fetch_timers());
-    let rx_accounts = spawn_bg_active(current_page_shared.clone(), Page::Accounts.index() as u8, 3, || accounts::fetch_accounts());
+    let (p, k) = w(); let rx_services = spawn_bg_active(p, Page::Services, 3, k, || services::fetch_services());
+    let (p, k) = w(); let rx_default_apps = spawn_bg_active(p, Page::DefaultApps, 10, k, || default_apps::fetch_default_apps());
+    let (p, k) = w(); let rx_timers = spawn_bg_active(p, Page::Timers, 5, k, || timers::fetch_timers());
+    let (p, k) = w(); let rx_accounts = spawn_bg_active(p, Page::Accounts, 3, k, || accounts::fetch_accounts());
 
     let (tx_backup, rx_backup) = channel();
-    let rx_packages = spawn_bg_active(current_page_shared.clone(), Page::Packages.index() as u8, 30, || packages::fetch_packages_state());
+    let (p, k) = w(); let rx_packages = spawn_bg_active(p, Page::Packages, 30, k, || packages::fetch_packages_state());
     let (tx_update, rx_update) = channel();
 
     (