Files
blocker/docs/PERF.md
T
SellPL 5073c7321d docs: add ADR log, architecture, limitations, permissions, perf, testing
The documentation set CLAUDE.md sections 2, 6, 8 and 10.1 call for, written as
records of decisions actually made rather than as a description of intent.

**DECISIONS.md** — 17 ADRs. Two are deliberately left open and say so: ADR-6
(the desktop UI toolkit, which section 3 requires be decided on measurement and
which needs the owner's call, partly because Slint is GPL-3.0) and ADR-17 (the
licence, which is a legal commitment nobody should make on the owner's behalf).
Where CLAUDE.md gives a reason for a decision, that reason is recorded; where it
does not, the file says the reason is unrecorded rather than inventing one.

**LIMITATIONS.md** — all eight section 6 limits plus the section 1.5.4 safety
guarantees, stated as facts about the operating systems. The words
"unbypassable", "tamper-proof", "guaranteed" and "secure" appear nowhere, per
sections 6.1 and 6.7. Ends with a separate list of shortfalls in the *current
code* — storage has no I/O, the golden tables cannot yet be parsed, LockState
always returns Normal — so a known gap in this implementation is never mistaken
for a platform limit.

**ARCHITECTURE.md** — the layering, the purity contract and its mechanical
enforcement, why `next_wakeup` must be armed over inactive schedules too, the
overnight weekday-of-start rule, and the end-to-end flow for one block event.

**PERF.md** — targets and results as separate tables, so an aspiration is never
read as a measurement. The one real number recorded (0.279 MB stripped daemon) is
labelled as nearly meaningless, since the daemon is two print statements. Lists
the common causes of idle-wakeup regressions.

**PERMISSIONS.md** — per platform, what each permission is for and what degrades
without it. Flags the two invasive ones (Android accessibility, extension host
permissions) rather than burying them, and records that unprivileged eBPF is
*assumed* unavailable here because the sysctls were never read.

**TESTING.md** — the golden-table contract, the add-the-case-first rule, and why
clock tampering needs code fixtures instead of data.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 15:22:24 +03:00

4.7 KiB

Performance

Two tables. Targets are from CLAUDE.md section 5 and are fixed. Results is what was actually measured, and it is currently almost empty because almost nothing is built yet.

The separation is deliberate. A single table mixing aspiration with measurement invites reading the aspiration as the result.

Targets (CLAUDE.md section 5)

Metric Android Windows / Linux daemon
Idle CPU ~0% (no wakeups without events) ~0%, ≤ 1 wakeup/min when idle
Idle RAM ≤ 30 MB PSS ≤ 15 MB RSS (daemon), UI process exits when its window closes
Block latency (foreground change → block shown) ≤ 150 ms ≤ 150 ms
Daemon binary — ≤ 5 MB stripped
APK size ≤ 10 MB (release, R8) —
Battery no wakelocks outside active enforcement —

The release profile in the workspace Cargo.toml carries opt-level = "z", LTO, codegen-units = 1, panic = "abort", and strip = true, which is what makes the size budget reachable. scripts/size-check.sh enforces it in CI from the first commit.

Results

Measured on: Ubuntu 26.04 LTS, KDE Plasma 6.6.6, 6 CPUs. Toolchain: rustc 1.98.1.

Metric Target Measured When
Daemon binary (stripped, release) ≤ 5 MB 0.279 MB 2026-10-01, M0
Block latency ≤ 150 ms not measured —
Idle CPU ~0% not measured —
Idle RAM (daemon) ≤ 15 MB RSS not measured —
Idle wakeups ≤ 1/min not measured —
APK size ≤ 10 MB not measured —

The binary figure is real but nearly meaningless right now: the daemon is two println! lines. The meaningful measurement comes in M2-Linux once enforcement exists, and the real risk is at that point — D-Bus, X11, and a UI toolkit are where 5 MB would actually be threatened.

How to measure

Linux daemon

# Binary size — the gate CI runs
scripts/size-check.sh

# Idle RSS
./systemctl --user start blocker
ps -o rss= -C blocker-daemon | awk '{printf "%.1f MB\n", $1/1024}'

# Idle CPU — sample over a minute, not instantaneously
pidstat -p "$(pidof blocker-daemon)" 10 6

# Block latency — timestamp on the compositor event, timestamp on the window map
# TODO: script this in M2-Linux once the KWin backend emits both.

perf stat -e context-switches,cpu-migrations is useful for the wakeup count. If idle wakeups exceed one per minute, a timer is firing that next_wakeup did not ask for — that is the first thing to look for.

Windows

Process Explorer, or ETW. The relevant counters are working set (RAM) and the process's CPU over a sampled window.

Android

adb shell dumpsys meminfo vu.valiq.blocker     # PSS, must be <= 30 MB
adb shell dumpsys batterystats vu.valiq.blocker
adb shell am profile start --sampling 1 vu.valiq.blocker /data/local/tmp/p

Perfetto for the wakeup analysis. A wakeup from the blocked package when nothing is happening is a bug in next_wakeup accounting, and battery stats will show it long before a user complains.

Why idle is hard, and what breaks it

The budget is zero idle wakeups, so the common causes are worth naming.

  1. A timer that fires to check something. The engine's job is to make these unnecessary. Every condition that contributes a next_wakeup boundary is a deliberate, named commitment. A new timer that does not correspond to a boundary is a bug.
  2. Conditions that should contribute no boundary but do. Wi-Fi and location schedules are changed by OS events — NetworkManager's callback, the geofencing API. Arming a timer for them would be a wakeup for nothing. wifi_schedule_declares_no_time_wakeup pins this.
  3. The UI holding a GL context. A resident graphics context costs both RAM and compositor wakeups. This is why the daemon and the block window are separate processes: the window exits when the block lifts, and nothing holds a context while idle.
  4. Unbounded Vec growth in the engine. blocked_apps is rebuilt per evaluation. With a handful of schedules it is small, but a schedule set in the thousands would allocate per focus event. next_wakeup returning None means the daemon is asleep and this does not matter, which is exactly why the budget and the design reinforce each other.
  5. Storage polling. Nothing in the design polls the database. If a future change introduces it, it will show up here first.

Recording a result

Add a row to the table above with the date and the tool used. Then note in the commit message whether the change touches the hot path — section 9 requires a re-measurement when it does.

A milestone is not done until its acceptance checks pass and its numbers are recorded here (section 7).