status bar
git clone https://git.lucas.co/cce-status-interface.git
perf: the bar's pollers read what they paint, and wait on events
Measured in a scale-2 shadow, the bar idle for 10 s (installed build ->
this one): pactl spawns 36 -> 0; context switches clock 168 -> 10,
launcher 449 -> 79, stats 754 -> ~330-410.
- spawn_system_stats ran whole in every stats segment -- the clock
included -- each second, two `pactl` spawns per pass (each walking
~10 PATH entries first). It now reads only what its segment paints
(module_reads, the stats_signature split) and never the backlight or
the sink where the fast path owns them (has_fast_levels); update()
keeps those two across a stats push. The clock alone wakes once a
minute, on the minute (until_next_minute).
- The volume subscription re-reads the sink each time it (re)starts,
replacing the poll as the catch-up after a gap.
- watch_brightness blocks in poll(POLLPRI) on actual_brightness, which
the kernel sysfs_notify's on every brightness write (checked on this
machine: ~20 ms after a same-value `brightnessctl set`, nothing at
rest), instead of re-reading sysfs every 100 ms in two processes.
The 100 ms poll remains the fallback.
- A CPU reading reaches the bar only when it moves CPU_STEP (2) points
or after CPU_HOLD_S (5 s), so idle jitter no longer redraws the stats
bubble -- and re-bakes the compositor's blur behind it -- each second.
- The launcher's supervisor waits on SIGCHLD (and a timer only while a
restart is pending) instead of try_wait-ing every 500 ms; killing the
clock in a shadow still restarts it after its backoff.
- The launcher and module runtimes run 2 tokio workers, not one per core
(20 here, in each of six processes).
CLAUDE.md: the stats and fast-path paragraphs, and the test count.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 26 ++++---
src/main.rs | 145 +++++++++++++++++++++++++--------------
src/osd.rs | 4 +-
src/stats.rs | 221 ++++++++++++++++++++++++++++++++++++++++++++++++++---------
4 files changed, 302 insertions(+), 94 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 0aa210c..163fbe3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -20,7 +20,7 @@ cce-icons glyph textures), `src/listeners.rs` (status/switcher socket tasks),
```sh
cargo build --release # standalone build (or `-p cce-status-interface` from the workspace root)
-cargo test # 48 tests: main.rs (parsers, droplet geometry, menu glyphs), config.rs, tray.rs, osd.rs, the tray bridge's x11.rs and title.rs
+cargo test # 50 tests: main.rs (parsers, droplet geometry, menu glyphs), stats.rs, config.rs, tray.rs, osd.rs, the tray bridge's x11.rs and title.rs
make install # release build, then `ccebuild install --no-build cce-status-interface`
```
@@ -292,15 +292,23 @@ listen to tray D-Bus, etc.:
`SystemStats` carries numbers (`cpu_pct`, `memory`, `battery: (capacity,
charging)`, `volume: (level, muted)`, `brightness`, `wifi: (signal,
connected)`), each `Option` where
- the source can be absent; only the clock arrives pre-formatted. The loop is
+ the source can be absent; only the clock arrives pre-formatted. The loop
+ reads only what its segment paints (`module_reads`, the same split as
+ `stats_signature`) — the `clock` segment reads nothing but the time, and
+ wakes once a minute, on the minute. Until 2026-10-05 every stats segment
+ ran the whole poll, two `pactl` spawns included, every second. A CPU
+ reading reaches the bar only when it moves by `CPU_STEP` points or has been
+ held back `CPU_HOLD_S`, so idle jitter does not redraw the segment (and
+ re-bake the compositor's blur behind it) each second. The loop is
once a second, which is fine for a clock or a load average and far too slow
for the two values a KEYPRESS moves — so the backlight and the sink have a
fast path beside it (`spawn_level_watchers`), each pushing its own
one-field event (`BrightnessUpdated` / `VolumeUpdated`) that patches
- `stats` in place. `watch_brightness` polls `/sys/class/backlight` every
- 100ms — `brightnessctl` writes the attribute directly, so there is nothing
- to subscribe to, and two small sysfs reads are cheap enough that the
- interval is not worth tuning; `watch_volume` follows `pactl subscribe` and
+ `stats` in place. `watch_brightness` blocks in `poll(POLLPRI)` on
+ `actual_brightness`, which the kernel's `backlight_generate_event`
+ `sysfs_notify`s on every write to `brightness` (measured ~20 ms after a
+ `brightnessctl set`, nothing at rest); it falls back to the old 100 ms poll
+ only when that cannot be opened. `watch_volume` follows `pactl subscribe` and
re-reads only on a `sink`/`server` event (NOT `sink-input`, which fires
throughout playback, and NOT `client`, which the bar's own `pactl` runs
generate — matching either would put the reader in a loop with itself).
@@ -312,8 +320,10 @@ listen to tray D-Bus, etc.:
`pactl` spawn per event would fall behind); the child carries
`PR_SET_PDEATHSIG` as well as `kill_on_drop`, because a subscription whose
reader was killed outright is reparented to init and sits there rather than
- noticing. The one-second loop still reads both values, so it remains the
- safety net when `pactl subscribe` cannot run at all. Measured in a shadow:
+ noticing. Where the fast path runs, the one-second loop does NOT read
+ either value (`has_fast_levels`) and `update()` keeps them across a stats
+ push; each subscription re-reads the sink when it (re)starts, so a gap
+ while `pactl subscribe` was down is caught up. Measured in a shadow:
~45ms for the backlight, ~55ms for the sink, against a second before.
- **Tray** (`spawn_status_tray`): a full StatusNotifierItem/Watcher host over `zbus`,
including DBusMenu fetching. Icons arrive as pixmaps or theme names (rendered via
diff --git a/src/main.rs b/src/main.rs
index 3ebbda4..f10f963 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -117,6 +117,13 @@ fn stats_signature(module: Option<&str>, s: &SystemStats) -> Option<String> {
}
}
+/// Does this module run the brightness/volume fast path
+/// ([`spawn_level_watchers`])? Then it owns those two values, and the
+/// one-second poll leaves them alone.
+fn has_fast_levels(module: Option<&str>) -> bool {
+ paints_stat(module, "brightness") || paints_stat(module, "volume")
+}
+
/// Does this module paint `field` (`"brightness"` or `"volume"`)? The
/// fast-path pushes carry one value each, so they ask this where a full stats
/// push compares a [`stats_signature`]; the two must agree about which
@@ -1402,15 +1409,15 @@ impl cce_ui::engine::Application for StatusApp {
let has_stats = selected_module.as_ref().map_or(true, |(name, _)| {
name == "stats" || name == "cpu" || name == "memory" || name == "brightness" || name == "volume" || name == "battery" || name == "wifi" || name == "clock"
});
- if has_stats {
- tokio::spawn(spawn_system_stats(sender.clone()));
- }
// The backlight and the sink are what a keypress moves, so they get a
- // fast path alongside the one-second poll — only where a module
+ // fast path instead of the one-second poll — only where a module
// actually paints one of them.
- if paints_stat(selected_module.as_ref().map(|(n, _)| n.as_str()), "brightness")
- || paints_stat(selected_module.as_ref().map(|(n, _)| n.as_str()), "volume")
- {
+ let module_name = selected_module.as_ref().map(|(n, _)| n.clone());
+ let fast_levels = has_fast_levels(module_name.as_deref());
+ if has_stats {
+ tokio::spawn(spawn_system_stats(sender.clone(), module_name, fast_levels));
+ }
+ if fast_levels {
let sender = sender.clone();
tokio::spawn(spawn_level_watchers(move |change| {
let _ = sender.send(change.into());
@@ -1505,7 +1512,16 @@ impl cce_ui::engine::Application for StatusApp {
changed = self.title != t;
self.title = t;
}
- CustomEvent::SystemStatsUpdated(s) => {
+ CustomEvent::SystemStatsUpdated(mut s) => {
+ // Where the fast path runs, the poll does not read the
+ // backlight or the sink (`spawn_system_stats`): those two
+ // stay as the fast path last set them.
+ if has_fast_levels(self.selected_module_name.as_deref()) {
+ if let Some(old) = self.stats.as_ref() {
+ s.brightness = old.brightness;
+ s.volume = old.volume;
+ }
+ }
log::debug!("[module-{}] stats updated, current width={}", self.selected_module_name.as_deref().unwrap_or("none"), self.width);
let module = self.selected_module_name.as_deref();
match stats_signature(module, &s) {
@@ -2198,11 +2214,13 @@ fn main() {
if !has_module {
log::info!("Starting cce-status-interface launcher daemon...");
- let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
+ let rt = small_runtime();
rt.block_on(async {
use tokio::signal::unix::{signal, SignalKind};
let mut sigint = signal(SignalKind::interrupt()).expect("SIGINT");
let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM");
+ // Before any module is spawned, so an early exit is not missed.
+ let mut sigchld = signal(SignalKind::child()).expect("SIGCHLD");
// `stats` is the combined readout segment (cpu, memory,
// brightness, volume, wifi, battery in one bubble); the six single
@@ -2262,6 +2280,11 @@ fn main() {
}
loop {
+ let next_restart = supervised
+ .values()
+ .filter(|e| e.child.is_none())
+ .map(|e| e.restart_at)
+ .min();
tokio::select! {
_ = sigint.recv() => {
log::info!("Received SIGINT, shutting down...");
@@ -2271,50 +2294,58 @@ fn main() {
log::info!("Received SIGTERM, shutting down...");
break;
}
- _ = tokio::time::sleep(std::time::Duration::from_millis(500)) => {
- let now = std::time::Instant::now();
- for module in &modules {
- let Some(entry) = supervised.get_mut(*module) else { continue };
-
- if let Some(child) = entry.child.as_mut() {
- match child.try_wait() {
- Ok(None) => continue,
- Ok(Some(status)) => {
- entry.child = None;
- if entry.spawned_at.elapsed() >= HEALTHY_UPTIME {
- entry.backoff = RESTART_BASE;
- } else {
- entry.backoff = (entry.backoff * 2).min(RESTART_MAX);
- }
- entry.restart_at = now + entry.backoff;
- log::warn!(
- "Module process '{}' exited with status: {:?}. Restarting in {:?}...",
- module, status, entry.backoff
- );
- }
- Err(e) => {
- log::error!("Error checking status for module '{}': {:?}", module, e);
- continue;
- }
+ // A module exited — or any child: the slider, a `pactl` —
+ // or a held-back restart is due. Until 2026-10-05 this
+ // woke every 500 ms to `try_wait` each module.
+ _ = sigchld.recv() => {}
+ _ = async {
+ match next_restart {
+ Some(t) => tokio::time::sleep_until(tokio::time::Instant::from_std(t)).await,
+ None => std::future::pending().await,
+ }
+ } => {}
+ }
+ let now = std::time::Instant::now();
+ for module in &modules {
+ let Some(entry) = supervised.get_mut(*module) else { continue };
+
+ if let Some(child) = entry.child.as_mut() {
+ match child.try_wait() {
+ Ok(None) => continue,
+ Ok(Some(status)) => {
+ entry.child = None;
+ if entry.spawned_at.elapsed() >= HEALTHY_UPTIME {
+ entry.backoff = RESTART_BASE;
+ } else {
+ entry.backoff = (entry.backoff * 2).min(RESTART_MAX);
}
+ entry.restart_at = now + entry.backoff;
+ log::warn!(
+ "Module process '{}' exited with status: {:?}. Restarting in {:?}...",
+ module, status, entry.backoff
+ );
}
+ Err(e) => {
+ log::error!("Error checking status for module '{}': {:?}", module, e);
+ continue;
+ }
+ }
+ }
- if now >= entry.restart_at {
- match spawn_module(module) {
- Ok(c) => {
- log::info!("Restarted module process for: {}", module);
- entry.child = Some(c);
- entry.spawned_at = now;
- }
- Err(e) => {
- entry.backoff = (entry.backoff * 2).min(RESTART_MAX);
- entry.restart_at = now + entry.backoff;
- log::error!(
- "Failed to restart module process for {}: {:?}. Retrying in {:?}...",
- module, e, entry.backoff
- );
- }
- }
+ if now >= entry.restart_at {
+ match spawn_module(module) {
+ Ok(c) => {
+ log::info!("Restarted module process for: {}", module);
+ entry.child = Some(c);
+ entry.spawned_at = now;
+ }
+ Err(e) => {
+ entry.backoff = (entry.backoff * 2).min(RESTART_MAX);
+ entry.restart_at = now + entry.backoff;
+ log::error!(
+ "Failed to restart module process for {}: {:?}. Retrying in {:?}...",
+ module, e, entry.backoff
+ );
}
}
}
@@ -2331,12 +2362,24 @@ fn main() {
return;
}
- let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
+ let rt = small_runtime();
let _guard = rt.enter();
cce_ui::engine::run::<StatusApp>();
}
+/// The runtime for the launcher and every module process: a few sockets, a
+/// timer and a `pactl` pipe, so two workers. `Runtime::new()` starts one per
+/// core — twenty here, in each of the bar's six processes — and a timer tick
+/// on a runtime that wide wakes idle workers to look for work to steal.
+fn small_runtime() -> tokio::runtime::Runtime {
+ tokio::runtime::Builder::new_multi_thread()
+ .worker_threads(2)
+ .enable_all()
+ .build()
+ .expect("tokio runtime")
+}
+
#[cfg(test)]
mod tests {
diff --git a/src/osd.rs b/src/osd.rs
index 906a19e..10b7ef1 100644
--- a/src/osd.rs
+++ b/src/osd.rs
@@ -12,8 +12,8 @@
//!
//! - **The trigger** ([`spawn_trigger`]) runs in the launcher daemon. It
//! drives the same fast-path watchers the bar's readouts use
-//! ([`crate::spawn_level_watchers`] — the backlight polled every 100ms, the
-//! sink followed through `pactl subscribe`), so the slider moves for a
+//! ([`crate::spawn_level_watchers`] — the backlight's sysfs change
+//! notification, the sink followed through `pactl subscribe`), so the slider moves for a
//! keypress, `brightnessctl` in a terminal or a mixer app alike. Each change
//! is forwarded as one line to the running slider, or starts one.
//! - **The slider** (`--osd <line>`, [`main`]) is a single-instance `cce-ui`
diff --git a/src/stats.rs b/src/stats.rs
index 150fd65..487fd07 100644
--- a/src/stats.rs
+++ b/src/stats.rs
@@ -196,47 +196,108 @@ pub(crate) fn get_initial_stats() -> SystemStats {
}
}
-pub(crate) async fn spawn_system_stats(sender: calloop::channel::Sender<CustomEvent>) {
+/// Does `module` paint `field` — one of the [`SystemStats`] field names?
+/// The same split as `stats_signature`: a single-stat module reads its own
+/// field, the combined `stats` bar (or an unknown name) reads them all.
+pub(crate) fn module_reads(module: Option<&str>, field: &str) -> bool {
+ match module {
+ Some("window") | Some("tray") | Some("light_source") => false,
+ Some(m @ ("clock" | "cpu" | "memory" | "battery" | "volume" | "brightness" | "wifi")) => m == field,
+ _ => true,
+ }
+}
+
+/// A CPU reading moves the bar only by at least this many points, or once
+/// it has been held back for [`CPU_HOLD_S`]. The busy figure jitters by a
+/// point or two every second at rest, and each change redraws the segment
+/// (and the compositor re-bakes the blur behind it).
+const CPU_STEP: u8 = 2;
+const CPU_HOLD_S: u64 = 5;
+
+/// The once-a-second poll. It reads only what `module` paints, and never the
+/// two values the fast path owns when `fast_levels` is set (the backlight and
+/// the sink, [`spawn_level_watchers`]): the bar keeps those from the fast
+/// path, so polling them too only spawned `pactl` twice a second for nothing.
+/// A clock alone wakes once a minute, on the minute — it shows no seconds.
+pub(crate) async fn spawn_system_stats(
+ sender: calloop::channel::Sender<CustomEvent>,
+ module: Option<String>,
+ fast_levels: bool,
+) {
log::info!("[spawn_system_stats] Starting system stats loop!");
+ let module = module.as_deref();
+ let reads = |field: &str| module_reads(module, field);
+ let read_volume_here = reads("volume") && !fast_levels;
+ let read_brightness_here = reads("brightness") && !fast_levels;
+ let clock_only = ["memory", "cpu", "battery", "volume", "brightness", "wifi"]
+ .iter()
+ .all(|f| !reads(f));
+
let mut last_cpu = read_cpu_ticks().unwrap_or((0, 0));
+ let mut shown_cpu: Option<u8> = Some(0);
+ let mut shown_cpu_at = std::time::Instant::now();
loop {
log::debug!("[spawn_system_stats] loop iteration start");
let clock = chrono::Local::now().format("%A, %B %d, %Y %I:%M %p").to_string();
- let memory = read_memory_usage();
-
- let cpu_pct = if let Some(current_cpu) = read_cpu_ticks() {
- let total_diff = current_cpu.0 - last_cpu.0;
- let idle_diff = current_cpu.1 - last_cpu.1;
- last_cpu = current_cpu;
- if total_diff > 0 {
- let usage = 100.0 - (idle_diff as f32 * 100.0 / total_diff as f32);
- Some(usage.round().clamp(0.0, 100.0) as u8)
- } else {
- Some(0)
+
+ let cpu_pct = if reads("cpu") {
+ let sample = read_cpu_ticks().map(|current_cpu| {
+ let total_diff = current_cpu.0 - last_cpu.0;
+ let idle_diff = current_cpu.1 - last_cpu.1;
+ last_cpu = current_cpu;
+ if total_diff > 0 {
+ let usage = 100.0 - (idle_diff as f32 * 100.0 / total_diff as f32);
+ usage.round().clamp(0.0, 100.0) as u8
+ } else {
+ 0
+ }
+ });
+ let moved = match (sample, shown_cpu) {
+ (Some(new), Some(old)) => {
+ new.abs_diff(old) >= CPU_STEP
+ || (new != old && shown_cpu_at.elapsed().as_secs() >= CPU_HOLD_S)
+ }
+ (a, b) => a != b,
+ };
+ if moved {
+ shown_cpu = sample;
+ shown_cpu_at = std::time::Instant::now();
}
+ shown_cpu
} else {
None
};
let stats = SystemStats {
clock,
- memory,
+ memory: if reads("memory") { read_memory_usage() } else { None },
cpu_pct,
- battery: read_battery_details(),
- volume: read_volume().await,
- brightness: read_brightness(),
- wifi: read_wifi(),
+ battery: if reads("battery") { read_battery_details() } else { None },
+ volume: if read_volume_here { read_volume().await } else { None },
+ brightness: if read_brightness_here { read_brightness() } else { None },
+ wifi: if reads("wifi") { read_wifi() } else { None },
};
log::debug!("[spawn_system_stats] stats: {:?}", stats);
let _ = sender.send(CustomEvent::SystemStatsUpdated(stats));
- tokio::time::sleep(std::time::Duration::from_secs(1)).await;
+ let wait = if clock_only {
+ until_next_minute(chrono::Local::now())
+ } else {
+ std::time::Duration::from_secs(1)
+ };
+ tokio::time::sleep(wait).await;
}
}
-/// How often the backlight is re-read on the fast path. Two small sysfs
-/// reads, so the cost is a rounding error next to the once-a-second poll's
-/// two `pactl` processes — and nothing is sent unless the value moved, so an
-/// unchanged backlight never wakes the bar's event loop.
+/// How long until just past the next minute boundary of `now`.
+pub(crate) fn until_next_minute(now: chrono::DateTime<chrono::Local>) -> std::time::Duration {
+ use chrono::Timelike;
+ let into = std::time::Duration::from_secs(now.second() as u64)
+ + std::time::Duration::from_nanos(now.nanosecond().min(999_999_999) as u64);
+ std::time::Duration::from_secs(60).saturating_sub(into) + std::time::Duration::from_millis(50)
+}
+
+/// How often the backlight is re-read when its change notification cannot be
+/// had ([`watch_brightness`]'s fallback).
const BRIGHTNESS_POLL_MS: u64 = 100;
/// How long a sink event is held before the volume is read, swallowing the
@@ -248,8 +309,10 @@ const VOLUME_COALESCE_MS: u64 = 30;
/// The fast path for the two values a keypress moves: the backlight and the
/// default sink. Both reach the bar the moment they change instead of at the
/// next [`spawn_system_stats`] tick, which is a full second at worst — long
-/// enough that the number visibly lags the key. The one-second poll still
-/// reads both, so it remains the safety net if either watcher cannot run.
+/// enough that the number visibly lags the key. Where these run, the poll no
+/// longer reads either value: each watcher is its own safety net (the volume
+/// one re-reads on every resubscribe, the backlight one falls back to
+/// polling).
///
/// Each change goes to `emit`: the bar turns it into a one-field
/// `CustomEvent`, and the launcher daemon into the volume/brightness slider
@@ -280,19 +343,75 @@ impl From<LevelChange> for CustomEvent {
}
}
-/// Poll `/sys/class/backlight` and push every change. `brightnessctl` writes
-/// the sysfs attribute directly (see the compositor's media-key bindings), so
-/// there is nothing to subscribe to — but the read is two small files, and
-/// only a moved value is sent.
-async fn watch_brightness(emit: impl Fn(LevelChange)) {
- let mut last = read_brightness();
+/// Push every backlight change. The kernel signals `actual_brightness`
+/// (`sysfs_notify`, from `backlight_generate_event`) on every write to
+/// `brightness` — `brightnessctl`, the compositor's media keys, firmware
+/// hotkeys — so a thread blocks in `poll` on it and wakes only then.
+/// Measured on this panel: the notification lands ~20 ms after a write, and
+/// nothing fires at rest. Until 2026-10-05 this re-read the files every
+/// 100 ms, in two processes (the bar's `stats` segment and the launcher's
+/// slider trigger), forever. Polling remains the fallback when the
+/// notification cannot be had.
+async fn watch_brightness(emit: impl Fn(LevelChange) + Send + 'static) {
+ std::thread::Builder::new()
+ .name("backlight-watch".into())
+ .spawn(move || {
+ let mut last = read_brightness();
+ if let Err(e) = brightness_notifications(&emit, &mut last) {
+ log::info!("[watch_brightness] no change notification ({e}); polling");
+ }
+ loop {
+ std::thread::sleep(std::time::Duration::from_millis(BRIGHTNESS_POLL_MS));
+ let cur = read_brightness();
+ if cur != last {
+ last = cur;
+ emit(LevelChange::Brightness(cur));
+ }
+ }
+ })
+ .ok();
+}
+
+/// Block on the first backlight's `actual_brightness` notification and push
+/// each moved value. Returns only on an error, for the polling fallback.
+fn brightness_notifications(
+ emit: &impl Fn(LevelChange),
+ last: &mut Option<i32>,
+) -> std::io::Result<()> {
+ use std::io::{Read, Seek};
+ use std::os::fd::AsRawFd;
+
+ let dir = std::fs::read_dir("/sys/class/backlight")?
+ .flatten()
+ .map(|e| e.path())
+ .find(|p| p.join("brightness").exists() && p.join("max_brightness").exists())
+ .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no backlight"))?;
+ let mut file = std::fs::File::open(dir.join("actual_brightness"))?;
+ let mut buf = [0u8; 32];
loop {
- tokio::time::sleep(std::time::Duration::from_millis(BRIGHTNESS_POLL_MS)).await;
+ // sysfs re-arms the notification on a read from the start.
+ file.seek(std::io::SeekFrom::Start(0))?;
+ let _ = file.read(&mut buf)?;
+ let mut pfd = libc::pollfd { fd: file.as_raw_fd(), events: libc::POLLPRI | libc::POLLERR, revents: 0 };
+ let rc = unsafe { libc::poll(&mut pfd, 1, -1) };
+ if rc < 0 {
+ let err = std::io::Error::last_os_error();
+ if err.kind() == std::io::ErrorKind::Interrupted {
+ continue;
+ }
+ return Err(err);
+ }
+ if pfd.revents & libc::POLLNVAL != 0 {
+ return Err(std::io::Error::other("backlight fd invalid"));
+ }
let cur = read_brightness();
- if cur != last {
- last = cur;
+ if cur != *last {
+ *last = cur;
emit(LevelChange::Brightness(cur));
}
+ // A held key writes in a burst; one read per 10 ms is plenty, and it
+ // bounds the loop should the attribute ever stay readable.
+ std::thread::sleep(std::time::Duration::from_millis(10));
}
}
@@ -369,6 +488,14 @@ async fn volume_subscription(
}
});
+ // A resubscribe follows a gap in which changes went unseen; nothing else
+ // reads the sink where this runs, so catch up now.
+ let cur = read_volume().await;
+ if cur != *last {
+ *last = cur;
+ emit(LevelChange::Volume(cur));
+ }
+
let mut delivered = false;
while rx.recv().await.is_some() {
// Swallow the rest of the burst, then read once.
@@ -395,3 +522,31 @@ async fn volume_subscription(
pub(crate) fn is_sink_event(line: &str) -> bool {
line.contains(" on sink #") || line.contains(" on server")
}
+
+#[cfg(test)]
+mod poll_tests {
+ use super::{module_reads, until_next_minute};
+ use chrono::TimeZone;
+
+ #[test]
+ fn a_single_stat_module_reads_only_its_own_field() {
+ assert!(module_reads(Some("clock"), "clock"));
+ assert!(!module_reads(Some("clock"), "volume"));
+ assert!(!module_reads(Some("clock"), "cpu"));
+ assert!(module_reads(Some("cpu"), "cpu"));
+ assert!(!module_reads(Some("cpu"), "memory"));
+ // The combined bar, and a name this build does not know, read all.
+ assert!(module_reads(Some("stats"), "wifi"));
+ assert!(module_reads(None, "battery"));
+ assert!(!module_reads(Some("tray"), "clock"));
+ }
+
+ #[test]
+ fn the_clock_wakes_just_past_the_minute() {
+ let at = |s, ms| chrono::Local.with_ymd_and_hms(2026, 10, 5, 21, 30, s).unwrap()
+ + chrono::Duration::milliseconds(ms);
+ assert_eq!(until_next_minute(at(0, 0)).as_millis(), 60_050);
+ assert_eq!(until_next_minute(at(59, 900)).as_millis(), 150);
+ assert_eq!(until_next_minute(at(30, 0)).as_millis(), 30_050);
+ }
+}