`[tool.ruff]` set a line length and a target version and nothing else, so which rules ran depended on the installed ruff's defaults — fine while nobody ran it, not fine for a CI job that can turn a push red because a tool changed its mind. The set is named explicitly (E, F, I, UP, SIM, B, RUF) with three ignores that are each a real decision rather than a shrug: B008, because FastAPI's whole dependency idiom is a call in a default argument; E501, because wrapping prose is a formatter's business; and RUF001, because the reports and the bot speak Russian and "ambiguous character" is the wrong complaint about a string the user is meant to read. The 73 findings that remained are fixed — mostly `datetime.timezone.utc` → `datetime.UTC`, `typing.Callable` → `collections.abc.Callable`, import order, and unused `noqa` markers — plus seven by hand: `contextlib.suppress` for two try/except/pass pairs, unpacking instead of list concatenation, one negated return. `ruff format` is deliberately *not* adopted: it would reformat 51 files, and some of the layout here is hand-set (the comment tables, the DNS wire-format structs) in ways a formatter flattens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
175 lines
6.0 KiB
Python
175 lines
6.0 KiB
Python
"""Application blocking by watching `/proc`.
|
|
|
|
No root needed: these are your own processes. The scan reads `/proc/*/comm` and
|
|
the cmdline, matches against the configured names, and either terminates the
|
|
process (SIGTERM, then SIGKILL if it ignores us) or just reports it, depending
|
|
on the setting.
|
|
|
|
Matching is deliberately forgiving — `steam` catches `steamwebhelper`, and
|
|
`telegram` catches `Telegram` — and an entry containing `*` or `?` is treated as
|
|
a glob (`steam*`, `*discord*`, `telegram-?`), matched against both the process
|
|
name and its command line. It never matches ourselves, PID 1, or anything
|
|
outside the current user's processes.
|
|
|
|
Every match carries its **start time** as well as its pid. A pid on its own is
|
|
not an identity: Linux reuses them, and the blocker remembers which processes it
|
|
has already told you about. Keyed by pid alone, a relaunch that happens to land
|
|
on a recycled number would inherit the old entry and be let through — which is
|
|
exactly the "it started the second time" this file is here to prevent.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import signal
|
|
from dataclasses import dataclass
|
|
from fnmatch import fnmatch
|
|
from pathlib import Path
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
_SELF_MARKERS = ("kadence", "python3 -m kadence_desktop")
|
|
|
|
|
|
def matches(pattern: str, name: str, cmdline: str) -> bool:
|
|
"""Whether one block-list entry matches a process.
|
|
|
|
A mask is anchored to the process *name* (so `steam*` does not match every
|
|
command that merely mentions steam) but also tried against the whole command
|
|
line, which is how `*discord*` catches an Electron app launched by path.
|
|
"""
|
|
pattern = pattern.strip().lower()
|
|
if not pattern:
|
|
return False
|
|
if any(char in pattern for char in "*?["):
|
|
return fnmatch(name, pattern) or fnmatch(cmdline, pattern)
|
|
return pattern in name or pattern in cmdline
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class RunningApp:
|
|
pid: int
|
|
name: str
|
|
cmdline: str
|
|
matched: str
|
|
#: Field 22 of `/proc/<pid>/stat` — clock ticks since boot. With the pid it
|
|
#: makes an identity that survives pid reuse. 0 when it could not be read.
|
|
start_time: int = 0
|
|
|
|
@property
|
|
def identity(self) -> tuple[int, int]:
|
|
return (self.pid, self.start_time)
|
|
|
|
|
|
def _read(path: Path) -> str:
|
|
try:
|
|
return path.read_text("utf-8", errors="replace")
|
|
except OSError:
|
|
return ""
|
|
|
|
|
|
def start_time(pid: int) -> int:
|
|
"""Clock ticks since boot at which this pid started, 0 if unreadable.
|
|
|
|
`comm` sits in `stat` wrapped in parentheses and may itself contain spaces
|
|
and parentheses, so the fields are counted from the *last* `)` — the one
|
|
place this file cannot naively split on whitespace.
|
|
"""
|
|
raw = _read(Path(f"/proc/{pid}/stat"))
|
|
cut = raw.rfind(")")
|
|
if cut < 0:
|
|
return 0
|
|
fields = raw[cut + 2 :].split()
|
|
# stat's field 3 is `state`, so field 22 is index 19 of what follows comm.
|
|
if len(fields) < 20:
|
|
return 0
|
|
try:
|
|
return int(fields[19])
|
|
except ValueError:
|
|
return 0
|
|
|
|
|
|
def alive(pid: int, started: int = 0) -> bool:
|
|
"""Whether this exact process is still running — not merely this pid."""
|
|
if not Path(f"/proc/{pid}").exists():
|
|
return False
|
|
return started == 0 or start_time(pid) == started
|
|
|
|
|
|
def scan(patterns: list[str]) -> list[RunningApp]:
|
|
"""Every live process of this user matching one of `patterns`."""
|
|
wanted = [pattern.strip().lower() for pattern in patterns if pattern.strip()]
|
|
if not wanted:
|
|
return []
|
|
|
|
uid = os.getuid()
|
|
self_pid = os.getpid()
|
|
found: list[RunningApp] = []
|
|
|
|
for entry in Path("/proc").iterdir():
|
|
if not entry.name.isdigit():
|
|
continue
|
|
pid = int(entry.name)
|
|
if pid in (1, self_pid):
|
|
continue
|
|
try:
|
|
if entry.stat().st_uid != uid:
|
|
continue
|
|
except OSError:
|
|
continue
|
|
|
|
comm = _read(entry / "comm").strip()
|
|
if not comm:
|
|
continue
|
|
cmdline = _read(entry / "cmdline").replace("\x00", " ").strip()
|
|
name, command = comm.lower(), cmdline.lower()
|
|
if any(marker in f"{name} {command}" for marker in _SELF_MARKERS):
|
|
continue
|
|
|
|
match = next((pattern for pattern in wanted if matches(pattern, name, command)), None)
|
|
if match:
|
|
found.append(
|
|
RunningApp(
|
|
pid=pid,
|
|
name=comm,
|
|
cmdline=cmdline[:200],
|
|
matched=match,
|
|
start_time=start_time(pid),
|
|
)
|
|
)
|
|
return found
|
|
|
|
|
|
def terminate(app: RunningApp, *, force: bool = False) -> bool:
|
|
"""Signal the process once. True when the signal landed (or it is gone).
|
|
|
|
Deliberately **not** blocking. The previous version sent SIGTERM and then
|
|
busy-waited two seconds for the process to die before escalating, on
|
|
whichever thread happened to call it — which on the desktop is the one
|
|
drawing the countdown. Since the blocker now rescans every few seconds
|
|
anyway, the escalation is simply the *next* pass's job (see
|
|
`BlockerManager._enforce_apps`), and nothing has to wait for anybody.
|
|
|
|
A `PermissionError` on SIGTERM escalates immediately rather than next pass:
|
|
a process that refused the polite signal on permission grounds is not about
|
|
to quit on its own.
|
|
"""
|
|
try:
|
|
os.kill(app.pid, signal.SIGKILL if force else signal.SIGTERM)
|
|
except ProcessLookupError:
|
|
return True
|
|
except PermissionError:
|
|
if force:
|
|
log.warning("not allowed to stop %s (pid %s)", app.name, app.pid)
|
|
return False
|
|
log.warning("not allowed to ask %s (pid %s) to quit", app.name, app.pid)
|
|
try:
|
|
os.kill(app.pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
return True
|
|
except PermissionError:
|
|
log.warning("not allowed to stop %s (pid %s)", app.name, app.pid)
|
|
return False
|
|
return True
|