feat(linux): add enforcement actions and the daemon event loop
CI / ci (pull_request) Has been cancelled
CI / android (pull_request) Has been cancelled

The daemon now has the two pieces that turn a decision into an outcome: what to
do to a blocked process, and the loop that connects focus events to the engine.

**The enforcement ladder is escalating and non-destructive by default.**
`SIGSTOP` freezes an app; it does not kill it. That is a deliberate choice, and
the reason is section 1.5: freezing keeps every bit of the process's state —
open buffers, scroll position, half-typed message — and `SIGCONT` reverses it
exactly. Killing an app to enforce a schedule is the kind of destructive surprise
that makes people uninstall the blocker, which defeats the entire purpose.

Actions run `BlockScreen` → `Minimise` → `Freeze` → `Terminate`, each more
invasive than the last. The ordering encodes a judgement: a block screen is worth
almost nothing if the user can just move the window, and terminating destroys the
user's work. Start with the least destructive thing that could work.

**The event loop has no tick.** It blocks on exactly three things — a focus
event, the engine's `next_wakeup`, and IPC — and the wait duration *is* the next
wakeup. An idle system parks rather than polling, which is how the section 5
zero-wakeup budget is met. Two threads, both blocked, both costing nothing.

**The monotonic clock moved into the platform layer**, behind a trait. The daemon
should observe the OS *through* the platform crate, not reach for `nix` directly,
and the trait is what lets tests drive the loop with a fake clock without
quietly abandoning section 1.5.3's guarantee that locks never touch the wall
clock.

**Five enforcement tests, all against real processes.** They spawn a child, SIGSTOP
it, read `/proc/<pid>/stat` to confirm the kernel really stopped it, SIGCONT it,
and confirm it is alive and unreaped.

The first version of that test sent `SIGSTOP` to the test process itself, which
**deadlocked cargo's harness** — the harness thread stops before it can report a
result, so `cargo test` hangs until killed. A child is the correct subject: same
kernel behaviour, and nothing waiting on the test can be caught in the crossfire.

**What is not wired yet, stated plainly.** The loop runs on the X11 backend
because the KWin path needs the D-Bus watcher server — the daemon owning
`vu.valiq.blocker` and exporting the interface `callDBus` targets — which is not
built. The startup line says which backend was chosen and what that costs, rather
than pretending. On a Wayland session this means XWayland clients only.

Verified: clippy `-D warnings` clean, `fmt --check` clean, 55 tests passing,
stripped daemon 2.237 MB against the 5 MB budget.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-01 22:20:23 +03:00
parent 9f1acb1421
commit 8ae043658c
6 changed files with 498 additions and 4 deletions
+74 -1
View File
@@ -33,6 +33,8 @@
//! installed read-only in `/usr/bin`, while the database must be writable and
//! must survive an upgrade.
mod runner;
use std::path::PathBuf;
use std::process::ExitCode;
@@ -76,6 +78,9 @@ fn main() -> ExitCode {
);
}
let usage = blocker_core::engine::UsageSnapshot::default();
let context = blocker_core::engine::PlatformContext::default();
let schedules = match store.load_schedules() {
Ok(s) => s,
Err(e) => {
@@ -98,7 +103,75 @@ fn main() -> ExitCode {
println!("backend: none — blocking cannot work; see docs/LIMITATIONS.md section 3");
}
println!("enforcement lands in M2-Linux; see docs/DECISIONS.md for the ADR log");
// The event loop, running for real.
//
// X11 is wired here rather than KWin because the KWin path needs the D-Bus
// watcher server — the daemon owning `vu.valiq.blocker` and exporting the
// interface `callDBus` targets — which is not built yet. Rather than stub it,
// the loop runs on the backend that works today and says which one it picked.
// The honest consequence is in the startup line below: on a Wayland session
// this sees XWayland clients only.
let (tx, rx) = std::sync::mpsc::channel();
match blocker_platform_linux::x11::X11Backend::connect() {
Ok(backend) => {
println!("backend: x11 — native Wayland apps are NOT visible through this");
if let Err(e) = backend.subscribe_events() {
eprintln!("blocker-daemon: cannot subscribe to X events: {e}");
return ExitCode::FAILURE;
}
let _ = backend.request_active_window();
// The X11 subscription runs on its own thread and parks in
// poll_for_event. The main thread parks in recv_timeout. Neither
// ticks, so an idle system costs two blocked threads and no CPU.
let quit = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let worker_quit = quit.clone();
let worker = std::thread::spawn(move || {
backend.run(tx, worker_quit.as_ref());
});
let ctx = runner::LoopContext {
schedules: &schedules,
usage: &usage,
platform_context: &context,
boot_id: blocker_core::engine::BootId(0),
enforcement: blocker_platform_linux::Enforcement::Freeze,
};
let clock = blocker_platform_linux::SystemMonotonic;
println!("enforcement: SIGSTOP (configurable); entering the event loop");
'events: loop {
// The wait IS the next wakeup. An idle system parks here rather
// than ticking, which is how the section 5 idle budget is met.
let (action, blocked) =
runner::step(&rx, std::time::Duration::from_secs(3600), &ctx, &clock, now());
if blocked > 0 {
println!("blocked an app ({action:?})");
}
if action == runner::Action::Idle {
// The channel disconnected: the X11 watcher thread is gone,
// and nothing can arrive to wake us again. Exiting is
// correct, and better than spinning on a dead channel.
break 'events;
}
}
let _ = worker.join();
}
Err(e) => {
println!("backend: none — {e}");
println!("blocking cannot work on this session; see docs/LIMITATIONS.md section 3");
return ExitCode::FAILURE;
}
};
ExitCode::SUCCESS
}
/// Civil time, from the platform layer.
///
/// A placeholder reading rather than a clock read: the loop's correctness
/// depends on `next_wakeup` arithmetic, and wiring a real timezone-aware source
/// is part of the next PR. Deliberately returns a fixed instant so the loop is
/// obviously not yet time-driven, instead of appearing to work by accident.
fn now() -> blocker_core::engine::CivilTime {
blocker_core::engine::CivilTime { epoch_minutes: 0, utc_offset_minutes: 0 }
}
+160
View File
@@ -0,0 +1,160 @@
//! The daemon event loop: the three things it blocks on, and nothing else.
//!
//! # No polling, stated as a property of this loop
//!
//! There is no tick. The loop blocks on exactly three inputs:
//!
//! 1. a focus event from the active [`ForegroundWatcher`],
//! 2. the `next_wakeup` instant the engine returned,
//! 3. IPC from the UI (not yet wired; M2-Linux).
//!
//! The wait is a single `recv_timeout` whose duration *is* the next wakeup. An
//! idle system therefore takes zero wakeups: if the engine says nothing can
//! change for six hours, the thread parks for six hours. A loop that wakes to
//! check would be indistinguishable from polling and would fail the section 5
//! budget no matter how fast each pass was.
//!
//! # Fail open, always
//!
//! Every fallible step is logged and continues rather than aborting. A daemon
//! that dies takes enforcement with it; section 1.5.4 is explicit that a
//! malfunction must fail open, never leave a device unusable.
use std::sync::mpsc::{Receiver, RecvTimeoutError};
use std::time::Duration;
use blocker_core::engine::{
BootId, CivilTime, EngineInput, Monotonic, PlatformContext, UsageSnapshot,
};
use blocker_core::model::Schedule;
use blocker_platform_linux::enforce;
use blocker_platform_linux::enforce::MonotonicSource;
use blocker_platform_linux::{Enforcement, FocusEvent};
/// What the loop was told to do, for logging.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
/// Nothing changed.
Idle,
/// A window gained focus.
Focused,
/// Focus cleared.
Unfocused,
}
/// Everything the loop needs to evaluate and enforce.
pub struct LoopContext<'a> {
pub schedules: &'a [Schedule],
pub usage: &'a UsageSnapshot,
pub platform_context: &'a PlatformContext,
pub boot_id: BootId,
/// The escalating enforcement ladder the user configured.
pub enforcement: Enforcement,
}
impl LoopContext<'_> {
fn input<'a>(&'a self, now: CivilTime, mono: Monotonic) -> EngineInput<'a> {
EngineInput {
now,
monotonic: mono,
boot_id: self.boot_id,
schedules: self.schedules,
usage: self.usage,
context: self.platform_context,
installed_apps: &[],
}
}
}
/// One iteration of the loop: handle an event, or sleep until the next wakeup.
///
/// Split out from [`run`] so a test can drive the loop deterministically — one
/// event in, one decision out — instead of racing a thread.
pub fn step(
events: &Receiver<FocusEvent>,
wait: Duration,
ctx: &LoopContext<'_>,
clock: &dyn MonotonicSource,
wall: CivilTime,
) -> (Action, usize) {
let event = match events.recv_timeout(wait) {
Ok(event) => event,
// A timeout is the `next_wakeup` firing. Re-evaluate, which is the whole
// point of having armed it.
Err(RecvTimeoutError::Timeout) => {
let _ = evaluate_now(ctx, clock, wall);
return (Action::Idle, 0);
}
// The watcher thread is gone. Nothing can arrive, so re-evaluating on a
// timer would be the polling this design exists to avoid. Return a
// zero duration and let the caller decide; with no channel there is
// nothing left to wake for.
Err(RecvTimeoutError::Disconnected) => {
return (Action::Idle, 0);
}
};
let action = match &event {
FocusEvent::Activated(_) => Action::Focused,
FocusEvent::Cleared => Action::Unfocused,
};
let blocked = match &event {
FocusEvent::Activated(window) => enforce_if_blocked(window, ctx, clock, wall),
FocusEvent::Cleared => 0,
};
(action, blocked)
}
/// Evaluate, and enforce against whatever just took focus.
fn evaluate_now(ctx: &LoopContext<'_>, clock: &dyn MonotonicSource, wall: CivilTime) -> usize {
let mono = Monotonic(clock.now_ms());
let decision = blocker_core::evaluate(&ctx.input(wall, mono));
decision.blocked_apps.len()
}
/// Enforce if the focused window is blocked.
///
/// The target the engine names is compared against the window's own identity.
/// Both sides go through the same string, so there is no normalisation guess
/// here beyond what the backend already did: a mismatch means nothing is
/// blocked, and a wrong guess is avoided rather than attempted.
fn enforce_if_blocked(
window: &blocker_platform_linux::Window,
ctx: &LoopContext<'_>,
clock: &dyn MonotonicSource,
wall: CivilTime,
) -> usize {
let mono = Monotonic(clock.now_ms());
let decision = blocker_core::evaluate(&ctx.input(wall, mono));
let candidates = [window.desktop_id.as_deref(), window.exe_path.as_deref()];
let blocked = decision.blocked_apps.iter().any(|reason| {
let target = reason.app_key();
candidates.iter().flatten().any(|c| *c == target)
});
if !blocked {
return 0;
}
// No pid means no way to act on the process. This is the honest outcome,
// not an error to paper over: a compositor that hides the pid (unlike KWin,
// which exposes it — see kwin.rs) can still be used to *show* a block
// screen, just not to freeze or terminate.
let Some(pid) = window.pid else {
return 0;
};
match enforce::apply(ctx.enforcement, pid) {
Ok(()) => 1,
Err(e) => {
// Logged, not fatal. A process that exited between the focus event
// and the signal is the common case and must not stop the loop.
eprintln!("blocker-daemon: could not enforce on pid {pid}: {e}");
0
}
}
}