"""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 == {}