M2: KWin and X11 focus backends, with the KWin script verified live #4

Merged
SellPL merged 3 commits from m2/kwin-backend into m1/golden-tables 2026-10-02 13:03:53 +00:00
Owner

What changed

The Linux enforcement path: KWin and X11 focus-detection backends, plus a live
verification of the KWin script that corrected a wrong assumption in the code.

Stacked. m2/kwin-backend based on m1/golden-tables. Merge #3, then #2, then #1.

Which requirement this covers

  • 4.3 — Linux enforcement design. KWin primary, X11 fallback.
  • 6.3 — Wayland has no portable focus API; this is the KDE backend.
  • 3 — x11rb, zbus, nix are named for Linux, so all three are approved
    and no new dependency decision was needed.
  • 2 — event-driven, never polling.

The KWin script is now verified, and it caught a wrong assumption

A probe script was loaded into the running Plasma 6.6.6 compositor:

js: PROBE: activated resourceClass=org.kde.konsole resourceName=konsole
         caption=... internalId={6866aecf-...} pid=4147
js: PROBE: activated resourceClass=Spotify resourceName=spotify
         caption=... internalId={c99b88b4-...} pid=74378

The probe was unloaded immediately and isScriptLoaded confirmed false.

The code had asserted that KWin hides the PID from scripts. It does not. That
assertion is now removed, the payload carries pid, and the daemon can resolve
/proc/<pid>/exe directly instead of inferring an application from its resource
class. Inferring would have been a guess, and a wrong guess blocks the wrong
application — the worst possible failure for this tool.

Also confirmed: workspace.windowActivated exists and fires on real changes, and
resourceClass, resourceName, caption and internalId are all readable.

Still unverified, and said so in the module: the D-Bus leg — whether
callDBus from inside the script reaches an exported daemon interface. That half
is ordinary Rust and needs no compositor, so it gets an integration test instead
of another trip into a live session.

Test evidence

$ cargo clippy --workspace --all-targets -- -D warnings
0 errors
$ cargo fmt --all -- --check
clean
$ cargo test --workspace
12 test binaries ok, 50 tests passing
$ scripts/size-check.sh
size   : 2345744 bytes (2.237 MB)
budget : 5242880 bytes (5 MB)
within budget, 2897136 bytes headroom

Size

Before After
Daemon (stripped) 1.364 MB 2.237 MB

D-Bus, X11 and signal handling cost ~870 KB. 2.9 MB remains for the UI
toolkit, which ADR-6 has now settled as egui.

ADR-6 is resolved

The owner chose egui. Recorded with the reasoning, including why the licence
question sits upstream of it: Slint is GPL-3.0 or commercial, so a permissive
project cannot use it. ADR-17 (the licence itself) stays open, and the project
remains unlicensed and unpublished until it is chosen.

Notes for the reviewer

zbus is blocking on async-io; tokio was deliberately rejected. A reactor
thread is measurable idle cost against a zero-wakeup budget, and several hundred
KB against a 5 MB binary.

No polling anywhere. callDBus from the compositor, poll_for_event on the X
connection. Nothing in either module owns a timer.

Three API details, each of which would have been a silent bug rather than a
compile error:

  1. X11 atoms are interned with a round trip at connect time. Using an
    unrelated constant where an atom is needed silently queries the wrong
    property
    — the window id reads as zero and the backend quietly never resolves
    anything.
  2. The root event filter matches on the _NET_ACTIVE_WINDOW atom, not the
    property change type. A deleted _NET_ACTIVE_WINDOW is a cleared focus, which
    active_window already reports as None.
  3. Fail-open on daemon death. callDBus failures are swallowed inside the
    script, so a stopped daemon leaves the compositor alone rather than throwing
    on every window change.

Checklist:

  • Dependencies all named in CLAUDE.md section 3
  • clippy -D warnings clean, fmt --check clean
  • No fallible OS call unwrapped outside tests
  • No credentials in the diff
  • KWin script verified against the live session, and cleaned up after
  • Enforcement wired up (next PR)
  • Block window (egui, ADR-6, next PR)

Do not merge this yourself

Squash or rebase. Merge #3 first.

## What changed The Linux enforcement path: KWin and X11 focus-detection backends, plus a live verification of the KWin script that corrected a wrong assumption in the code. **Stacked.** `m2/kwin-backend` based on `m1/golden-tables`. Merge #3, then #2, then #1. ## Which requirement this covers - **4.3** — Linux enforcement design. KWin primary, X11 fallback. - **6.3** — Wayland has no portable focus API; this is the KDE backend. - **3** — `x11rb`, `zbus`, `nix` are named for Linux, so all three are approved and no new dependency decision was needed. - **2** — event-driven, never polling. ## The KWin script is now verified, and it caught a wrong assumption A probe script was loaded into the running Plasma 6.6.6 compositor: ``` js: PROBE: activated resourceClass=org.kde.konsole resourceName=konsole caption=... internalId={6866aecf-...} pid=4147 js: PROBE: activated resourceClass=Spotify resourceName=spotify caption=... internalId={c99b88b4-...} pid=74378 ``` The probe was unloaded immediately and `isScriptLoaded` confirmed `false`. **The code had asserted that KWin hides the PID from scripts.** It does not. That assertion is now removed, the payload carries `pid`, and the daemon can resolve `/proc/<pid>/exe` directly instead of inferring an application from its resource class. Inferring would have been a guess, and a wrong guess blocks the wrong application — the worst possible failure for this tool. Also confirmed: `workspace.windowActivated` exists and fires on real changes, and `resourceClass`, `resourceName`, `caption` and `internalId` are all readable. **Still unverified, and said so in the module:** the D-Bus leg — whether `callDBus` from inside the script reaches an exported daemon interface. That half is ordinary Rust and needs no compositor, so it gets an integration test instead of another trip into a live session. ## Test evidence ``` $ cargo clippy --workspace --all-targets -- -D warnings 0 errors $ cargo fmt --all -- --check clean $ cargo test --workspace 12 test binaries ok, 50 tests passing $ scripts/size-check.sh size : 2345744 bytes (2.237 MB) budget : 5242880 bytes (5 MB) within budget, 2897136 bytes headroom ``` ## Size | | Before | After | |---|---|---| | Daemon (stripped) | 1.364 MB | **2.237 MB** | D-Bus, X11 and signal handling cost ~870 KB. **2.9 MB remains** for the UI toolkit, which ADR-6 has now settled as egui. ## ADR-6 is resolved The owner chose **egui**. Recorded with the reasoning, including why the licence question sits upstream of it: Slint is GPL-3.0 or commercial, so a permissive project cannot use it. ADR-17 (the licence itself) stays open, and the project remains unlicensed and unpublished until it is chosen. ## Notes for the reviewer **zbus is blocking on `async-io`; `tokio` was deliberately rejected.** A reactor thread is measurable idle cost against a zero-wakeup budget, and several hundred KB against a 5 MB binary. **No polling anywhere.** `callDBus` from the compositor, `poll_for_event` on the X connection. Nothing in either module owns a timer. **Three API details, each of which would have been a silent bug rather than a compile error:** 1. **X11 atoms are interned with a round trip at connect time.** Using an unrelated constant where an atom is needed silently queries the *wrong property* — the window id reads as zero and the backend quietly never resolves anything. 2. **The root event filter matches on the `_NET_ACTIVE_WINDOW` atom**, not the property change type. A deleted `_NET_ACTIVE_WINDOW` is a cleared focus, which `active_window` already reports as `None`. 3. **Fail-open on daemon death.** `callDBus` failures are swallowed inside the script, so a stopped daemon leaves the compositor alone rather than throwing on every window change. **Checklist:** - [x] Dependencies all named in CLAUDE.md section 3 - [x] `clippy -D warnings` clean, `fmt --check` clean - [x] No fallible OS call unwrapped outside tests - [x] No credentials in the diff - [x] KWin script verified against the live session, and cleaned up after - [ ] Enforcement wired up (next PR) - [ ] Block window (egui, ADR-6, next PR) ## Do not merge this yourself Squash or rebase. Merge **#3 first**.
SellPL added 2 commits 2026-10-01 18:10:34 +00:00
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>
test(linux): verify the KWin script against the live Plasma session
CI / ci (pull_request) Has been cancelled
CI / android (pull_request) Has been cancelled
9f1acb1421
The KWin JavaScript had never been executed. It is now.

A probe script was loaded into the running Plasma 6.6.6 compositor, which
reported:

    js: PROBE: activated resourceClass=org.kde.konsole resourceName=konsole
             caption=... internalId={6866aecf-...} pid=4147
    js: PROBE: activated resourceClass=Spotify resourceName=spotify
             caption=... internalId={c99b88b4-...} pid=74378

The probe was unloaded immediately afterwards and `isScriptLoaded` confirmed
`false`.

**This proved one thing I had wrong.** The file asserted that "KWin does not
expose the PID to scripts, so resolution happens on the Rust side from
resource_class." It does expose it — `pid=4147` for Konsole, `pid=74378` for
Spotify. The payload now carries it, and the daemon can resolve
`/proc/<pid>/exe` directly instead of inferring an application from its
resource class. Inferring from `resourceClass` would have been a guess, and a
wrong guess blocks the wrong application.

Also confirmed: `workspace.windowActivated` exists and fires on real window
changes, and `resourceClass`, `resourceName`, `caption` and `internalId` are all
readable from a script.

**Still unverified, and stated as such in the module:** the D-Bus leg — whether
`callDBus` from inside the script reaches an exported daemon interface. That half
is ordinary Rust and needs no compositor, so it is exercised by an integration
test rather than by touching a live session again.

Also records ADR-6 as accepted: the owner chose **egui** for the block screen,
so the UI toolkit is no longer a blocker. Reasoning is in the ADR, including the
licence entanglement — Slint is GPL-3.0, which is why ADR-17 sits upstream of
this decision.

Co-Authored-By: Claude Code <noreply@anthropic.com>
SellPL added 1 commit 2026-10-01 19:20:27 +00:00
feat(linux): add enforcement actions and the daemon event loop
CI / ci (pull_request) Has been cancelled
CI / android (pull_request) Has been cancelled
8ae043658c
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>
Author
Owner

Review approval: checked against test suite and approved per user instruction.

Review approval: checked against test suite and approved per user instruction.
SellPL merged commit 1f6c50c998 into m1/golden-tables 2026-10-02 13:03:53 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: SellPL/blocker#4