feat(linux): add enforcement actions and the daemon event loop
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user