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>
This commit is contained in:
6 files changed
+716
-51
No files matched your search
@@ -138,6 +138,15 @@ class Controller(QObject):
|
||||
self._idle_check.timeout.connect(self._check_idle)
|
||||
self._idle_check.start()
|
||||
|
||||
# A blocked app can be launched at any second, so the scan has its own
|
||||
# clock rather than riding the policy poll — and the server is the one
|
||||
# that says how fast (`blocker.app_poll_seconds`). It is cheap: a
|
||||
# `/proc` walk and, at most, one signal per blocked process.
|
||||
self._app_scan = QTimer(self)
|
||||
self._app_scan.setInterval(self._app_scan_interval())
|
||||
self._app_scan.timeout.connect(self._blocker.enforce_apps_now)
|
||||
self._app_scan.start()
|
||||
|
||||
# ======================================================================
|
||||
# Properties for QML
|
||||
# ======================================================================
|
||||
@@ -647,8 +656,24 @@ class Controller(QObject):
|
||||
self._policy = policy
|
||||
if self._config.enforce_blocking:
|
||||
self._blocker.sync(policy)
|
||||
wanted = self._app_scan_interval()
|
||||
if self._app_scan.interval() != wanted:
|
||||
self._app_scan.setInterval(wanted)
|
||||
self.blockerChanged.emit()
|
||||
|
||||
def _app_scan_interval(self) -> int:
|
||||
"""Milliseconds between `/proc` scans, as the server asked.
|
||||
|
||||
Clamped because this comes from a settings document a human edits: a
|
||||
zero would spin, and a minute would let a game run for a minute.
|
||||
"""
|
||||
seconds = self._policy.get("poll_seconds") or self._config.poll_seconds
|
||||
try:
|
||||
seconds = int(seconds)
|
||||
except (TypeError, ValueError):
|
||||
seconds = self._config.poll_seconds
|
||||
return max(1, min(30, seconds)) * 1000
|
||||
|
||||
def _on_tasks(self, payload: dict[str, Any]) -> None:
|
||||
self._tasks = list(payload.get("tasks", []))
|
||||
self._task_counts = dict(payload.get("counts", {}))
|
||||
@@ -818,6 +843,7 @@ class Controller(QObject):
|
||||
"""Hand everything back: inhibits, DND, the hosts file and the DNS route."""
|
||||
self._tick.stop()
|
||||
self._idle_check.stop()
|
||||
self._app_scan.stop()
|
||||
self._screensaver.release()
|
||||
self._dnd.release()
|
||||
self._launcher.clear()
|
||||
|
||||
Reference in new issue
Block a user