1f6c50c9981a6e17ae9abccd5444e78e4cd6ffab
4 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8ae043658c |
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> |
||
|
|
a79d262986 |
feat(linux): add KWin and X11 focus-detection backends
The first enforcement-path code. CLAUDE.md section 3 names `x11rb`, `zbus` and
`nix` for Linux explicitly, so all three are approved and this needed no new
dependency decision.
**KWin backend (primary).** Wayland has no portable focus API (section 6.3), and
this machine runs Plasma 6.6.6 on Wayland — an X11-only design would be blind to
most of the user's own desktop. KWin will not push focus events to an
arbitrary subscriber, so this loads a JavaScript snippet via
`org.kde.KWin /Scripting`, which hooks `workspace.windowActivated` and calls
back over D-Bus with KWin's `callDBus` helper. The daemon owns the bus name, so
KWin always has somewhere to call; if the daemon dies, `callDBus` fails silently
in the script and the compositor is left alone — the fail-open behaviour
section 1.5.4 requires.
The D-Bus surface was introspected on the live session, not read from docs:
$ busctl --user introspect org.kde.KWin /Scripting org.kde.kwin.Scripting
.loadScript method s->i .start method - .unloadScript method s->b
**X11 backend (fallback).** `_NET_ACTIVE_WINDOW` property subscription, resolved
through `_NET_WM_PID` to `/proc/<pid>/exe`. Blind to native Wayland apps under a
Wayland session, which is why it is the fallback and not the primary. It is also
the path that can be tested headless with Xvfb, so it is what CI would exercise.
**No polling anywhere.** Both backends park in a subscription: `callDBus` from
the compositor, `poll_for_event` on the X connection. Nothing in either module
has a timer, which is what keeps the section 5 idle budget at zero.
**zbus is blocking on async-io, deliberately.** `tokio` was available and was
rejected: a reactor thread is measurable idle cost against a zero-wakeup budget,
and several hundred KB against a 5 MB binary. Noted in the manifest so the
decision is visible next to the version pin.
**Size, measured rather than assumed:** the stripped daemon goes 1.364 MB ->
**2.237 MB**, so D-Bus, X11 and the signal handling cost ~870 KB. 2.9 MB of the
5 MB budget remains.
**What is verified, and what is not.** The D-Bus interface is introspected from
the live session. The KWin *JavaScript* API — `workspace.windowActivated`,
`client.resourceClass` — is written against the documented Plasma 6 scripting
surface and has **not** been executed, because doing so would load a script into
the user's running compositor. That is the honest status and LIMITATIONS.md
records it. Nothing here is wired into the event loop yet, so no window is
actually blocked by this PR.
Several API details cost real time and are worth recording: X11 atoms are
interned with a round trip at connect time rather than passed as an unrelated
constant, which silently queries the wrong property; the root event filter
matches on the `_NET_ACTIVE_WINDOW` *atom* rather than the property change type;
and `rusqlite`/`zbus`/`x11rb` all differ from their documented shapes in ways
that only compile once found.
Co-Authored-By: Claude Code <noreply@anthropic.com>
|
||
|
|
9a5c529a8e |
feat(storage): add SQLite persistence in a separate crate
Implements ADR-4 (CLAUDE.md section 3, `rusqlite` bundled). Schedules, lock state, disable attempts and usage buckets now persist across restarts. **Why a new crate rather than a module inside `blocker-core`.** The purity contract says the engine does no I/O, and `tests/purity.rs` enforces that mechanically by failing if `blocker-core` has any dependency at all. Putting rusqlite in `blocker-core` would have forced that check to degrade into a source-text grep — something a careful developer can route around and nobody reads in six months. `blocker-storage` depends on `blocker-core`; the reverse edge does not exist, so the zero-dependency proof stays automatic. Both crates sit in the same layer of the section 2 diagram: storage is shared by every platform and contains no platform code. **Schema is relational, not JSON-in-a-text-column.** `schedule_target` and `schedule_condition` are separate tables because target rows are queried by target, and an index on a JSON string is not a plan. Serialisation is hand-written in `storage/src/codec.rs` — `serde` is not a section 3 dependency, and section 0 rule 3 requires asking before adding one. **Fail open, verified end to end** (section 1.5.4). Corruption moves the damaged file aside and starts clean, keeping the original for diagnosis. Confirmed against the real binary, not only in tests: ``` $ printf 'SQLite format 3\x00 not a database' > .../blocker.db $ blocker-daemon blocker-daemon: database was corrupt and has been moved to .../blocker.db.corrupt; starting clean, so no schedules are active until you re-add them EXIT=0 ``` A truncated file is covered separately — that is what a power loss during a write actually produces, and it fails differently from garbage bytes. **Two bugs this PR's own tests caught:** 1. `prune_usage` anchored its cutoff on `MIN(day_index)`, the oldest row. Since that row is by definition the one being tested, the cutoff always sat before it and the delete matched nothing — the table grew forever, which is the slow disk leak the function exists to prevent. Now anchored on `MAX`. 2. An off-by-one in the same function: with a strict `<` against `newest - keep_days`, the boundary day was kept too, yielding `keep_days + 1` rows. Both are covered by `old_usage_is_pruned`. **Cost, measured rather than assumed:** the stripped daemon goes from 0.279 MB to **1.364 MB** with SQLite actually linked in — bundled SQLite is ~1.1 MB of the 5 MB budget, leaving 3.8 MB for D-Bus, X11 and a UI toolkit. That is a real margin rather than a comfortable one, and it makes ADR-4's bundled-vs-system trade-off a genuine open question rather than a settled one. The M0 figure of 0.279 MB excluded SQLite entirely, because dead-code elimination dropped a crate the placeholder binary never referenced. Co-Authored-By: Claude Code <noreply@anthropic.com> |
||
|
|
585fd30440 |
feat(core): add workspace skeleton and the pure decision engine
Establishes the cargo workspace and the shared `blocker-core` crate. The engine is deliberately written before any platform code, because CLAUDE.md section 2 makes it the single source of decision truth and M1 acceptance requires it to be pure. **Workspace** — five crates plus a test crate, zero external dependencies. Section 3 requires every crate to justify its binary-size cost, so M0 adds none. [profile.release] is workspace-only (cargo rejects it in members) and carries the section 5 settings that make the 5 MB stripped budget reachable. **Purity contract** — `evaluate(&EngineInput) -> Decision` reads no clock, no disk and no OS API; everything arrives in the input. `tests/purity.rs` enforces this mechanically by asserting blocker-core has zero dependencies, so a stray I/O reach-in fails the build rather than silently making the golden tables useless as a parity contract. Decisions worth calling out: - **Monotonic time is a separate type from wall time.** Section 1.5.3 requires a system date change never to shorten a lock. Keeping them apart makes that structural rather than a rule to remember. - **`start > end` means overnight.** No `crosses_midnight` flag: the comparison already encodes it and a second field is a second way to be wrong. The weekday for a window that started yesterday evening is yesterday's, which `overnight_window_survives_past_midnight` pins down. - **`next_wakeup` is armed over every non-paused schedule, not only active ones.** Filtering to active conditions first would let the daemon sleep straight through the moment a schedule is due to start. - **Allowlist needs the installed-app inventory.** `EngineInput` carries `installed_apps`; an empty slice fails open rather than guessing. - **Wall-clock correction extends, never shortens, and clamps to the 24h cap.** Extending unbounded would satisfy anti-tamper and violate section 1.5.4. - **`cast_possible_truncation` is denied workspace-wide.** Truncating a millisecond counter would corrupt a lock timer. Verified: 22 tests pass, `cargo fmt --check` clean, `cargo clippy -D warnings` clean, stripped release daemon 0.279 MB against the 5 MB budget. Co-Authored-By: Claude Code <noreply@anthropic.com> |