Files
kadence/desktop/tests/test_app_blocking.py
T
timka123andClaude Opus 5 7b7b50bc8e blockers: the report's cooldown was deciding whether to block
Both clients made the same mistake, in the same shape: the throttle that keeps
the daily report from filling with one row per poll was placed in front of the
enforcement call. So blocking worked exactly once per target, and the second
launch of a banned app — or the second visit to a banned site — simply went
through.

Android, `BlockerEngine`: `claim()` now gates only `report()`/`announce()`.
Enforcement runs on every decision behind a 1.2s debounce whose only job is to
stop the accessibility event and the usage-stats poll both pressing home for one
visit. An app still in front on the next pass is pressed again — one
GLOBAL_ACTION_HOME is a request, not a result — and a visit is no longer
consumed when no accessibility service is attached to consume it with, which
turned a missing grant into a silently unblocked app. `lastForeground` and
`lastBlockedApp` are @Volatile like every other field here; they are written
from the service's main thread and the poll coroutine both. The usage-stats
cadence now follows the server's `app_poll_seconds`, and the first look happens
at once rather than four seconds in.

Linux, `BlockerManager._enforce_apps`: the pid went into the dedupe set *before*
the kill was attempted, so a process whose signal was refused was exempt for the
rest of the interval, and a pid is reused — a relaunch landing on a recycled
number inherited the exemption. Processes are now identified by (pid, start
time) from /proc/<pid>/stat, the kill is attempted on every pass, and only the
report is throttled. The books are pruned of dead processes so an all-day
interval cannot leak.

`processes.terminate()` no longer busy-waits two seconds for the process to die
before escalating — on the desktop that wait ran on the thread drawing the
countdown. SIGTERM now, SIGKILL on a later pass once the grace has gone by,
which is also gentler than the old fixed two seconds. With nothing blocking,
the scan gets its own timer at the cadence the server asks for instead of
riding the five-second policy poll.

Neither enforcement path had any test: the desktop's blocker suite hardcoded an
empty app list and nothing anywhere touched BlockerEngine. 16 new tests, on
fakes and the committed fixtures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-22 17:16:56 +03:00

232 lines
7.6 KiB
Python

"""Blocking applications: the part that has to work the *second* time.
The bug these tests exist for: the manager used to remember every pid it had
dealt with and skip it forever, which made one cooldown do two jobs. The daily
report stayed readable — and a blocked app relaunched a moment later, or one
whose first signal was refused, ran untouched for the rest of the interval.
So the rules pinned here are:
* enforcement happens on **every** pass, reporting at most once per cooldown;
* a process is identified by (pid, start time), because pids are recycled;
* the kill escalates across passes (SIGTERM, then SIGKILL) and never blocks.
`FakeProcs` stands in for `/proc`: these tests must not need a victim process,
and the real module is covered by the fact that it reads files this one fakes.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from kadence_desktop.blocking.manager import BlockerManager
@dataclass
class FakeApp:
"""What `processes.scan` returns, with its own notion of staying alive."""
pid: int
name: str
matched: str
start_time: int = 1000
cmdline: str = ""
#: Which signal it is prepared to ignore: "term", "kill", or neither.
survives: str = ""
@property
def identity(self) -> tuple[int, int]:
return (self.pid, self.start_time)
@dataclass
class FakeProcs:
"""A stand-in for `kadence_desktop.blocking.processes`."""
running: list[FakeApp] = field(default_factory=list)
signals: list[tuple[int, str]] = field(default_factory=list)
def scan(self, patterns: list[str]) -> list[FakeApp]:
return [app for app in self.running if app.matched in patterns]
def terminate(self, app: FakeApp, *, force: bool = False) -> bool:
self.signals.append((app.pid, "kill" if force else "term"))
if force and app.survives == "kill":
return False
if not force and app.survives in ("term", "kill"):
return True # delivered, ignored — exactly what escalation is for
self.running = [item for item in self.running if item.pid != app.pid]
return True
def alive(self, pid: int, started: int = 0) -> bool:
return any(
app.pid == pid and (started == 0 or app.start_time == started) for app in self.running
)
def policy(*, apps=("steam*",), kill: bool = True, poll_seconds: int = 3) -> dict:
return {
"active": True,
"enabled": True,
"domains": [],
"apps": list(apps),
"kill_apps": kill,
"redirect_ip": "127.0.0.1",
"unexpandable": [],
"allowed": [],
"poll_seconds": poll_seconds,
}
def manager(procs: FakeProcs, reports: list, **kwargs) -> BlockerManager:
class NoDns:
running = False
port = 0
error = None
applied = None
def start(self) -> bool:
return True
def stop(self) -> None:
pass
def set_rules(self, *args, **kwargs) -> None:
pass
def on(self, *args) -> bool:
return True
def off(self) -> bool:
return True
return BlockerManager(
helper_path="/nonexistent/kadence-helper",
report=lambda kind, target, action, details: reports.append((kind, target, action)),
stub=NoDns(),
route=NoDns(),
procs=procs,
**kwargs,
)
def test_the_second_launch_is_closed_too():
"""The bug, stated as a test: relaunching inside the cooldown must not work."""
procs = FakeProcs([FakeApp(pid=101, name="steam", matched="steam*")])
reports: list = []
blocker = manager(procs, reports)
blocker.sync(policy())
assert procs.signals == [(101, "term")]
assert procs.running == []
# A second launch, a moment later: a new process, a recycled-or-not pid.
procs.running = [FakeApp(pid=101, name="steam", matched="steam*", start_time=2000)]
blocker.enforce_apps_now()
assert procs.signals == [(101, "term"), (101, "term")]
assert procs.running == []
def test_a_pid_is_not_an_identity():
"""Same pid, same start time is one process; a new start time is another."""
procs = FakeProcs([FakeApp(pid=7, name="steam", matched="steam*", start_time=42)])
reports: list = []
blocker = manager(procs, reports)
blocker.sync(policy())
assert reports == [("app", "steam*", "killed")]
# Reusing the pid *and* the start time cannot happen on a real machine; the
# point is that the bookkeeping keys on both, so this one is throttled...
procs.running = [FakeApp(pid=7, name="steam", matched="steam*", start_time=42)]
blocker.enforce_apps_now()
assert reports == [("app", "steam*", "killed")]
# ...while a genuinely new process is reported again.
procs.running = [FakeApp(pid=7, name="steam", matched="steam*", start_time=99)]
blocker.enforce_apps_now()
assert reports == [("app", "steam*", "killed"), ("app", "steam*", "killed")]
def test_the_report_is_throttled_but_the_block_is_not():
"""One row in the evening report; one signal per pass regardless."""
stubborn = FakeApp(pid=55, name="steam", matched="steam*", survives="kill")
procs = FakeProcs([stubborn])
reports: list = []
blocker = manager(procs, reports, kill_grace=0.0)
for _ in range(4):
blocker.sync(policy())
assert len(reports) == 1, reports
# SIGTERM once, then SIGKILL on every pass after the grace — it is still
# there, so the manager keeps insisting.
assert procs.signals == [(55, "term"), (55, "kill"), (55, "kill"), (55, "kill")]
def test_sigkill_only_after_the_grace_period():
"""A well-behaved app gets time to quit before anything is forced."""
procs = FakeProcs([FakeApp(pid=8, name="steam", matched="steam*", survives="term")])
reports: list = []
blocker = manager(procs, reports, kill_grace=600.0)
blocker.sync(policy())
blocker.enforce_apps_now()
blocker.enforce_apps_now()
assert procs.signals == [(8, "term")], "nothing may be forced inside the grace"
def test_a_refused_signal_is_retried_rather_than_exempting_the_process():
"""`terminate` saying no must not be a free pass for the interval."""
procs = FakeProcs([FakeApp(pid=9, name="steam", matched="steam*", survives="kill")])
reports: list = []
blocker = manager(procs, reports, kill_grace=0.0, report_cooldown=0.0)
blocker.sync(policy())
assert reports == [("app", "steam*", "killed")]
blocker.enforce_apps_now()
assert procs.signals == [(9, "term"), (9, "kill")]
assert len(reports) == 2
def test_warn_mode_never_kills():
procs = FakeProcs([FakeApp(pid=11, name="discord", matched="*discord*")])
reports: list = []
blocker = manager(procs, reports)
blocker.sync(policy(apps=("*discord*",), kill=False))
assert procs.signals == []
assert procs.running # still there, by design
assert reports == [("app", "*discord*", "warned")]
assert blocker.killed_today == 0
def test_nothing_is_enforced_once_the_interval_ends():
procs = FakeProcs([FakeApp(pid=12, name="steam", matched="steam*")])
reports: list = []
blocker = manager(procs, reports)
blocker.sync(policy() | {"active": False})
blocker.enforce_apps_now()
assert procs.signals == []
assert reports == []
def test_the_books_do_not_grow_for_processes_that_are_gone():
procs = FakeProcs([FakeApp(pid=i, name="steam", matched="steam*") for i in range(20, 25)])
reports: list = []
blocker = manager(procs, reports)
blocker.sync(policy())
assert len(reports) == 5
# Everything was closed, so the next pass has nothing left to remember.
blocker.enforce_apps_now()
assert blocker._reported == {}
assert blocker._asked == {}