Files
blocker/desktop/platform-linux/src/enforce/tests.rs
T
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

114 lines
4.0 KiB
Rust

//! 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();
}