git.lucas.co / cce-status-interface
status bar
git clone https://git.lucas.co/cce-status-interface.git

commitc05c92c7f643efb1519bb3593e2bc1d958e1a87a
parent73ab926f2e
authorLucas Galante <[email protected]>
date2026-09-19 00:50
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")
+}