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>
232 lines
7.6 KiB
Python
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 == {}
|