status bar
git clone https://git.lucas.co/cce-status-interface.git
feat(stats): the backlight and the sink update on the change, not the tick
The one-second stats loop is right for a clock or a load average and far
too slow for the two values a KEYPRESS moves: the brightness and volume
numbers trailed the key by up to a full second.
Both now have a fast path beside that loop, each pushing a one-field
event 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 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. The burst is coalesced for 30ms before the read,
since a held volume key emits a stream and one spawn per event falls
behind.
Only a changed value is sent, so an idle desktop never wakes the event
loop, and `update()` asks the new `paints_stat` whether this module
shows the field before redrawing — the one-field counterpart to
`stats_signature`, with a test holding the two in agreement so a segment
never redraws (and re-bakes the compositor's blur) for a number it does
not paint. The one-second loop still reads both values and remains the
safety net when pactl subscribe cannot run at all.
The subscription child carries PR_SET_PDEATHSIG as well as
kill_on_drop: a shadow run left one reparented to init and still
sleeping minutes after its reader was killed, and the launcher restarts
module processes, so they would accumulate.
Measured in a shadow session against the real backlight and sink:
~45ms for the backlight and ~55ms for the volume, against a second.
Co-Authored-By: Claude Opus 5 <[email protected]>
CLAUDE.md | 26 ++++++++++-
Cargo.toml | 2 +
src/main.rs | 104 ++++++++++++++++++++++++++++++++++++++++++++
src/stats.rs | 138 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 268 insertions(+), 2 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 7a21e7e..f9c3a98 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -19,7 +19,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 # 46 tests: main.rs (contrast, parsers), config.rs, tray.rs
+cargo test # 48 tests: main.rs (contrast, parsers), config.rs, tray.rs
make install # installs ../target/release/cce-status-interface to ~/.local/bin
```
@@ -157,7 +157,29 @@ listen to tray D-Bus, etc.:
`/sys/class/power_supply/BAT*`, `/sys/class/backlight`, and `pactl` for volume/mute.
`SystemStats` carries numbers (`cpu_pct`, `memory`, `battery: (capacity,
charging)`, `volume: (level, muted)`, `brightness`), each `Option` where
- the source can be absent; only the clock arrives pre-formatted.
+ the source can be absent; only the clock arrives pre-formatted. 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
+ 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).
+ Both send only a CHANGED value, so an idle desktop never wakes the event
+ loop, and `update()` asks `paints_stat` whether this module shows the field
+ before redrawing — the one-field counterpart to `stats_signature`, and a
+ test holds the two in agreement. The subscription burst is coalesced for
+ 30ms before the read (a held volume key emits a stream of events, and one
+ `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:
+ ~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
`resvg`/`png`).
diff --git a/Cargo.toml b/Cargo.toml
index 50311dd..2e0c7bf 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,6 +23,8 @@ png = "0.17"
log = "0.4"
env_logger = "0.11"
kdl = "4.6"
+# Real dependency: PR_SET_PDEATHSIG on the `pactl subscribe` child.
+libc = "0.2"
diff --git a/src/main.rs b/src/main.rs
index f6a08aa..454ceca 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -112,11 +112,34 @@ fn stats_signature(module: Option<&str>, s: &SystemStats) -> Option<String> {
}
}
+/// 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
+/// modules are blind to a stat, or a segment would redraw for a number it
+/// does not show.
+fn paints_stat(module: Option<&str>, field: &str) -> bool {
+ match module {
+ // The other single-stat modules; stats-blind modules.
+ Some("clock") | Some("cpu") | Some("memory") | Some("battery") => false,
+ Some("window") | Some("tray") | Some("light_source") => false,
+ Some("brightness") => field == "brightness",
+ Some("volume") => field == "volume",
+ // `stats` paints every reader's value; so does an unknown name.
+ _ => true,
+ }
+}
+
#[derive(Debug, Clone)]
pub(crate) enum CustomEvent {
LayoutUpdated(String),
TitleUpdated(String),
SystemStatsUpdated(SystemStats),
+ /// The backlight moved, pushed by the fast path (`watch_brightness`) the
+ /// moment it did rather than at the next one-second stats poll.
+ BrightnessUpdated(Option<i32>),
+ /// The default sink's `(level %, muted)` moved, pushed by the fast path
+ /// (`watch_volume`) on the sound server's own event.
+ VolumeUpdated(Option<(Option<u32>, bool)>),
TrayUpdated(TrayItem),
TrayRemoved(String),
/// A bar-built in-surface menu (window picker), fetched off-thread.
@@ -1490,6 +1513,14 @@ impl cce_ui::engine::Application for StatusApp {
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
+ // 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")
+ {
+ tokio::spawn(spawn_level_watchers(sender.clone()));
+ }
let font_system = cce_ui::create_font_system();
@@ -1598,6 +1629,22 @@ impl cce_ui::engine::Application for StatusApp {
}
self.stats = Some(s);
}
+ CustomEvent::BrightnessUpdated(b) => {
+ log::debug!("[module-{}] fast-path brightness {:?}", self.selected_module_name.as_deref().unwrap_or("none"), b);
+ changed = paints_stat(self.selected_module_name.as_deref(), "brightness")
+ && self.stats.as_ref().is_some_and(|s| s.brightness != b);
+ if let Some(s) = self.stats.as_mut() {
+ s.brightness = b;
+ }
+ }
+ CustomEvent::VolumeUpdated(v) => {
+ log::debug!("[module-{}] fast-path volume {:?}", self.selected_module_name.as_deref().unwrap_or("none"), v);
+ changed = paints_stat(self.selected_module_name.as_deref(), "volume")
+ && self.stats.as_ref().is_some_and(|s| s.volume != v);
+ if let Some(s) = self.stats.as_mut() {
+ s.volume = v;
+ }
+ }
CustomEvent::TrayUpdated(item) => {
self.tray_items.insert(item.id.clone(), item);
}
@@ -2942,4 +2989,61 @@ mod tests {
assert!(parse_ccectl_windows(r#"{"id":3,"title":"no app_id"}"#).is_empty());
assert!(parse_ccectl_windows("{not json").is_empty());
}
+
+ /// The fast path and the full stats push must agree about which modules
+ /// are blind to a value; a disagreement would redraw a segment for a
+ /// number it does not paint (and re-bake the compositor's blur with it).
+ #[test]
+ fn paints_stat_agrees_with_the_stats_signature() {
+ let a = SystemStats {
+ clock: "x".into(),
+ memory: Some(1),
+ cpu_pct: Some(1),
+ battery: Some((1, false)),
+ volume: Some((Some(10), false)),
+ brightness: Some(10),
+ };
+ for field in ["brightness", "volume"] {
+ for module in [
+ None,
+ Some("stats"),
+ Some("brightness"),
+ Some("volume"),
+ Some("clock"),
+ Some("cpu"),
+ Some("memory"),
+ Some("battery"),
+ Some("window"),
+ Some("tray"),
+ Some("light_source"),
+ ] {
+ // Move only `field`, then ask both paths whether it shows.
+ let mut b = a.clone();
+ match field {
+ "brightness" => b.brightness = Some(50),
+ _ => b.volume = Some((Some(50), false)),
+ }
+ let by_signature = stats_signature(module, &a) != stats_signature(module, &b);
+ assert_eq!(
+ paints_stat(module, field),
+ by_signature,
+ "module {:?}, field {}",
+ module,
+ field
+ );
+ }
+ }
+ }
+
+ /// The sink and the default-sink change are ours; a single application's
+ /// stream is not — `sink-input` fires throughout playback and would have
+ /// us re-reading `pactl` the whole time.
+ #[test]
+ fn sink_events_exclude_sink_inputs() {
+ assert!(is_sink_event("Event 'change' on sink #0"));
+ assert!(is_sink_event("Event 'change' on server"));
+ assert!(!is_sink_event("Event 'change' on sink-input #34"));
+ assert!(!is_sink_event("Event 'new' on source-output #7"));
+ assert!(!is_sink_event(""));
+ }
}
diff --git a/src/stats.rs b/src/stats.rs
index 4675818..8a8f073 100644
--- a/src/stats.rs
+++ b/src/stats.rs
@@ -185,3 +185,141 @@ pub(crate) async fn spawn_system_stats(sender: calloop::channel::Sender<CustomEv
tokio::time::sleep(std::time::Duration::from_secs(1)).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.
+const BRIGHTNESS_POLL_MS: u64 = 100;
+
+/// How long a sink event is held before the volume is read, swallowing the
+/// rest of its burst. PulseAudio reports one change as several events (and a
+/// held volume key as a stream of them); reading once at the end of a burst
+/// keeps this from falling a process-spawn behind per event.
+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.
+pub(crate) async fn spawn_level_watchers(sender: calloop::channel::Sender<CustomEvent>) {
+ tokio::spawn(watch_brightness(sender.clone()));
+ tokio::spawn(watch_volume(sender));
+}
+
+/// 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(sender: calloop::channel::Sender<CustomEvent>) {
+ let mut last = read_brightness();
+ loop {
+ tokio::time::sleep(std::time::Duration::from_millis(BRIGHTNESS_POLL_MS)).await;
+ let cur = read_brightness();
+ if cur != last {
+ last = cur;
+ let _ = sender.send(CustomEvent::BrightnessUpdated(cur));
+ }
+ }
+}
+
+/// Follow `pactl subscribe` and re-read the sink whenever it reports one.
+/// The alternative — polling `pactl` fast enough to feel immediate — would
+/// spawn two processes several times a second; the subscription costs one
+/// long-lived process and reads only when something actually happened.
+///
+/// A subscription that ends (no pactl, a sound server restart) is retried
+/// with the same backoff shape the status listener uses, and the one-second
+/// poll covers the gap in the meantime.
+async fn watch_volume(sender: calloop::channel::Sender<CustomEvent>) {
+ let mut last = read_volume().await;
+ let mut retry_s = 1u64;
+ loop {
+ match volume_subscription(&sender, &mut last).await {
+ // A subscription that delivered something was working; a fresh
+ // failure after it should start over at the short delay.
+ Ok(true) => retry_s = 1,
+ Ok(false) => {}
+ Err(e) => log::warn!("[watch_volume] pactl subscribe failed: {:?}", e),
+ }
+ tokio::time::sleep(std::time::Duration::from_secs(retry_s)).await;
+ retry_s = (retry_s * 2).min(30);
+ }
+}
+
+/// One run of `pactl subscribe`, ending when the process does. `Ok(true)`
+/// means it delivered at least one event we acted on.
+async fn volume_subscription(
+ sender: &calloop::channel::Sender<CustomEvent>,
+ last: &mut Option<(Option<u32>, bool)>,
+) -> std::io::Result<bool> {
+ use tokio::io::{AsyncBufReadExt, BufReader};
+
+ let mut cmd = tokio::process::Command::new("pactl");
+ // SAFETY: `prctl` is async-signal-safe and touches only this child's own
+ // process attributes, which is all a pre-exec closure may do.
+ unsafe {
+ cmd.pre_exec(|| {
+ libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM);
+ Ok(())
+ });
+ }
+ let mut child = cmd
+ .arg("subscribe")
+ // The event lines are matched by their English words, so pin the
+ // locale rather than trusting the session's.
+ .env("LC_ALL", "C")
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::null())
+ // Both halves of "no orphaned subscriptions": `kill_on_drop` covers
+ // the loop ending under us, and PDEATHSIG covers the bar being
+ // killed outright — the launcher's supervisor restarts module
+ // processes, and a subscription whose reader is gone sits there
+ // until its next write rather than noticing. Observed: an orphan
+ // reparented to init and still sleeping minutes later.
+ .kill_on_drop(true)
+ .spawn()?;
+ let Some(stdout) = child.stdout.take() else {
+ return Ok(false);
+ };
+
+ // Lines are forwarded through a channel rather than read inline, because
+ // the coalescing wait below cancels its read: `next_line` is not
+ // cancel-safe and would drop a partly-read line, while `recv` is.
+ let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
+ tokio::spawn(async move {
+ let mut lines = BufReader::new(stdout).lines();
+ while let Ok(Some(line)) = lines.next_line().await {
+ if is_sink_event(&line) && tx.send(()).is_err() {
+ break;
+ }
+ }
+ });
+
+ let mut delivered = false;
+ while rx.recv().await.is_some() {
+ // Swallow the rest of the burst, then read once.
+ let coalesce = std::time::Duration::from_millis(VOLUME_COALESCE_MS);
+ while tokio::time::timeout(coalesce, rx.recv()).await.is_ok() {}
+ delivered = true;
+ let cur = read_volume().await;
+ if cur != *last {
+ *last = cur;
+ let _ = sender.send(CustomEvent::VolumeUpdated(cur));
+ }
+ }
+ let _ = child.wait().await;
+ Ok(delivered)
+}
+
+/// Does a `pactl subscribe` line concern what the volume readout paints?
+///
+/// `sink` is the sink itself; `server` is the default-sink change, which
+/// moves the readout to a different device's level. `sink-input` is a single
+/// application's stream and must NOT match — it fires on every player's
+/// volume, and matching it would re-read the sink constantly during
+/// playback.
+pub(crate) fn is_sink_event(line: &str) -> bool {
+ line.contains(" on sink #") || line.contains(" on server")
+}