testsAndMisc/.github/skills/efficient-polling-scripts/SKILL.md
Krzysztof kuhy Rudnicki 4f2b0ccdf7 Dissolve linux_configuration/scripts/ and single_use/
Two levels of pure taxonomy carrying no information, and a 2-directory
cap leaves no room for them. Categories now sit directly under
linux_configuration/: features, fixes, utils, misc, fresh-install,
installers, diagnostics, gaming, boot_recovery, periodic_background, lib,
meta, tests. A script's own lib/ and tests/ are exempt from the cap, so
those stay put.

Depth violations 248 -> 139; roughly 40 of the remainder are inside
digital_wellbeing and hosts, deferred until the guard-lib repoint.

The moves are the easy half. What breaks is every path that counted
levels, and three classes of it had to be found by resolving paths rather
than reading diffs:

  - 52 relative `source` lines for lib/common.sh and lib/android.sh. The
    correct prefix differs per file: 40 were ../../ and 12 were ../../../,
    so a blanket replace would have fixed one set and broken the other.
  - REPO_ROOT in 7 fixes/lib/tests harnesses and setup_syncyomi_guard.sh,
    each walking up four levels from a directory now two levels shallower.
    This caused a real regression -- the fixes suite went red with
    "log_warn: command not found" -- caught by running it and comparing
    against the pre-move baseline worktree.
  - conftest.py's sys.path entries, which took the pytest gate down with
    14 collection errors, and the `# shellcheck source=` directives, which
    took shellcheck down with SC1091.

16 symlinks in $HOME broke, including the entire i3blocks bar. All
repointed, and cpu_monitor/memory/disk/time/volume were run to confirm
they emit real output rather than merely resolving.

Verified: 1798 tests pass at 100% coverage, 8 of 9 lib/tests suites pass
(the ninth needs the coverage jail, identically at baseline), pre-commit
is clean, and the two untracked secret configs are still ignored after
their gitignore paths changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJrqKERAo5QUeEHWS5Asgp
2026-08-23 18:53:30 +02:00

11 KiB
Raw Blame History

name description
efficient-polling-scripts Use BEFORE writing any shell or Python script that runs on a timer, per-tick status bar (i3blocks/waybar/polybar), cron-like loop, or any repeated invocation. Prevents fork-storm anti-patterns that can consume many CPU-hours per day from tiny polling scripts.

Efficient Polling & Status-Bar Scripts

When this applies

Any script that runs frequently — per second or per few seconds — especially:

  • i3blocks / waybar / polybar / xmobar / tmux status-line scripts
  • cron / systemd-timer jobs with intervals < 1 min
  • watcher loops invoked by another process every tick
  • Python CLIs invoked from a shell hot loop

A single fork pipeline running once per second will consume ~30–50 CPU-minutes per day per forked helper. Five such scripts with 3–8 helpers each turn into days of CPU-time lost per day and tens of thousands of forked processes showing up in atop.

The rules

R1. Zero forks in the hot path when possible

Every $(...), backtick, and | in a shell script forks a process. Favor bash builtins:

Instead of Use
$(cat /proc/loadavg) $(</proc/loadavg) or read -r one _ < /proc/loadavg
echo "$x" | awk '{print $1}' read -r first _ <<< "$x" or arr=($x); first=${arr[0]}
echo "$x" | tr -d '%' ${x//%/}
echo "$x" | grep -Po '\d+%' [[ $x =~ ([0-9]+)% ]] && vol=${BASH_REMATCH[1]}
echo "$a < $b" | bc -l (( a_times_100 < b_times_100 )) (scale decimals to ints)
sensors | awk ... read -r milli < /sys/class/hwmon/hwmonN/temp1_input
acpi -b | awk ... read -r cap < /sys/class/power_supply/BAT0/capacity
free -h | awk ... parse /proc/meminfo with while read -r
df -h / | awk ... stat -f builtin? No: use a long-lived reader, or accept one fork at low frequency
lspci | grep -i nvidia check /sys/bus/pci/devices/*/vendor (0x10de == NVIDIA)

R2. Read from /sys and /proc directly

The kernel exposes structured data without forking anything. Useful paths:

  • CPU load: /proc/loadavg
  • CPU per-core stat: /proc/stat
  • Memory: /proc/meminfo
  • Temps / fans / voltages: /sys/class/hwmon/hwmon*/
    • CPU on AMD: name=k10temp, temp1_input = Tctl (milli-°C, divide by 1000)
    • CPU on Intel: name=coretemp
    • Motherboard Super-I/O: name=nct* / it87* / f71*
    • AMD GPU: name=amdgpu, plus /sys/class/drm/card*/device/gpu_busy_percent
  • Battery: /sys/class/power_supply/BAT*/ (capacity, status, energy_now, power_now)
  • Backlight: /sys/class/backlight/*/brightness
  • Network link: /sys/class/net/*/operstate, /sys/class/net/*/statistics/*_bytes

NVIDIA is the unfortunate exception — there is no sysfs utilization interface, so nvidia-smi is required. Mitigate with R4 (long-lived producer).

R3. Integer arithmetic, never bc in a hot loop

bc forks a process. For decimal comparisons, multiply out:

# "1.23" → 123, "0.45" → 45; compare against threshold ×100.
load_x100=$((10#${one//./}))
(( load_x100 < 150 )) && echo 'normal'

Bash's ((…)) and [[ … ]] are builtins — free.

R4. Prefer event-driven / long-lived producers over polling + sleep

When an update needs to happen often, replace "poll + sleep + exit" with one of:

  • i3blocks interval=persist: script runs forever, prints one block per update. Block on an event stream with read — no sleep, no busy-wait.
  • pactl subscribe: event stream for PulseAudio/PipeWire volume/mute changes.
  • udevadm monitor: hardware / power-supply / backlight events.
  • inotifywait -m: file/dir changes.
  • dbus-monitor: session-wide events (network, media keys, NetworkManager).
  • journalctl -f: new log lines.
  • nvidia-smi --loop=N / nvidia-smi dmon -d N: one long-lived nvidia-smi emitting rows instead of forking every N seconds. Tail its stdout with while read.
  • mpstat N, iostat N, vmstat N: same pattern for CPU/IO.

Canonical persist skeleton:

#!/bin/bash
set -u
emit() { printf '%s\n' "$1"; }

emit "$(initial_value)"
producer_command | while read -r line; do
  # `read` blocks on I/O — no CPU, no sleep, no poll.
  [[ $line matches relevant event ]] || continue
  emit "$(compute_new_value)"
done

R5. One-shot scripts must still be cheap

Even with interval=5, 1728 invocations/day × 3 forks = 5k forks/day. Make the single-invocation path fork-free when possible. Profile with:

strace -f -e trace=%process -c ./myscript.sh

The clone / execve counts are your fork count.

R6. Python called from a hot loop is an anti-pattern

CPython startup is ~50–80 ms on modern hardware. Invoking python my_helper.py once per second = ~5–8% of one core doing nothing but importing stdlib.

If a status-bar value needs Python logic:

  • Inline it in bash when possible (the rules above almost always suffice).
  • Run a persistent Python daemon that writes to a FIFO / Unix socket / tmpfile; the bash hot-path reads from it with read / $(<file).
  • Use a compiled helper (Go/Rust/C) if Python startup is the only issue — a static binary startup is sub-millisecond.

R7. Cap risk with a systemd slice

Even a correct script can regress. Put status-bar / monitoring work in a resource-capped user slice so the blast radius is bounded:

# ~/.config/systemd/user/monitors.slice
[Slice]
CPUQuota=50%
MemoryMax=512M
MemorySwapMax=0   # REQUIRED on zram systems — see oom-prevention skill
TasksMax=256

Launch i3blocks (or individual persist scripts) under that slice, e.g. via a user service with Slice=monitors.slice, so every child inherits the cap.

R8. Measure before and after

For any "fast" shell script, time 10k invocations:

time for _ in {1..10000}; do ./script.sh >/dev/null; done

Target: a 1-Hz script should take < 2 ms per invocation on a modern desktop. A 5-second-interval script can afford ~20 ms. If you're over budget, count the execve with strace -c and remove forks.

R9. Never flush+rebuild firewall rules unconditionally in a poll loop

iptables -F chain followed by dozens of iptables -A calls every tick is one of the most expensive fork patterns possible — each rule insert is its own fork, plus it contends the xtables lock with the kernel's own netfilter subsystem (netd on Android, firewalld/nftables on Linux). This is a specific enough, and costly enough, instance of R6-style unconditional recomputation to call out on its own: a real incident (phone_focus_mode/dns_enforcer.sh, see feedback-iptables-hash-gate-before-rebuild in this user's memory) rebuilt ~100 rules every 20s with no state check at all, and measured out to pegging netd at a sustained ~50% CPU, contributing to a phone overheating.

Fix: check whether the current state is already correct before touching iptables at all —

# Cheap check (2-3 forks): is the chain already right?
chain_intact() {
    "$1" -C OUTPUT -j "$2" >/dev/null 2>&1 || return 1
    [ "$("$1" -S "$2" 2>/dev/null | grep -c '^-A')" = "$3" ]
}

# Only pay for the expensive flush+rebuild (50-100+ forks) when it's wrong.
chain_intact iptables MY_CHAIN "$expected_count" || rebuild_my_chain

This preserves self-healing (a flush by the kernel or another process is still detected and repaired on the next tick) while skipping the ~95% of ticks where nothing changed. If diagnosing whether this pattern is already live in a running system, kill -STOP/kill -CONT the suspect script's PID and compare /proc/<pid>/stat utime+stime jiffy deltas of the netfilter daemon before/after — don't trust dumpsys cpuinfo (Android) or similar cached snapshot tools, some builds don't refresh them between calls.

Python-specific rules (for daemons, not hot-loop callees)

  • Use pathlib.Path.read_text() / read_bytes() — one syscall, no subprocess.
  • Open /sys / /proc files with the builtin open(); they're tiny reads.
  • For event loops, use asyncio / selectors to block on fds (same idea as read in bash) instead of time.sleep() in a polling loop.
  • Don't shell out with subprocess.run("sensors") when /sys/class/hwmon exists.
  • Cache psutil objects across ticks — psutil.cpu_percent(interval=None) uses deltas and is O(1) after the first call.

Common red flags (search for these in review)

  • while true / while : with a sleep and no event source
  • $(…|…|…) chains with three or more pipes in a status-bar script
  • | awk, | grep, | tr, | cut, | sed, | head, | tail where bash builtins would do
  • $(cat foo) anywhere — always replaceable with $(<foo)
  • echo … | bc — replaceable with bash integer math
  • sensors, acpi, free, lspci, iwgetid in a per-second script
  • python … / node … invoked per tick
  • No set -u (silent typo bugs compound over thousands of ticks)
  • iptables -F / ip6tables -F followed by unconditional -A rebuilds with no preceding "is this already correct?" check (see R9)

Verification checklist before shipping

  1. shellcheck script.sh — clean.
  2. strace -c -f script.sh 2>&1 | grep -E 'execve|clone' — fork count matches expectation.
  3. time for _ in {1..10000}; do script.sh >/dev/null; done — under budget.
  4. For persist scripts: run for 60 s under perf stat -p $PID — CPU time near zero when idle.
  5. Running under the monitors.slice unit — verify with systemctl --user status monitors.slice.

Reference implementations in this repo

  • linux_configuration/i3-configuration/i3blocks/volume.sh — persist mode with pactl subscribe.
  • linux_configuration/i3-configuration/i3blocks/gpu_monitor.sh — persist mode with nvidia-smi --loop.
  • linux_configuration/i3-configuration/i3blocks/battery_status.sh — zero-fork via /sys/class/power_supply.
  • linux_configuration/i3-configuration/i3blocks/cpu_monitor.sh — zero-fork via /proc/loadavg + /sys/class/hwmon.
  • linux_configuration/i3-configuration/i3blocks/motherboard_temp.sh — zero-fork via /sys/class/hwmon.
  • linux_configuration/system-maintenance/systemd/monitors.slice — resource-cap slice.
  • phone_focus_mode/dns_enforcer.sh::chain_intact()/expected_rule_count() — R9's hash/count-gate pattern, fixed 2026-07-03 after it was found pegging Android's netd at ~50% CPU.
  • phone_focus_mode/hosts_enforcer.sh::sync_magisk_module — the original hash-before-write pattern R9 mirrors.