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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
This commit is contained in:
Krzysztof Rudnicki 2026-07-11 00:16:11 +02:00
parent 2d0d69b461
commit fc4b504093
7 changed files with 184 additions and 18 deletions

View File

@ -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/<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.
@ -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.

View File

@ -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"
}

View File

@ -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."
]
}

View File

@ -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

View File

@ -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() {

View File

@ -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
}

View File

@ -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