# drain **Battery-drain triage TUI. Catches the polling loop you didn't mean to write.** ![Version](https://img.shields.io/badge/version-0.1.9-blue) ![Rust](https://img.shields.io/badge/language-Rust-f74c00) ![License](https://img.shields.io/badge/license-Unlicense-green) ![Platform](https://img.shields.io/badge/platform-Linux-blue) ![Stay Amazing](https://img.shields.io/badge/Stay-Amazing-important) Small TUI that answers one question fast: **what is draining my battery right now, and did I just make it worse?** `top` and `htop` show CPU%. `powertop` is the kernel-level truth but needs a tracing kernel, takes seconds to settle, and floods the screen with what you didn't ask. `drain` reads `/proc` directly, samples once per second, and ranks processes by a single drain score combining **CPU%**, **voluntary context switches/s** (the polling proxy), and **I/O bytes/s**. It also pairs with [glass](https://github.com/isene/glass) and [tile](https://github.com/isene/tile) (the CHasm asm WM/terminal): the **WS** column attributes each process to its workspace via `_NET_WM_PID` + `_NET_WM_DESKTOP`, so you can tell *which* glass is the rogue one when you have several. A pinned **suite** summary line shows aggregate wakes/s for `glass` / `tile` / `strip` / `bare` / asmites at a glance. Part of the [Fe₂O₃](https://github.com/isene/fe2o3) Rust terminal suite. ## Why ctx-switches/s? A polling loop wakes the CPU once per timer tick. Each wake counts as one voluntary context switch in `/proc//status`. So `Δvoluntary_ctxt_switches / Δt` is the wakes-per-second rate — the single best signal for "I introduced a poll where I should have used an event wait". Watching CPU% alone misses the worst kind of waste: the tight 10ms timer that uses 0.1% CPU but kills your battery from the wakeups it causes the whole CPU package to do. ## Features - **Per-process top-N** — CPU%, wakes/s, NVOL/s (preempted/s), I/O, and a five-dot weighted drain score - **Battery W in header** — current draw, Δ vs rolling 30-sample average, Δ vs persistent baseline, hours-remaining estimate - **WS column** — workspace attribution via EWMH `_NET_WM_PID` + `_NET_WM_DESKTOP`. Falls back to `QueryTree` on minimal WMs - **Pinned suite summary** — `glass×3 21.5w/s · tile 4.8w/s · …` always-visible "is the asm suite behaving?" - **Diff mode** (`d`) — highlights processes whose wakes/s rate is significantly above their own historical baseline. Catches "did this code change introduce polling?" without you remembering the old number - **Persistent baseline** — auto-saved to `~/.cache/drain/state.json` on quit, reloaded on start. Carries across sessions - **Thread expansion** (`Enter`) — drill into `/proc//task/` for a multi-threaded process. Catches the case where one Firefox thread is the offender, not the whole browser - **`strace -c` integration** (`S`) — attach to selected pid for 3s, show syscall histogram in the analysis pane. Answers "what is it actually doing?" without leaving drain - **Orphan / held-resource detector** (`O`) — the *resource held open but idle* drain class the wakes/CPU sampler misses: a non-corked audio sink, a block-mode idle-inhibitor, wifi `power_save` off, or a kernel wakelock. Each costs hundreds of mW while CPU%/wakes read zero. Header shows an `orphans:N` badge (green 0 · yellow ≤2 · red ≥3). In the view, `k`/`K` SIGTERM/SIGKILL the holder (with confirm), `a` adds it to a persisted allowlist. Scanned on a slow 30 s cadence (skipped while paused) — two short subprocesses + two stat reads, no polling loop. (`drain --orphans` prints a headless scan.) - **Firefox tab view** (`Enter` on a `firefox-bin` row) — lists Firefox's Fission content processes (each `Isolated Web Content` / `Web Content` is a tab or site) sorted by drain, so the rogue tab floats to the top. `k` / `K` kill the selected one (Firefox shows a recoverable "tab crashed"). For tab titles, see Firefox's `about:processes`. - **claude analysis pane** — `claude -p` runs at startup and on `I` with the top drainers as prompt; suggests likely polling-loop candidates. `Ctrl+Y` copies the analysis to your clipboard via OSC 52 (no `xclip` fork) - **Filter** (`/`) — substring match on process name. Useful when you want to track one process across config changes - **Configurable refresh interval** (`+`/`-`) — 0.5s to 10s - **Pause** (`p` or `f`) — freeze sampling so you can read the table ## Install ```bash cargo build --release ln -sf $(pwd)/target/release/drain ~/bin/drain ``` Static-linked dependencies: `crust` (TUI), `x11rb` (X11 wire protocol for the WS column), `libc` (sysconf). No tracing kernel, no root. `strace -c` requires ptrace permission. If `kernel.yama.ptrace_scope = 1` (the typical default), you can either run drain with `sudo` for strace mode, or set `kernel.yama.ptrace_scope = 0` for your session. ## Key bindings | Key | Action | |-----|--------| | `q` | quit (saves baseline) | | `↑` / `↓` | move row selection | | `Enter` | expand selected pid into per-thread view | | `S` | attach `strace -c` to selected pid for 3s | | `O` | open / rescan the orphan (held-resource) view | | `k` / `K` | in orphan/Firefox view: SIGTERM / SIGKILL the selected (y/n confirm) | | `a` | in orphan view: allowlist the selected holder (persisted) | | `Enter` | per-thread view — or, on a `firefox-bin` row, the Firefox tab view | | `Esc` | close threads / strace / orphan / Firefox overlay | | `s` | cycle sort: drain → cpu → wakes → io → drain | | `d` | toggle diff mode (highlight anomalies vs baseline) | | `/` | enter filter; type substring; `Enter` apply, `Esc` cancel | | `+` / `-` | refresh interval ±0.5s (range 0.5s … 10s) | | `p` / `f` | pause / unpause sampling | | `I` | re-query claude analysis | | `Ctrl+Y` | copy claude analysis to clipboard (OSC 52) | | `r` | reset rolling-average bat watt readout | | `h` / `?` | toggle help line in footer | ## Workflow when battery W jumps from 4 → 7+ 1. Open `drain`. Header shows current Bat W with Δ vs the rolling average and Δ vs your persistent baseline. 2. Press `s` to cycle to **wakes** sort. The top of the list is the suspect. 3. If you suspect a recent change in your own code, press `d` to enable diff mode. Rows that have spiked above their historical baseline get an `↑↑↑` / `↑↑` / `↑` badge. 4. Press `↑/↓` to highlight the row. `Enter` expands threads; `S` attaches strace and shows the syscall breakdown in the analysis pane on the right. 5. The WS column tells you which glass / firefox / slack / etc. instance it is when you have several. 6. The pinned **suite** line at the top shows aggregate wakes/s for the asm tools — instant "is the foundation still cold?" check. ## Layout ``` drain Bat 4.21 W ↓ (Δ -0.13 W vs 30-sample avg) | baseline -0.40 W 8.4h left sort: drain suite: glass×3 21.5w/s tile 4.8w/s strip 2.4w/s bare 0.0w/s PID PROC WS CPU% WAKE/s NVOL/s IO kB/s DRAIN 1208672 firefox 7 3.1 180.5 4.2 3.1 ●●●●● 856756 Xorg · 0.2 83.0 0.0 0.0 ●●○○○ 1175734 glass 3 0.1 21.0 0.0 0.0 ●●○○○ ... ─── analysis ─── Firefox at 180 wakes/s on WS 7 dominates here. Likely background-tab JS animation or video. Try freezing the tab or enabling Memory Saver. Xorg at 83 wakes/s tracks Firefox repaints — normal. The asm tools are quiet; the suite is behaving. q · ↑↓ · Enter · S · s · d · / · +/- · p · I · C-y · r · h help drain v0.1.3 ``` ## Drain score A weighted sum, tuned for a 4 W idle laptop: ``` drain = cpu_pct + wakes_per_s × 0.05 + (io_bytes/1024) × 0.02 ``` Five dots map roughly to: | Dots | Drain | Typical cause | |---|---|---| | `●●●●●` | ≥ 50 | Tight CPU loop or 1000+ wakes/s | | `●●●●○` | ≥ 20 | Heavy work; one busy app | | `●●●○○` | ≥ 8 | Background polling that adds up | | `●●○○○` | ≥ 3 | Active but reasonable | | `●○○○○` | ≥ 1 | Almost-quiet | | `○○○○○` | < 1 | Idle | Rationale: a tight 10% CPU loop is worse than 200 wakes/s of an otherwise-sleeping process, but 200 wakes/s of context-switch overhead is worse than 4% CPU of useful steady work. The 0.05 weight on wakes hits both with the right severity. ## Limitations - `/proc//io` is restricted on Linux — non-owned PIDs report 0 for I/O without `CAP_SYS_PTRACE`. Fine for your own user-session drain hunting; matters if you want to chase a daemon. - `_NET_WM_PID` is set by most apps but not all (some Java Swing apps, some headless tools). Those show `·` in the WS column. `?` means the window has a `_NET_WM_PID` but no `_NET_WM_DESKTOP` — common on minimal WMs that don't publish per-window desktop info. - `strace -c` needs ptrace permission. See Install notes above. - claude pane needs the [`claude` CLI](https://docs.claude.com/claude-code/) in `$PATH`. Without it, `I` and the initial query show an error in the analysis pane; everything else still works. - Sub-second polling loops between samples are still visible (they show up as elevated wakes/s) but `drain` itself only refreshes once per second to keep its own footprint negligible. `drain` appears in its own table — pressing `+` to slow the refresh drops drain's own footprint visibly. ## The battery ledger `L` in the TUI, or `drain --ledger`, shows watt-hours and CPU-seconds per app per day. The TUI can only account for time it is open, so start the tally once and the days fill in on their own: ```sh drain --tally & # or from your WM's autostart ``` `L` in the TUI shows the same thing, says whether it is recording, and `t` there turns it on or off. That also comments or uncomments the autostart line in `~/.tilerc`, so the choice survives the next login. It follows upower, which your desktop already runs and which already samples the battery. Its signal carries Energy in watt-hours, so the tally adds no timer of its own: one blocking read on a pipe, and no wakeups that were not happening anyway. Energy comes from the battery's counters, so the charge lost during a suspend is measured too. Up to 0.25 Wh is unwritten at any moment, which is what a kill costs you. When the draw crosses 6 W it also spends one second naming the cause: the top CPU consumers go into the ledger, and a line lands in `~/.drain/peaks.tsv` saying when it was expensive, how expensive, and who was busy. At most one such sample every five minutes, and none at all below the threshold, so an idle machine pays nothing. Set `DRAIN_PEAK_W` to move the line. ``` $ drain --ledger Expensive stretches (>= 6 W), newest first 2026-08-22 16:50 7.4 firefox:31% frame:3% claude:2% Battery ledger — last 2 day(s) 41.2 Wh measured while discharging firefox 0.0s 28.10Wh 68.2% ``` Without upower it falls back to the kernel's uevent socket. Be warned that the fallback is thin: an ACPI battery may emit no uevent at all on a capacity change (this laptop emits none in seven minutes of discharge), so it then catches AC transitions and little else. ## Files ``` ~/.cache/drain/state.json persistent baseline (bat W avg + per-comm wakes/cpu medians) ~/.drain/ledger.tsv the ledger: ~/.drain/peaks.tsv expensive stretches: ``` ## Related - [glass](https://github.com/isene/glass) — terminal where drain runs - [tile](https://github.com/isene/tile) — WM that publishes the `_NET_WM_DESKTOP` drain reads - [strip](https://github.com/isene/tile) — status bar segment helpers; one of drain's first targets when you suspect "did my asmite refactor regress?" - [Fe₂O₃ landing page](https://isene.github.io/fe2o3/) — the family