From fc4b504093ec2ad3a90fcc858402e38cc73a8407 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 11 Jul 2026 00:16:11 +0200 Subject: [PATCH] Harden phone_focus_mode CPU use; whitelist wake_alarm_sync The on-device focus enforcer was overheating the phone by rebuilding firewall rules every tick. State-gate the work instead: - dns_enforcer.sh: only rebuild the DNS chain when it's missing, detached from OUTPUT, or the wrong rule count (chain_intact/expected_rule_count), instead of flushing+reinserting ~50-100 rules every tick (~50% netd CPU). - config.sh: throttle CURFEW_NET_REASSERT_INTERVAL 1s -> 15s (1s forked iptables every second all night, overheating); also whitelist com.kuhy.wake_alarm_sync (general + curfew-night lists). - focus_daemon.sh: build the blocked-system-apps file once at startup and memoize is_allowed() per tick. - deploy.sh: drop `su --mount-master` (removed in Magisk v26+). - SKILL.md: document the state-change-gate polling pattern. Verification: shellcheck clean; no-polling-antipatterns + evidence/contract hooks pass. Evidence + contract artifacts included. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM --- .../skills/efficient-polling-scripts/SKILL.md | 39 ++++++++++++++++++ .../phone-focus-cpu-hardening-2026-07-10.json | 16 ++++++++ .../phone-focus-cpu-hardening-2026-07-10.json | 40 ++++++++++++++++++ phone_focus_mode/config.sh | 20 ++++++--- phone_focus_mode/deploy.sh | 7 ++-- phone_focus_mode/dns_enforcer.sh | 41 ++++++++++++++++++- phone_focus_mode/focus_daemon.sh | 39 ++++++++++++++---- 7 files changed, 184 insertions(+), 18 deletions(-) create mode 100644 docs/superpowers/contracts/phone-focus-cpu-hardening-2026-07-10.json create mode 100644 docs/superpowers/evidence/phone-focus-cpu-hardening-2026-07-10.json diff --git a/.github/skills/efficient-polling-scripts/SKILL.md b/.github/skills/efficient-polling-scripts/SKILL.md index 84813950..1ceb4b95 100644 --- a/.github/skills/efficient-polling-scripts/SKILL.md +++ b/.github/skills/efficient-polling-scripts/SKILL.md @@ -140,6 +140,41 @@ 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 — + +```bash +# 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//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. @@ -158,6 +193,8 @@ If you're over budget, count the `execve` with `strace -c` and remove forks. - `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 @@ -175,3 +212,5 @@ If you're over budget, count the `execve` with `strace -c` and remove forks. - `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. +- `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. diff --git a/docs/superpowers/contracts/phone-focus-cpu-hardening-2026-07-10.json b/docs/superpowers/contracts/phone-focus-cpu-hardening-2026-07-10.json new file mode 100644 index 00000000..17d7bb0c --- /dev/null +++ b/docs/superpowers/contracts/phone-focus-cpu-hardening-2026-07-10.json @@ -0,0 +1,16 @@ +{ + "title": "phone_focus_mode CPU/overheat hardening", + "objective": "Eliminate redundant per-tick firewall and rule rebuilds in the on-device focus enforcer that were pegging netd and overheating the phone, without weakening enforcement or self-healing, and whitelist the wake_alarm_sync companion app.", + "acceptance_criteria": [ + "DNS chain is rebuilt only when missing, detached from OUTPUT, or the wrong rule count; identical rebuilds are skipped.", + "Curfew net reassert runs every 15s instead of every 1s.", + "Blocked-system-apps file is built once at startup; is_allowed() is memoized per tick.", + "com.kuhy.wake_alarm_sync is present in both the general and curfew-night whitelists.", + "shellcheck --severity=warning passes on all four scripts and the no-polling-antipatterns hook passes." + ], + "out_of_scope": [ + "Replacing the interim iptables stopgap with a netd/eBPF per-UID firewall.", + "Any change to the Linux-side focus daemon (this is the Android/ADB enforcer)." + ], + "verifier": "shellcheck --severity=warning + pre-commit no-polling-antipatterns hook + on-device netd CPU observation" +} diff --git a/docs/superpowers/evidence/phone-focus-cpu-hardening-2026-07-10.json b/docs/superpowers/evidence/phone-focus-cpu-hardening-2026-07-10.json new file mode 100644 index 00000000..0698824a --- /dev/null +++ b/docs/superpowers/evidence/phone-focus-cpu-hardening-2026-07-10.json @@ -0,0 +1,40 @@ +{ + "intent": "Stop the on-device phone_focus_mode enforcer from overheating the phone by eliminating redundant per-tick firewall/rule rebuilds, while preserving self-healing enforcement; also whitelist the new wake_alarm_sync app so it can set tomorrow's alarm at night.", + "scope": [ + "phone_focus_mode/config.sh", + "phone_focus_mode/dns_enforcer.sh", + "phone_focus_mode/focus_daemon.sh", + "phone_focus_mode/deploy.sh", + ".github/skills/efficient-polling-scripts/SKILL.md", + "Non-goal: replacing the interim iptables stopgap with a proper netd/eBPF per-UID firewall (tracked as a follow-up)" + ], + "changes": [ + "config.sh: throttle CURFEW_NET_REASSERT_INTERVAL 1s -> 15s. A 1s interval forked `iptables -L` every second all night, pegging netd at ~30% CPU and overheating the phone; 15s matches HOSTS/LAUNCHER pacing. Deliberate tradeoff: curfew leak window grows from <=1s to <=15s.", + "config.sh: add com.kuhy.wake_alarm_sync to the general and curfew-night app whitelists so the wake-alarm phone companion can run (it writes the alarm time to a GitHub Gist).", + "dns_enforcer.sh: state-gate the DNS chain rebuild. fill_chain_v4/v6 flush+reinsert ~50-100 rules each call; running that unconditionally every tick pegged netd at ~50% CPU on the xtables lock even though the rule set never changes. New chain_intact()/expected_rule_count() only rebuild when the chain is missing, untethered from OUTPUT, or the wrong size; self-healing is preserved.", + "focus_daemon.sh: build the blocked-system-apps file once at startup instead of every enable sweep, and memoize is_allowed() lookups once per main-loop tick.", + "deploy.sh: drop `su --mount-master` (removed in Magisk v26+); plain `su -c` runs in the correct mount namespace for our use cases.", + "SKILL.md: document the state-change-gate pattern (only rebuild firewall rules when tampered) in the efficient-polling-scripts skill." + ], + "verification": [ + { + "command": "shellcheck --severity=warning phone_focus_mode/config.sh phone_focus_mode/deploy.sh phone_focus_mode/dns_enforcer.sh phone_focus_mode/focus_daemon.sh", + "result": "pass", + "evidence": "Exit 0, no warnings or errors" + }, + { + "command": "pre-commit run (staged phone_focus_mode scripts + SKILL.md)", + "result": "pass", + "evidence": "shellcheck, no-polling-antipatterns, codespell and generic hooks pass" + } + ], + "risks": [ + "Curfew net leak window grows from <=1s to <=15s during the reassert interval; acceptable vs. thermal/CPU cost.", + "State-gate on the DNS chain relies on chain_intact() correctly detecting tampering (existence + attachment + rule count); a subtle mismatch that always reads intact would skip a legitimate rebuild.", + "Dropping --mount-master assumes the deployed Magisk is v26+." + ], + "rollback": [ + "git revert the commit to restore unconditional rebuilds and the 1s interval.", + "After rollback, re-run deploy.sh and confirm the DNS/curfew chains reassert and the whitelist no longer contains wake_alarm_sync." + ] +} diff --git a/phone_focus_mode/config.sh b/phone_focus_mode/config.sh index 6001d37b..0aa3cd20 100755 --- a/phone_focus_mode/config.sh +++ b/phone_focus_mode/config.sh @@ -96,11 +96,15 @@ export CURFEW_NET_IPT_CHAIN="FOCUS_CURFEW_NET" # Android's netd periodically reasserts the whole `filter` table via # iptables-restore, which atomically erases our custom chain (proven on-device: # the chain vanishes ~every 5s). The main enforcer tick (5s) rebuilds it but -# leaves up-to-5s leak windows. This fast watchdog re-pins the chain every -# CURFEW_NET_REASSERT_INTERVAL seconds *from a cached UID list* (no pm fork), -# shrinking the leak to <=1s. The proper fix is netd's own per-UID firewall -# (ndc/eBPF), tracked as a follow-up; this is the interim stopgap. -export CURFEW_NET_REASSERT_INTERVAL=1 +# leaves up-to-5s leak windows. This watchdog re-pins the chain every +# CURFEW_NET_REASSERT_INTERVAL seconds *from a cached UID list* (no pm fork). +# A 1s interval was measured on-device to peg netd at ~30% CPU continuously +# (an `iptables -L` fork every second, all night), overheating the phone - +# throttled to 15s (matches HOSTS_CHECK_INTERVAL/LAUNCHER_CHECK_INTERVAL +# pacing) as a deliberate tradeoff: leak window grows from <=1s to <=15s. +# The proper fix is netd's own per-UID firewall (ndc/eBPF), tracked as a +# follow-up; this remains an interim stopgap either way. +export CURFEW_NET_REASSERT_INTERVAL=15 export CURFEW_NET_UID_CACHE="$STATE_DIR/curfew_net_uids.txt" # Manual test toggle: `focus_ctl.sh curfew-test-on` writes this file to force # curfew ACTIVE regardless of clock, so the whole stack can be validated during @@ -380,6 +384,9 @@ eu.kanade.tachiyomi.sy # --- Development --- com.github.android +# --- Wake alarm sync (writes alarm time to GitHub Gist for PC sync) --- +com.kuhy.wake_alarm_sync + # --- Media / podcasts --- ac.mdiq.podcini.X is.xyz.mpv @@ -468,6 +475,9 @@ com.kunzisoft.keepass.libre # Smart home (control lights before sleep) com.xiaomi.smarthome +# --- Wake alarm sync (must work at night to set tomorrow's alarm) --- +com.kuhy.wake_alarm_sync + # --- Good-for-you apps (not scroll traps; kept per your request) --- # Remove any of these if you want a stricter night. com.stronglifts.app diff --git a/phone_focus_mode/deploy.sh b/phone_focus_mode/deploy.sh index b466d1e7..4bbe546f 100755 --- a/phone_focus_mode/deploy.sh +++ b/phone_focus_mode/deploy.sh @@ -137,13 +137,12 @@ connect_adb() { } # Wrapper: run a root shell command on the phone. -# Uses --mount-master so the command sees (and can modify) the global mount -# namespace — required for any status checks that inspect the hosts bind -# mount, /data/adb/focus_mode files, or for starting daemons. +# --mount-master was removed in Magisk v26+; plain su -c works and still +# runs in the correct mount namespace for our use cases. adb_root() { local command_text="$1" - printf '%s\n' "$command_text" | adb_cmd shell su --mount-master -c "sh -s" + printf '%s\n' "$command_text" | adb_cmd shell su -c "sh -s" } compute_file_hash() { diff --git a/phone_focus_mode/dns_enforcer.sh b/phone_focus_mode/dns_enforcer.sh index 19034297..b251f365 100755 --- a/phone_focus_mode/dns_enforcer.sh +++ b/phone_focus_mode/dns_enforcer.sh @@ -167,12 +167,49 @@ fill_chain_v6() { done } +# ---- State-change gate ---- +# +# fill_chain_v4/v6 flush and reinsert ~50-100 iptables/ip6tables rules each +# call. Running that unconditionally every $DNS_CHECK_INTERVAL was measured +# to peg netd at ~50% CPU on-device (contending the xtables lock) even +# though the rule set never changes at runtime. Only rebuild when the chain +# is actually missing, untethered from OUTPUT, or the wrong size (i.e. +# someone/something - netd, a root shell, a flush - tampered with it). +# Self-healing behavior described in the header comment is preserved; only +# redundant identical rebuilds are skipped. + +expected_rule_count() { + # 2 fixed DoT rules (tcp+udp/853) + 4 rules per configured DoH/DNS IP. + local n=2 ip + for ip in "$@"; do + [ -z "$ip" ] && continue + [ "${ip#\#}" != "$ip" ] && continue + n=$((n + 4)) + done + echo "$n" +} + +chain_intact() { + local ipt="$1" expected="$2" actual + "$ipt" -C OUTPUT -j "$DNS_IPT_CHAIN" >/dev/null 2>&1 || return 1 + actual="$("$ipt" -S "$DNS_IPT_CHAIN" 2>/dev/null | grep -c '^-A')" + [ "$actual" = "$expected" ] +} + enforce_iptables() { if command -v iptables >/dev/null 2>&1; then - ensure_chain iptables && fill_chain_v4 + if chain_intact iptables "$(expected_rule_count $DNS_DOH_IPV4)"; then + : + elif ensure_chain iptables && fill_chain_v4; then + log "iptables (v4) DNS chain rebuilt (was missing/tampered)" + fi fi if command -v ip6tables >/dev/null 2>&1; then - ensure_chain ip6tables && fill_chain_v6 + if chain_intact ip6tables "$(expected_rule_count $DNS_DOH_IPV6)"; then + : + elif ensure_chain ip6tables && fill_chain_v6; then + log "ip6tables (v6) DNS chain rebuilt (was missing/tampered)" + fi fi } diff --git a/phone_focus_mode/focus_daemon.sh b/phone_focus_mode/focus_daemon.sh index 6f31d9ea..3f8ad3d9 100755 --- a/phone_focus_mode/focus_daemon.sh +++ b/phone_focus_mode/focus_daemon.sh @@ -85,6 +85,14 @@ build_sysprotect_file() { | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' > "$STATE_DIR/sysprotect.txt" } +# $BLOCKED_SYSTEM_APPS is a static config value (never changes without a +# daemon restart), so - like build_sysprotect_file above - this only needs +# to run once at startup, not every enable_focus_mode() sweep. +build_blocked_sys_file() { + echo "$BLOCKED_SYSTEM_APPS" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' \ + | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' > "$STATE_DIR/blocked_sys.txt" +} + reconcile_disabled_apps() { [ -f "$DISABLED_APPS_FILE" ] || return @@ -137,6 +145,7 @@ init() { build_whitelist_file build_night_whitelist_file build_sysprotect_file + build_blocked_sys_file refresh_default_handlers rotate_log @@ -213,11 +222,25 @@ is_curfew_now() { # (test hook) or inside the time window. The is_allowed() switch below consults # this; because is_allowed() only runs during the focus-mode sweep/reconcile, # curfew automatically takes effect only at home and is a no-op when away. +# +# Memoized once per main-loop tick (reset at the top of main()'s while loop): +# is_allowed() calls this once per enabled package in the sweep, and +# is_curfew_now forks `date` - on-device this meant up to ~N extra forks per +# 10s tick for N enabled apps, all recomputing a value that cannot change +# within a single tick. Result is cached in _CURFEW_TICK_RESULT. curfew_active() { - [ "${NIGHT_CURFEW_ENABLED:-0}" = "1" ] || return 1 - [ -e "$CURFEW_OVERRIDE_FILE" ] && return 1 - [ -e "$CURFEW_FORCE_FILE" ] && return 0 - is_curfew_now + if [ -n "$_CURFEW_TICK_CACHED" ]; then + [ "$_CURFEW_TICK_RESULT" = "1" ] + return + fi + _CURFEW_TICK_CACHED=1 + _CURFEW_TICK_RESULT=0 + if [ "${NIGHT_CURFEW_ENABLED:-0}" = "1" ] && [ ! -e "$CURFEW_OVERRIDE_FILE" ]; then + if [ -e "$CURFEW_FORCE_FILE" ] || is_curfew_now; then + _CURFEW_TICK_RESULT=1 + fi + fi + [ "$_CURFEW_TICK_RESULT" = "1" ] } # ---- Check if package is allowed (whitelist or system-protected) ---- @@ -319,10 +342,9 @@ enable_focus_mode() { # list so a newly-promoted handler is never disabled. refresh_default_handlers - # Build blocked system app list (used both at entry and for periodic sweep) + # Blocked system app list is static; built once in init() (see + # build_blocked_sys_file), not rebuilt on every sweep. local blocked_sys="$STATE_DIR/blocked_sys.txt" - echo "$BLOCKED_SYSTEM_APPS" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' \ - | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' > "$blocked_sys" # Periodic rescan catches third-party apps the user re-enabled (e.g. via # Play Store or `pm enable` in a terminal) since the last tick. @@ -472,6 +494,9 @@ main() { init while true; do + # Invalidate the per-tick curfew_active() memo (see its definition). + _CURFEW_TICK_CACHED="" + location="$(get_location)" if [ -n "$location" ]; then