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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user