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:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
# `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"] }
|
||||
@@ -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/<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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user