diff --git a/.github/skills/efficient-polling-scripts/SKILL.md b/.github/skills/efficient-polling-scripts/SKILL.md new file mode 100644 index 0000000..8481395 --- /dev/null +++ b/.github/skills/efficient-polling-scripts/SKILL.md @@ -0,0 +1,177 @@ +--- +name: efficient-polling-scripts +description: 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)` | `$(/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. + +## 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 `$(&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/scripts/system-maintenance/systemd/monitors.slice` — resource-cap slice. diff --git a/linux_configuration/i3-configuration/i3blocks/battery_status.sh b/linux_configuration/i3-configuration/i3blocks/battery_status.sh index 8d58b91..48df325 100755 --- a/linux_configuration/i3-configuration/i3blocks/battery_status.sh +++ b/linux_configuration/i3-configuration/i3blocks/battery_status.sh @@ -1,11 +1,49 @@ #!/bin/bash +# i3blocks battery indicator, zero-fork per invocation. +# +# Reads /sys/class/power_supply directly instead of forking `acpi | awk`. +# Uses only bash builtins (read, printf, arithmetic, parameter expansion). -acpi -b | awk -F', ' ' - /Battery/ { - split($2, percent, "%") - split($3, time, " ") - printf " %d%%", percent[1] - if (time[1] != "") printf ", %s", time[1] - if ($1 ~ /Charging/) printf ", " - printf "\n" - }' +set -u + +bat= +for d in /sys/class/power_supply/BAT*/; do + [[ -d $d ]] && { + bat=$d + break + } +done + +if [[ -z $bat ]]; then + # Desktop with no battery — emit empty block so i3bar hides it. + echo + exit 0 +fi + +cap='N/A' +[[ -r ${bat}capacity ]] && read -r cap < "${bat}capacity" + +status='' +[[ -r ${bat}status ]] && read -r status < "${bat}status" + +# Compute time remaining from energy_now/power_now (µWh / µW → hours). +# Falls back to charge_now/current_now on batteries that expose charge instead. +time_str='' +num=0 +den=0 +if [[ -r ${bat}energy_now && -r ${bat}power_now ]]; then + read -r num < "${bat}energy_now" + read -r den < "${bat}power_now" +elif [[ -r ${bat}charge_now && -r ${bat}current_now ]]; then + read -r num < "${bat}charge_now" + read -r den < "${bat}current_now" +fi +if ((den > 0 && num > 0)); then + total_min=$((num * 60 / den)) + printf -v time_str '%02d:%02d' "$((total_min / 60))" "$((total_min % 60))" +fi + +printf ' %s%%' "$cap" +[[ -n $time_str ]] && printf ', %s' "$time_str" +[[ $status == Charging ]] && printf ', ' +printf '\n' diff --git a/linux_configuration/i3-configuration/i3blocks/config b/linux_configuration/i3-configuration/i3blocks/config index 730f0ba..1c7e7d3 100644 --- a/linux_configuration/i3-configuration/i3blocks/config +++ b/linux_configuration/i3-configuration/i3blocks/config @@ -6,7 +6,7 @@ markup=pango [gpu_monitor] command=~/.config/i3blocks/gpu_monitor.sh -interval=5 +interval=persist markup=pango @@ -31,7 +31,7 @@ color=#50FA7B [volume] command=~/.config/i3blocks/volume.sh -interval=1 +interval=persist diff --git a/linux_configuration/i3-configuration/i3blocks/cpu_monitor.sh b/linux_configuration/i3-configuration/i3blocks/cpu_monitor.sh index f2b7edc..3bd5491 100755 --- a/linux_configuration/i3-configuration/i3blocks/cpu_monitor.sh +++ b/linux_configuration/i3-configuration/i3blocks/cpu_monitor.sh @@ -1,48 +1,60 @@ #!/bin/bash +# i3blocks CPU monitor, zero-fork per invocation. +# +# Reads /proc/loadavg and /sys/class/hwmon/*/temp*_input directly instead +# of forking `sensors | awk | tr` and `echo | bc`. Pure bash builtins. -# CPU Temperature -cpu_temp=$(sensors | awk '/^Tctl:/ {print $2}' | tr -d '+°C') -if [ -z "$cpu_temp" ]; then - cpu_temp=$(sensors | awk '/^Package id 0:/ {print $4}' | tr -d '+°C') -fi -if [ -z "$cpu_temp" ]; then - cpu_temp=$(sensors | awk '/^Core 0:/ {print $3}' | tr -d '+°C') -fi -if [ -z "$cpu_temp" ]; then - cpu_temp="N/A" +set -u + +# Locate AMD k10temp or Intel coretemp hwmon node. +hwmon='' +for d in /sys/class/hwmon/hwmon*/; do + [[ -r ${d}name ]] || continue + read -r n < "${d}name" + case $n in + k10temp | coretemp) + hwmon=$d + break + ;; + esac +done + +temp='N/A' +temp_int=-1 +if [[ -n $hwmon && -r ${hwmon}temp1_input ]]; then + read -r milli < "${hwmon}temp1_input" + temp_int=$((milli / 1000)) + temp=$temp_int fi -# CPU Load (1-minute average) -cpu_load=$(awk '{print $1}' /proc/loadavg) -if [ -z "$cpu_load" ]; then - cpu_load="N/A" +load='N/A' +load_x100=0 +if [[ -r /proc/loadavg ]]; then + read -r one _ < /proc/loadavg + load=$one + # loadavg prints two decimals, e.g. "1.23" → 123, "0.05" → 5. + load_digits=${one//./} + load_digits=${load_digits##0} + load_x100=$((10#${load_digits:-0})) fi -# Colors for CPU Load and Temperature -cpu_color="#FFFFFF" # Default color - -# Change color based on CPU load -if [[ $cpu_load != "N/A" ]]; then - cpu_load_float=$(echo "$cpu_load" | awk '{print ($1 + 0)}') - if (($(echo "$cpu_load_float < 1.0" | bc -l))); then - cpu_color="#50FA7B" # Green for low load - elif (($(echo "$cpu_load_float < 2.0" | bc -l))); then - cpu_color="#F1FA8C" # Yellow for medium load +color='#FFFFFF' +if ((temp_int >= 0)); then + if ((temp_int < 65)); then + color='#50FA7B' + elif ((temp_int < 85)); then + color='#F1FA8C' else - cpu_color="#FF5555" # Red for high load + color='#FF5555' + fi +elif ((load_x100 > 0)); then + if ((load_x100 < 100)); then + color='#50FA7B' + elif ((load_x100 < 200)); then + color='#F1FA8C' + else + color='#FF5555' fi fi -# Change color based on CPU temperature -if [[ $cpu_temp != "N/A" ]]; then - cpu_temp_float=$(echo "$cpu_temp" | awk '{print ($1 + 0)}') - if (($(echo "$cpu_temp_float < 65.0" | bc -l))); then - cpu_color="#50FA7B" # Green for low temperature - elif (($(echo "$cpu_temp_float < 85.0" | bc -l))); then - cpu_color="#F1FA8C" # Yellow for medium temperature - else - cpu_color="#FF5555" # Red for high temperature - fi -fi - -echo -e " ${cpu_temp}°C, ${cpu_load}" +printf ' %s°C, %s\n' "$color" "$temp" "$load" diff --git a/linux_configuration/i3-configuration/i3blocks/gpu_monitor.sh b/linux_configuration/i3-configuration/i3blocks/gpu_monitor.sh index cfe1c8b..35b50fc 100755 --- a/linux_configuration/i3-configuration/i3blocks/gpu_monitor.sh +++ b/linux_configuration/i3-configuration/i3blocks/gpu_monitor.sh @@ -1,64 +1,75 @@ #!/bin/bash +# i3blocks GPU monitor, persist mode. +# +# Keeps a single long-lived `nvidia-smi --loop=5` (or reads amdgpu sysfs +# in a blocking-read loop) instead of forking nvidia-smi/lspci/awk/tr/bc +# every interval. No sleep, no polling loop in bash — nvidia-smi's own +# periodic emitter drives updates and we block on `read`. +# +# Configure with `interval=persist` in the i3blocks config. -# Function to get NVIDIA GPU metrics -get_nvidia_metrics() { - gpu_temp=$(nvidia-smi --query-gpu=temperature.gpu --format=csv,noheader,nounits 2> /dev/null) - if [ -z "$gpu_temp" ]; then - gpu_temp="N/A" - fi +set -u - gpu_load=$(nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader,nounits 2> /dev/null) - if [ -z "$gpu_load" ]; then - gpu_load="N/A" - fi - - echo "GPU Temp: $gpu_temp°C, GPU Load: $gpu_load" -} - -# Function to get Intel GPU metrics -get_intel_metrics() { - gpu_load=$(cat /sys/class/drm/card0/device/gpu_busy_percent 2> /dev/null) - if [ -z "$gpu_load" ]; then - gpu_load="N/A" - fi - - gpu_temp=$(sensors | awk '/^temp1:/ {print $2; exit}' | tr -d '+°C') - if [ -z "$gpu_temp" ]; then - gpu_temp="N/A" - fi - - echo "GPU Temp: $gpu_temp°C, GPU Load: $gpu_load" -} - -# Detect GPU type and get metrics -if lspci | grep -i nvidia > /dev/null; then - gpu_metrics=$(get_nvidia_metrics) -elif lspci | grep -i vga | grep -i intel > /dev/null; then - gpu_metrics=$(get_intel_metrics) -else - echo "No supported GPU found." -fi - -#!/bin/bash -# GPU Metrics -gpu_temp=$(echo "$gpu_metrics" | awk -F', ' '{print $1}' | awk -F': ' '{print $2}') -gpu_load=$(echo "$gpu_metrics" | awk -F', ' '{print $2}' | awk -F': ' '{print $2}') - -gpu_color="#FFFFFF" -# Colors for GPU Load -if [[ $gpu_load != "N/A" ]]; then - if (($(echo "$gpu_load < 50.0" | bc -l))); then - gpu_color="#50FA7B" # Green - elif (($(echo "$gpu_load < 75.0" | bc -l))); then - gpu_color="#F1FA8C" # Yellow +emit() { + local temp=$1 load=$2 color + if [[ $load == 'N/A' ]]; then + color='#FFFFFF' + elif ((load < 50)); then + color='#50FA7B' + elif ((load < 75)); then + color='#F1FA8C' else - gpu_color="#FF5555" # Red + color='#FF5555' fi -else - gpu_color="#FFFFFF" # Default color + printf ' %s°C, %s%%\n\n%s\n' \ + "$color" "$temp" "$load" "$color" +} + +# Prefer NVIDIA if present (persist via --loop). +if command -v nvidia-smi > /dev/null 2>&1; then + # One child process for the lifetime of i3blocks; emits CSV every 5s. + nvidia-smi \ + --query-gpu=temperature.gpu,utilization.gpu \ + --format=csv,noheader,nounits \ + --loop=5 2> /dev/null | + while IFS=',' read -r temp load; do + # Strip leading/trailing whitespace using parameter expansion. + temp=${temp## } + temp=${temp%% } + load=${load## } + load=${load%% } + [[ -z $temp || -z $load ]] && continue + emit "$temp" "$load" + done + exit 0 fi -# Output< -echo -e " ${gpu_temp}, ${gpu_load}%" -echo -echo "#FFFFFF" # Default color for fallback (ignored if markup is enabled) +# AMD fallback: read sysfs directly; emit once (i3blocks restarts on exit). +amdgpu='' +for d in /sys/class/hwmon/hwmon*/; do + [[ -r ${d}name ]] || continue + read -r n < "${d}name" + [[ $n == amdgpu ]] && { + amdgpu=$d + break + } +done +if [[ -n $amdgpu ]]; then + temp='N/A' + if [[ -r ${amdgpu}temp1_input ]]; then + read -r milli < "${amdgpu}temp1_input" + temp=$((milli / 1000)) + fi + load='N/A' + # drm card matching the amdgpu hwmon exposes gpu_busy_percent. + for card in /sys/class/drm/card*/device/gpu_busy_percent; do + [[ -r $card ]] && { + read -r load < "$card" + break + } + done + emit "$temp" "$load" + exit 0 +fi + +printf 'No supported GPU\n\n#FF5555\n' diff --git a/linux_configuration/i3-configuration/i3blocks/motherboard_temp.sh b/linux_configuration/i3-configuration/i3blocks/motherboard_temp.sh index 77e47c5..3009462 100755 --- a/linux_configuration/i3-configuration/i3blocks/motherboard_temp.sh +++ b/linux_configuration/i3-configuration/i3blocks/motherboard_temp.sh @@ -1,26 +1,51 @@ #!/bin/bash +# i3blocks motherboard-temperature indicator, zero-fork per invocation. +# +# Reads /sys/class/hwmon directly instead of forking `sensors | awk | tr`. +# Prefers Super-I/O chips (nct*, it*, f71*) which expose the true board +# sensor; falls back to the first non-CPU/GPU/NIC hwmon with a temp1_input. -# Get the first temp1 value from sensors -temp=$(sensors | awk '/^temp1:/ {print $2; exit}' | tr -d '+°C') +set -u -# Ensure the temperature is a valid number -if [[ ! $temp =~ ^[0-9]+(\.[0-9]+)?$ ]]; then - echo " MB: N/A" - echo - echo "#FF5555" # Red color for error - exit 1 +hwmon='' +for d in /sys/class/hwmon/hwmon*/; do + [[ -r ${d}name ]] || continue + read -r n < "${d}name" + case $n in + nct* | it87* | it8* | f71*) + hwmon=$d + break + ;; + esac +done +if [[ -z $hwmon ]]; then + for d in /sys/class/hwmon/hwmon*/; do + [[ -r ${d}name && -r ${d}temp1_input ]] || continue + read -r n < "${d}name" + case $n in + k10temp | coretemp | amdgpu | nouveau | nvme | r8169* | iwlwifi*) continue ;; + *) + hwmon=$d + break + ;; + esac + done fi -# Define temperature thresholds -if (($(echo "$temp < 50.0" | bc -l))); then - color="#50FA7B" # Green for OK temperature -elif (($(echo "$temp < 70.0" | bc -l))); then - color="#F1FA8C" # Yellow for warning temperature +if [[ -z $hwmon || ! -r ${hwmon}temp1_input ]]; then + printf ' MB: N/A\n\n#FF5555\n' + exit 0 +fi + +read -r milli < "${hwmon}temp1_input" +temp=$((milli / 1000)) + +if ((temp < 50)); then + color='#50FA7B' +elif ((temp < 70)); then + color='#F1FA8C' else - color="#FF5555" # Red for high temperature + color='#FF5555' fi -# Output the temperature with the color -echo " ${temp}°C" #  is a thermometer icon -echo -echo $color +printf ' %s°C\n\n%s\n' "$temp" "$color" diff --git a/linux_configuration/i3-configuration/i3blocks/volume.sh b/linux_configuration/i3-configuration/i3blocks/volume.sh index c6e1112..1bb1884 100755 --- a/linux_configuration/i3-configuration/i3blocks/volume.sh +++ b/linux_configuration/i3-configuration/i3blocks/volume.sh @@ -1,19 +1,41 @@ #!/bin/bash +# i3blocks persist-mode volume indicator. +# +# Event-driven: blocks in `read` on the `pactl subscribe` event stream. +# No sleep, no polling loop, no awk/tr/grep forks. One pactl-subscribe +# process stays alive; two short pactl calls run only on actual events. +# +# Configure with `interval=persist` in the i3blocks config. -# Get the current volume level and mute status -volume=$(pactl get-sink-volume @DEFAULT_SINK@ | awk '{print $5}' | tr -d '%') -mute=$(pactl get-sink-mute @DEFAULT_SINK@ | awk '{print $2}') -color="#50FA7B" +set -u -# Determine icon and color based on mute status -if [ "$mute" = "yes" ]; then - icon="🔇" # Muted - color="#FF5555" -else - icon="🔊" # Volume icon -fi +GREEN='#50FA7B' +RED='#FF5555' -# Output the volume with icon and color -echo "$icon $volume%" -echo -echo "$color" +emit() { + local raw mute vol icon color + raw=$(pactl get-sink-volume @DEFAULT_SINK@ 2> /dev/null) || return 0 + if [[ $raw =~ ([0-9]+)% ]]; then + vol=${BASH_REMATCH[1]} + else + vol=0 + fi + + mute=$(pactl get-sink-mute @DEFAULT_SINK@ 2> /dev/null) || return 0 + if [[ $mute == *yes ]]; then + icon='🔇' + color=$RED + else + icon='🔊' + color=$GREEN + fi + + printf '%s %s%%\n\n%s\n' "$icon" "$vol" "$color" +} + +emit +# `read -r` blocks on the event stream — no busy-wait, no sleep. +pactl subscribe 2> /dev/null | while read -r line; do + [[ $line == *"on sink"* || $line == *"on server"* ]] || continue + emit +done diff --git a/linux_configuration/scripts/system-maintenance/bin/kill_stale_recorders.sh b/linux_configuration/scripts/system-maintenance/bin/kill_stale_recorders.sh new file mode 100755 index 0000000..9eddb81 --- /dev/null +++ b/linux_configuration/scripts/system-maintenance/bin/kill_stale_recorders.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# Kill stray recorders and .NET profilers that were left running unintentionally. +# +# Targets based on the resource-usage report: ffmpeg x11grab and dotnet +# trace/monitor/ust processes can easily burn GiB of RAM and many CPU-hours +# after whatever session started them has ended. +# +# Usage: +# kill_stale_recorders.sh # interactive: lists matches, prompts +# kill_stale_recorders.sh --force # non-interactive: kill all matches +# kill_stale_recorders.sh --dry-run # only list, don't kill + +set -euo pipefail + +PATTERNS=( + 'ffmpeg.*x11grab' + 'ffmpeg.*-f[[:space:]]+x11grab' + 'dotnet-trace' + 'dotnet-monitor' + 'dotnet-ust' +) + +mode='prompt' +case ${1:-} in + --force) mode='force' ;; + --dry-run) mode='dry' ;; + -h | --help) + sed -n '2,12p' "$0" + exit 0 + ;; +esac + +mapfile -t matches < <( + for pat in "${PATTERNS[@]}"; do + pgrep -af "$pat" 2> /dev/null || true + done | sort -u +) + +if ((${#matches[@]} == 0)); then + echo 'No stale recorder/profiler processes found.' + exit 0 +fi + +echo 'Stale processes:' +printf ' %s\n' "${matches[@]}" + +if [[ $mode == dry ]]; then + exit 0 +fi + +if [[ $mode == prompt ]]; then + read -r -p 'Kill these? [y/N] ' answer + [[ $answer == [yY] ]] || { + echo 'Aborted.' + exit 0 + } +fi + +killed=0 +for line in "${matches[@]}"; do + pid=${line%% *} + [[ -n $pid ]] || continue + if kill "$pid" 2> /dev/null; then + killed=$((killed + 1)) + fi +done +echo "Sent SIGTERM to $killed process(es)." diff --git a/linux_configuration/scripts/system-maintenance/systemd/monitors.slice b/linux_configuration/scripts/system-maintenance/systemd/monitors.slice new file mode 100644 index 0000000..b9d9b73 --- /dev/null +++ b/linux_configuration/scripts/system-maintenance/systemd/monitors.slice @@ -0,0 +1,20 @@ +[Unit] +Description=Resource-capped slice for user monitoring / status-bar scripts +Documentation=https://www.freedesktop.org/software/systemd/man/systemd.slice.html + +[Slice] +# Cap the entire slice at 50% of one CPU and 512 MiB of RAM. If i3blocks or +# any other status-bar tooling enters a fork-storm regime (as was observed +# when polling scripts forked awk/tr/grep/bc every tick), the kernel will +# throttle the slice rather than let it eat the box. +CPUQuota=50% +# MemorySwapMax=0 is required on systems with zram: without it, a cgroup +# hitting MemoryMax thrashes zram instead of being OOM-killed, freezing +# the machine. See .github/skills/oom-prevention/SKILL.md. +MemoryMax=512M +MemorySwapMax=0 +TasksMax=256 + +# Make sure killing the slice reaps every descendant. +[Install] +WantedBy=default.target