Files
SellPL 8ae043658c
CI / ci (pull_request) Has been cancelled
CI / android (pull_request) Has been cancelled
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>
2026-10-01 22:20:23 +03:00

141 lines
5.6 KiB
Rust

//! 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/<pid>/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<bool> {
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;