//! 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;