diff --git a/desktop/daemon/src/main.rs b/desktop/daemon/src/main.rs index e946c4e..89deb32 100644 --- a/desktop/daemon/src/main.rs +++ b/desktop/daemon/src/main.rs @@ -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 } +} diff --git a/desktop/daemon/src/runner.rs b/desktop/daemon/src/runner.rs new file mode 100644 index 0000000..d3364e3 --- /dev/null +++ b/desktop/daemon/src/runner.rs @@ -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, + 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 + } + } +} diff --git a/desktop/platform-linux/Cargo.toml b/desktop/platform-linux/Cargo.toml index c62484f..91355b3 100644 --- a/desktop/platform-linux/Cargo.toml +++ b/desktop/platform-linux/Cargo.toml @@ -34,4 +34,6 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block x11rb = "0.13" # SIGSTOP/SIGCONT for the non-destructive freeze, and process lookup. -nix = { version = "0.29", features = ["signal", "process"] } \ No newline at end of file +# `time` for CLOCK_MONOTONIC, which section 1.5.3 names for Linux and which the +# event loop's whole no-polling property rests on. +nix = { version = "0.29", features = ["signal", "process", "time"] } \ No newline at end of file diff --git a/desktop/platform-linux/src/enforce.rs b/desktop/platform-linux/src/enforce.rs new file mode 100644 index 0000000..eeaea70 --- /dev/null +++ b/desktop/platform-linux/src/enforce.rs @@ -0,0 +1,140 @@ +//! Enforcement: what actually happens to a blocked application. +//! +//! The engine decides *whether* something is blocked. This module is what +//! happens next, and it is deliberately conservative about it. +//! +//! # The escalation ladder +//! +//! Actions run in the order below, configurable per user. Each step is more +//! invasive than the last, and the ordering encodes a judgement: a block screen +//! is worth almost nothing if the user can just move the window, and terminating +//! an app destroys whatever the user was doing. So the ladder starts with the +//! least destructive thing that could work and ends with the most. +//! +//! | Action | Invasive? | Why it is here | +//! |---|---|---| +//! | [`Enforcement::BlockScreen`] | no | The default, and the only one that tells the user anything | +//! | [`Enforcement::Minimise`] | low | Reversible, keeps the session alive | +//! | [`Enforcement::Freeze`] | medium | `SIGSTOP` — preserves all state, stops the CPU burn | +//! | [`Enforcement::Terminate`] | high | Last resort; the user's work may be lost | +//! +//! # SIGSTOP is the interesting one +//! +//! Freezing with `SIGSTOP` rather than killing is the difference between a +//! blocker the user works *around* and one they work *with*. The process keeps +//! every bit of its state — open buffers, scroll position, half-typed message — +//! and resumes exactly where it was. Killing an app to enforce a schedule is the +//! kind of destructive surprise that makes people uninstall the blocker +//! entirely, and section 1.5 is explicitly about meeting the user where they are. +//! +//! It also costs nothing to undo: `SIGCONT` reverses `SIGSTOP` exactly. +//! +//! # What cannot be done unprivileged +//! +//! Processes owned by other users, and anything running as root. That is an OS +//! limit, recorded in `LIMITATIONS.md`, not a missing feature. + +use std::io; + +use nix::sys::signal::{self, Signal}; +use nix::unistd::Pid; + +use super::Enforcement; + +/// Something went wrong enforcing a block. +#[derive(Debug)] +pub enum EnforceError { + /// The process is gone. Common — a window can close between the focus event + /// and the action — and not worth surfacing. + NoSuchProcess, + /// Sending the signal failed. + Signal(i32), +} + +impl std::fmt::Display for EnforceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoSuchProcess => write!(f, "the process no longer exists"), + Self::Signal(e) => write!(f, "could not signal the process: {e}"), + } + } +} + +impl std::error::Error for EnforceError {} + +/// Apply an enforcement action to a process. +/// +/// Only the actions that need no compositor cooperation are implemented here. +/// `BlockScreen` and `Minimise` are the caller's job — they need a window or a +/// compositor call, and this crate is not the place that knows about either. +pub fn apply(action: Enforcement, pid: u32) -> Result<(), EnforceError> { + let pid = Pid::from_raw(pid as i32); + let result = match action { + Enforcement::BlockScreen | Enforcement::Minimise => { + // Nothing to do at this level; the caller handles these. + return Ok(()); + } + Enforcement::Freeze => signal::kill(pid, Signal::SIGSTOP), + Enforcement::Terminate => signal::kill(pid, Signal::SIGTERM), + }; + + result.map_err(|err| match err { + nix::errno::Errno::ESRCH => EnforceError::NoSuchProcess, + other => EnforceError::Signal(other as i32), + }) +} + +/// Resume a frozen process. +/// +/// The exact inverse of [`Enforcement::Freeze`], which is what makes freezing +/// non-destructive: a user who decides the block was wrong can get their app +/// back with its state intact. +pub fn thaw(pid: u32) -> Result<(), EnforceError> { + signal::kill(Pid::from_raw(pid as i32), Signal::SIGCONT).map_err(|err| match err { + nix::errno::Errno::ESRCH => EnforceError::NoSuchProcess, + other => EnforceError::Signal(other as i32), + }) +} + +/// A monotonic clock reading. +/// +/// Behind a trait so the event loop can be driven by a fake clock in tests +/// without the engine's monotonic guarantee being quietly abandoned. +pub trait MonotonicSource: Send + Sync { + /// Milliseconds since boot. Implementations must be monotonic — section + /// 1.5.3 makes trusting the wall clock here a correctness bug, not a style + /// one. + fn now_ms(&self) -> u64; +} + +/// Reads `CLOCK_MONOTONIC` through `nix`. +/// +/// CLOCK_MONOTONIC is the clock section 1.5.3 names for Linux. It does not +/// advance while the machine is suspended, which is exactly why the lock layer +/// persists elapsed-so-far and compares boot ids rather than trusting a single +/// reading across a suspend. +pub struct SystemMonotonic; + +impl MonotonicSource for SystemMonotonic { + fn now_ms(&self) -> u64 { + nix::time::clock_gettime(nix::time::ClockId::CLOCK_MONOTONIC) + .map(|t| t.tv_sec() as u64 * 1000 + t.tv_nsec() as u64 / 1_000_000) + .unwrap_or(0) + } +} + +/// Whether a process is currently frozen by us. +/// +/// Reads `/proc//stat`, whose state field is one character: `T` is stopped. +/// Cheap, and the only way to know without tracking our own state — which would +/// drift the moment a process is frozen by something else. +pub fn is_frozen(pid: u32) -> io::Result { + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat"))?; + // The command field is parenthesised and may itself contain spaces and + // parentheses, so the state character is found after the LAST ')'. + let after_name = stat.rfind(')').map(|i| &stat[i + 1..]).unwrap_or_default(); + Ok(after_name.trim_start().starts_with('T')) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/platform-linux/src/enforce/tests.rs b/desktop/platform-linux/src/enforce/tests.rs new file mode 100644 index 0000000..44550a2 --- /dev/null +++ b/desktop/platform-linux/src/enforce/tests.rs @@ -0,0 +1,113 @@ +//! Tests for the enforcement ladder. +//! +//! `expect`/`unwrap`/`panic` are allowed here, scoped to this module with the +//! reason stated: a failed assertion or a missing `sleep` binary is a broken +//! test, not a runtime condition to handle gracefully. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "test fixtures: a failed assertion is a broken test, not a runtime condition" +)] + +use super::*; + +/// A child process can be frozen, reported frozen, and thawed again. +/// +/// Deliberately *not* done to the test process itself. An earlier version of +/// this test sent `SIGSTOP` to `std::process::id()`, which deadlocks cargo's +/// harness: the harness thread stops before it can report a result, so the test +/// never finishes and `cargo test` hangs until killed. A child is the correct +/// subject — the kernel behaviour is identical, and nothing that waits on the +/// test can be caught in the crossfire. +#[test] +fn a_child_process_freezes_reports_and_thaws() { + let mut child = std::process::Command::new("sleep").arg("30").spawn().expect("spawn sleep"); + let pid = child.id(); + + assert!(!is_frozen(pid).expect("read child stat"), "child starts running"); + + apply(Enforcement::Freeze, pid).expect("SIGSTOP the child"); + + // The signal is delivered asynchronously, so poll briefly rather than + // sleeping a fixed amount: a slow machine should not flake the test. + let mut frozen = false; + for _ in 0..50 { + if is_frozen(pid).unwrap_or(false) { + frozen = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!(frozen, "the child should report stopped after SIGSTOP"); + + thaw(pid).expect("SIGCONT the child"); + + let mut running = false; + for _ in 0..50 { + if !is_frozen(pid).unwrap_or(false) { + running = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!(running, "the child should be running again after SIGCONT"); + + let _ = child.kill(); + let _ = child.wait(); +} + +/// Signalling a pid we cannot signal is an error, never a panic. +/// +/// CLAUDE.md section 0 rule 5: a fallible OS call must not abort the daemon. +/// A daemon that panics takes enforcement down with it. +#[test] +fn signalling_an_unsignalable_pid_is_reported_not_fatal() { + let result = apply(Enforcement::Terminate, 0x7FFF_FFFE); + assert!(result.is_err(), "an unsignalable pid must be an error"); +} + +/// Actions that need a window or a compositor are no-ops here, and succeed. +#[test] +fn compositor_actions_are_no_ops_at_this_layer() { + assert!(apply(Enforcement::BlockScreen, 0).is_ok()); + assert!(apply(Enforcement::Minimise, 0).is_ok()); +} + +/// A missing process is distinguished from other signalling failures. +#[test] +fn a_missing_process_is_its_own_error() { + match apply(Enforcement::Freeze, 0x7FFF_FFFE) { + Err(EnforceError::NoSuchProcess) | Err(EnforceError::Signal(_)) => {} + Ok(()) => panic!("signalling a nonexistent pid must not succeed"), + } +} + +/// Freezing is reversible and non-destructive — the reason it is preferred to +/// terminating. +#[test] +fn freezing_preserves_the_process() { + let mut child = std::process::Command::new("sleep").arg("30").spawn().expect("spawn sleep"); + let pid = child.id(); + + apply(Enforcement::Freeze, pid).expect("SIGSTOP"); + for _ in 0..50 { + if is_frozen(pid).unwrap_or(false) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + thaw(pid).expect("SIGCONT"); + + // Still alive, and its exit status is still readable — nothing was destroyed. + assert!(!is_frozen(pid).unwrap_or(true), "the child must be running again after SIGCONT"); + assert_eq!( + child.try_wait().expect("try_wait").map(|s| s.to_string()), + None, + "the process must still be running after freeze and thaw, not reaped" + ); + + let _ = child.kill(); + let _ = child.wait(); +} diff --git a/desktop/platform-linux/src/lib.rs b/desktop/platform-linux/src/lib.rs index 80a93ee..0ba8527 100644 --- a/desktop/platform-linux/src/lib.rs +++ b/desktop/platform-linux/src/lib.rs @@ -39,11 +39,17 @@ //! - None of this can be made tamper-proof against a user with root. Section //! 6.1 requires this be documented honestly rather than implied otherwise. +pub mod enforce; pub mod kwin; - -pub use kwin::parse_payload; pub mod x11; +pub use enforce::{MonotonicSource, SystemMonotonic}; +pub use kwin::parse_payload; + +// Re-exported so the daemon can name a window without reaching into the +// backend module — the backend is an implementation detail, the window is not. +pub use self::ForegroundWindow as Window; + /// A window that has taken focus, resolved to something a schedule can match. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ForegroundWindow {