mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:27:02 +01:00
fix(wellbeing): stop focus mode reading zombie processes as running Steam
Focus mode flapped in and out of GAMING mode every few seconds and killed
browsers while no Steam was running. The pattern matching was not at
fault: the daemon was correctly seeing a process named "steam". That
process was a root-owned zombie.
A total gaming block on 2026-07-04 purged the Steam client and it was
never reinstalled, but steam-backlog-enforcer kept trying to launch it
through an orphaned /usr/local/bin/steam wrapper still pointing at the
deleted /usr/bin/steam. Each launch died on exec and, never being reaped,
lingered as a dead process that still carried the name "steam" - which
this daemon counted as a live Steam.
get_running_processes() now reads /proc/*/stat rather than /proc/*/comm
and skips state Z. Both cost one read per PID, so the poll loop stays
fork-free, but stat also carries the process state. _parse_stat() uses
rpartition(')') because a process name can itself contain spaces and
')', which naive whitespace splitting would corrupt. Names are truncated
to 15 chars in stat exactly as in comm, so the existing patterns match
unchanged.
Verified against the live zombie, before the enforcer was fixed:
zombies on system now : ['steam']
OLD (/proc/*/comm) sees 'steam' : True
NEW (zombie-filtered) sees 'steam' : False
dropped by filter : ['steam']
and non-regression on real processes: a synthetic live 'steam' and a
'steam_app_29687' game both still return True. It skips zombies, not
Steam. Running `focus-mode-daemon` now reports "Browser detected -
entering BROWSING mode" with no GAMING flapping.
The zombie source is fixed separately in steam-backlog-enforcer d54df3a;
this is the durable guard against any future one.
Note: install_focus_mode_daemon.sh copies rather than symlinks, so
/usr/local/bin/focus-mode-daemon was a stale copy without this fix.
Reinstalled. Editing this file alone never changes what runs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rN2usTS3pYLJqeVg2ZpgR
This commit is contained in:
parent
147ec3f690
commit
22e22c16a7
@ -0,0 +1,53 @@
|
||||
{
|
||||
"intent": "Stop focus mode from entering GAMING mode and killing browsers when no Steam is running. The daemon was flapping in and out of GAMING mode every few seconds against a Steam that had been uninstalled since 2026-07-04.",
|
||||
"scope": [
|
||||
"linux_configuration/scripts/periodic_background/digital_wellbeing/focus_mode_daemon.py",
|
||||
"Non-goal: the Steam/enforcer coexistence conflict. A healthy enforcer keeps a headless Steam alive permanently, which focus mode would correctly read as GAMING and lock browsers out for good. Only relevant if Steam is reinstalled, and needs its own decision (GAMING = client alive vs GAMING = a game actually running).",
|
||||
"Non-goal: reinstalling Steam. User chose to leave it uninstalled.",
|
||||
"Companion fix in the separate steam-backlog-enforcer repo (commit d54df3a) removes the zombie source itself."
|
||||
],
|
||||
"changes": [
|
||||
"get_running_processes() reads /proc/*/stat instead of /proc/*/comm and skips processes in state Z (zombie). Both cost one read per PID, so the poll loop stays fork-free, but stat also carries the process state.",
|
||||
"Added _parse_stat(), which extracts (name, state) using rpartition(')') because a process name may itself contain spaces and ')' - splitting on whitespace would corrupt such names. Names are truncated to 15 chars in stat exactly as in comm, so STEAM_PATTERNS and STEAM_GAME_PREFIX match unchanged.",
|
||||
"Added the ZOMBIE_STATE constant.",
|
||||
"Root cause: a total gaming block on 2026-07-04 purged the Steam client; the enforcer kept trying to launch it via an orphaned /usr/local/bin/steam wrapper pointing at the deleted /usr/bin/steam. Each failed launch left an unreaped zombie still named 'steam', which this daemon counted as a running Steam."
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "probe get_running_processes() against live /proc while a real steam zombie was present",
|
||||
"result": "pass",
|
||||
"evidence": "zombies on system now: ['steam'] | OLD (/proc/*/comm) sees 'steam': True | NEW (zombie-filtered) sees 'steam': False | is_steam_running(NEW): False | dropped by filter: ['steam']. Reproduced across 6 consecutive polls."
|
||||
},
|
||||
{
|
||||
"command": "non-regression: synthetic non-zombie process sets through is_steam_running()",
|
||||
"result": "pass",
|
||||
"evidence": "synthetic live 'steam' -> True; synthetic game 'steam_app_29687' -> True. Proves the filter skips zombies, not Steam."
|
||||
},
|
||||
{
|
||||
"command": "focus-mode-daemon (the deployed command, run for 12s)",
|
||||
"result": "pass",
|
||||
"evidence": "'Focus Mode Daemon starting...' then 'Browser detected - entering BROWSING mode'. No 'Steam detected - entering GAMING mode' lines at all; browsers untouched. Before the fix the same command flapped GAMING on/off every ~15s."
|
||||
},
|
||||
{
|
||||
"command": "sudo install -m 755 focus_mode_daemon.py /usr/local/bin/focus-mode-daemon; diff -q",
|
||||
"result": "pass",
|
||||
"evidence": "Deployed copy was STALE (grep -c ZOMBIE_STATE = 0) because install_focus_mode_daemon.sh copies rather than symlinks. Reinstalled; deployed file now byte-identical to source."
|
||||
},
|
||||
{
|
||||
"command": "pre-commit run --files <this file + focus_mode_daemon.py> (in a clean worktree)",
|
||||
"result": "pass",
|
||||
"evidence": "Run from a temporary git worktree because the main tree carries unrelated uncommitted brother_printer work whose tests fail; with brother_printer at its committed state the suite is 340 passed."
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"Reading /proc/*/stat instead of /proc/*/comm is a wider parse surface; mitigated by rpartition(')') handling names containing ')' or spaces, which naive whitespace splitting would break.",
|
||||
"A process that is genuinely running is never skipped - only state Z is filtered - so real Steam and real games are still detected.",
|
||||
"install_focus_mode_daemon.sh copies rather than symlinks, so editing the repo file alone does not change what runs. Any future change must be re-installed to /usr/local/bin/focus-mode-daemon.",
|
||||
"The removed /usr/local/bin/steam wrapper carried GPU workaround flags: exec /usr/bin/steam -cef-disable-gpu -cef-disable-gpu-compositing \"$@\" . Restore them if a Steam reinstall brings back GPU trouble."
|
||||
],
|
||||
"rollback": [
|
||||
"git revert the commit, then re-run: sudo install -m 755 focus_mode_daemon.py /usr/local/bin/focus-mode-daemon (reverting the repo file alone does NOT change the deployed daemon).",
|
||||
"After rollback, expect focus mode to once again count zombie processes as running; verify with the probe that /proc/*/comm-based detection returns True for a 'steam' zombie.",
|
||||
"The enforcer fix (steam-backlog-enforcer d54df3a) is independent and removes the zombie source, so rolling this back alone should not resurrect the symptom unless the enforcer is also reverted."
|
||||
]
|
||||
}
|
||||
@ -34,6 +34,9 @@ WHITELIST_FILE = STATE_DIR / "whitelist"
|
||||
POLL_INTERVAL = 2 # seconds between process checks
|
||||
DEFAULT_WHITELIST_MINUTES = 5
|
||||
|
||||
# /proc/PID/stat state character for a dead-but-unreaped process.
|
||||
ZOMBIE_STATE = "Z"
|
||||
|
||||
# Process patterns
|
||||
STEAM_PATTERNS = frozenset(
|
||||
[
|
||||
@ -161,18 +164,53 @@ def notify(title: str, message: str, urgency: str = "normal") -> None:
|
||||
)
|
||||
|
||||
|
||||
def _parse_stat(stat_line: str) -> tuple[str, str] | None:
|
||||
"""Extract the (name, state) pair from a /proc/PID/stat line.
|
||||
|
||||
The stat format is ``pid (comm) state ...``. The comm field may itself
|
||||
contain spaces and ')' characters, so the name is everything between the
|
||||
first '(' and the *last* ')' - splitting on whitespace would corrupt any
|
||||
process whose name embeds either.
|
||||
|
||||
Args:
|
||||
stat_line: Raw contents of a /proc/PID/stat file.
|
||||
|
||||
Returns:
|
||||
A (name, state) tuple, or None if the line is malformed.
|
||||
"""
|
||||
open_paren = stat_line.find("(")
|
||||
before_close, sep, after_close = stat_line.rpartition(")")
|
||||
if open_paren == -1 or not sep:
|
||||
return None
|
||||
state_fields = after_close.split()
|
||||
if not state_fields:
|
||||
return None
|
||||
return before_close[open_paren + 1 :], state_fields[0]
|
||||
|
||||
|
||||
def get_running_processes() -> set[str]:
|
||||
"""Get set of currently running process names by reading /proc directly.
|
||||
|
||||
Reads /proc/*/comm to avoid forking a subprocess on every poll cycle.
|
||||
Reads /proc/*/stat rather than /proc/*/comm: both cost one read per PID
|
||||
(so the poll loop stays fork-free), but stat also carries the process
|
||||
state. That distinction matters because a dead process keeps its name
|
||||
until the parent reaps it, so a crashed "steam" lingers as a zombie and
|
||||
would otherwise be counted as a running Steam. Zombies are skipped.
|
||||
|
||||
Names are truncated to 15 characters here exactly as they are in
|
||||
/proc/*/comm, so the module's process patterns match unchanged.
|
||||
"""
|
||||
processes: set[str] = set()
|
||||
try:
|
||||
for comm_path in Path("/proc").glob("*/comm"):
|
||||
for stat_path in Path("/proc").glob("*/stat"):
|
||||
with contextlib.suppress(OSError):
|
||||
proc_name = comm_path.read_text().strip().lower()
|
||||
if proc_name:
|
||||
processes.add(proc_name)
|
||||
parsed = _parse_stat(stat_path.read_text())
|
||||
if parsed is None:
|
||||
continue
|
||||
proc_name, state = parsed
|
||||
if state == ZOMBIE_STATE or not proc_name:
|
||||
continue
|
||||
processes.add(proc_name.lower())
|
||||
except OSError as exc:
|
||||
log(f"Error reading /proc: {exc}")
|
||||
return processes
|
||||
|
||||
Loading…
Reference in New Issue
Block a user