A Sunday ran past the 6.0h the i3 bar showed: 2h47m of Saturday's leftover was carried into the budget the cutoff used, and the 3-second "Gaming budget" journal line omitted it. Unspent time is now dropped at 06:00 every day; load_state takes a legacy ledger's share back out of today's high-water mark. The budget line logs only on change (was 25k lines/day), fired warnings are logged, and budget.jsonl records carry budget_seconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015PrwCnQouKTF4EUMENMxnG
4.4 KiB
Control socket: implementation notes
How the root daemon's ctl.sock (contract: DOCS-web-control-api.md, "Control
socket") is built. Code: steam_backlog_enforcer/_ctl_*.py; client for the web
server: _ctl_client.py (results in _ctl_types.py, errors in _ctl_protocol.py).
Trust
- Root (uid 0) is admitted besides the desktop user: root can already do all of
this directly, so refusing it protects nothing and it keeps recovery/tests on
one interface. Any other uid is closed unanswered (client:
DaemonUnreachableError). - A second
enforce(manual run,--demo) never takes the socket from a live daemon;--demonever serves. - The daemon re-checks the CLI's locks with the same tables
(
_command_locks.py): under a total block onlygaming_unblockworks; under the manual-pick lockstore_unblockandblock_gamingarelocked. - Ops run on connection threads and take the enforce loop's
tick_lock, so they never interleave with a pass; the loop reloads state under that lock.
Differences from the contract
- Daemon-only error codes:
op_failed(the privileged action ran and failed, or an unexpected exception) andunsupported(restart asked of a daemon systemd does not supervise: the exit would be a stop). Not inApiErrorCode; the web server should map unknown daemon codes to a 502. - The two-phase reply uses
pending_id(the contract'sPendingAction.id) and an extralapse_after_seconds; the server maps it. block_gamingis capped at 1..365 days and refused while a block is active.gaming_reset_armwhile one is armed returns the existing pending action (samepending_id, sameready_at): re-arming can neither shorten nor extend the wait. Pending resets live in daemon memory, so a daemon restart drops them (fail closed: arm again).- Test-only:
SBE_CTL_TEST_COUNTDOWN_SECONDS(1..299) in the unit's environment shortens the countdown for VM runs. It cannot lengthen it; unset means 300 s.
Gaming reset
_gaming_reset.reset_today, shared with the CLI: zeroes billed time and per-game
attribution, re-arms warnings, releases the mounts, keeps earned time
(budget_seconds; the old CLI path dropped it), and writes a manual_adjustment
record to /var/log/steam-backlog-enforcer/budget.jsonl (billed seconds and
per-game breakdown before). That record is the auditable trail; the per-day
history mirrors current billed time, so it shows the day as quiet.
What a restart does and does not lose
restart sets a flag; the enforce loop finishes its pass, runs one more
accounting tick (billing the time since the last tick), leaves an exit-time
marker (_ctl_gap.py), and exits 0; Restart=always brings it back after
RestartSec=5. The new daemon spends the marker on its first tick, billing the
whole downtime. Everything else already lives on disk after every tick:
| State | Survives? | Why |
|---|---|---|
| Billed seconds, per-game attribution | yes | playtime_state.json saved every tick; the final tick bills up to the exit, the first tick of the new daemon bills the gap |
| Cutoff / kill-grace timing | yes | blocked_at is persisted; _sustain_block measures from it |
| Playtime mounts (the block itself) | yes | kernel mount table; reconcile is mountinfo-driven. RuntimeDirectoryPreserve=yes keeps the stub file |
| Fired warnings, budget high-water | yes | in PlaytimeState |
| Open store window | yes (blip) | deadline in state.json; startup re-blocks the store once, the first tick re-opens it |
| Total block | yes | lock file, guard-lib |
| Armed gaming reset | no | memory only: arm again (fails closed) |
| Audit-journal "last verdict", history throttle | no | cosmetic: one extra verdict_change line, one extra history write |
Why the gap is billed explicitly: the budget clamps each tick's delta to
2 x ENFORCE_INTERVAL = 6 s so a suspend cannot inflate it, which would forgive
the restart gap (RestartSec + interpreter start + startup pass, seconds to tens
of seconds) and make a restart repeatable free play. The marker is deleted
before use, expires after 300 s and only a requested restart writes one, so a
crash or a long outage still goes through the normal clamp. Downtime nothing
enforces: an unauthorised game started in the gap is killed on the first tick;
the budget cutoff and its mounts persist throughout.
Rate limit: 1 per 10 minutes, persisted in
/var/lib/steam-backlog-enforcer/ctl_restart.json (root, 0600) so restarting
the daemon does not reset it.