01 / Baseline
Service latency and per-update collection costs
Waybar requests one JSON status update on a fixed schedule. rstat's original Waybar module took roughly 800 milliseconds per update because it launched a shell script that invoked command-line tools including awk, bc and powerprofilesctl.
- Service round trip ~800 ms with the D-Bus request in the update path
powerprofilesctlcrossed process and D-Bus boundaries to retrieve the active power profile, even though Linux exposed the same value through a sysfs file.- Process creation Shell + at least 3 child processes / update
- Each update started external utilities, incurring process creation, executable loading and teardown costs.
- Text interfaces Per-process file reads and parsing
- Linux's process filesystem, procfs, converted kernel counters into text. The command-line utilities then parsed that text and passed more text through the shell.
- State lifetime One shell invocation per update
- The script exited after each update, releasing its file descriptors and buffers. Persistent sampling state required a collector that remained running between updates.
02 / Procfs collector
Persistent collection and direct sysfs access
The first revision replaced the one-shot shell module with a long-running Rust collector that retained counters, buffers and previous samples between Waybar updates. The Rust collector removed nearly every external utility, but powerprofilesctl still contacted the system power-profile daemon over D-Bus inside every update.
Reading the active power profile directly from /sys/firmware/acpi/platform_profile removed that round trip. The Rust procfs collector then collapsed three process-directory walks into one, reused an 8 KB read buffer, took resident-set size (RSS) from /proc/[pid]/stat instead of opening statm, and identified kernel threads from the same record so it could skip their I/O file in the default view. Together, those changes cut each update from roughly 800 to 15 milliseconds.
Each refresh still enumerated /proc and opened per-process files, causing the kernel to format counters as text for userspace to parse. Collection cost therefore continued to grow with the number of live processes.
03 / Event-driven accounting
Scheduler tracepoints and task lifetime tracking
The optimized Rust procfs collector still scaled with the number of live processes. It opened stat for every process and io for each ordinary process. Across roughly 300 processes, those files can require about 1,800 open, read and close system calls per refresh, before directory enumeration and global metrics. The next revision moved recurring task accounting into an extended Berkeley Packet Filter (eBPF) program, verified by the Linux kernel before loading. Scheduler events supply the task being accounted for, allowing the program to accumulate its CPU residency and snapshot its RSS and I/O counters.
Four scheduler tracepoints maintain task state as processes fork, run, exit and are freed. Every record is keyed by its process identifier (PID) and kernel start timestamp, preventing a reused PID from joining two unrelated process lifetimes. A retired record remains in the shared eBPF statistics map until the Rust daemon consumes its final counters, so short-lived work remains visible. One procfs scan at startup seeds blocked threads and zombie process leaders that already existed when the eBPF program attached; that scan doesn't recur during refreshes.
sched_process_fork
sched_switch
sched_process_exit
sched_process_free
Four tracepoints create, update and retire task state.
CPU ← every switch
RSS / I/O ← gated
At most once per task every 10 ms; exit forces the final snapshot.
stats[PID, start time]
Final counters survive task exit until userspace consumes them.
batch map lookup
reused buffers → Waybar JSON
04 / Collection and output
Batch reads, retained storage and bounded output
With task accounting maintained in eBPF maps, each userspace refresh collects task records, reads global metrics, aggregates processes and constructs the Waybar response. Batch operations reduce system-call overhead, while retained buffers and bounded result sets limit allocation and sorting work.
- Batch map collection
- Linux's
BPF_MAP_LOOKUP_BATCHoperation transfers many task records into preallocated key and value arrays in one system call. The iterative fallback requires separate key-advance and lookup calls for each record. - RSS and I/O sampling interval
- CPU residency is charged on every switch. RSS and I/O are sampled at most once per task every 10 milliseconds, with a forced final snapshot when the task exits.
- Map capacity and userspace storage
- The statistics, scheduler-start and PID-to-start-time hash maps have fixed capacities and are created before their tracepoints attach. Userspace reserves its batch arrays and process-aggregation tables once, then clears them without releasing their storage.
- Current and previous snapshots
- Two vectors retain capacity and swap roles between samples. Sorting records by PID and start time gives cache-friendly traversal and binary-search lookups against the previous sample.
- Persistent system-file descriptors
sysinfo()returns all three load averages in one system call. The daemon discovers and opens its sysfs temperature, frequency, graphics-processor and throttling files once;pread()fills fixed byte buffers, and numeric values are parsed directly from those bytes.- Waybar JSON construction
write!appends the known Waybar schema to reusable tooltip and JSON strings. A small emitter escapes task names in place without a general serializer or temporary formatted strings.- Top-process selection
- CPU, memory and I/O leaders are selected into fixed five-entry arrays, while the list of tasks blocked in Linux's uninterruptible D state is capped at ten. Only those small arrays are sorted for output.