shadow: fail closed instead of raising on unreadable lock/fact inputs
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run

The enforce loop has no per-tick guard, so an OSError/ValueError from
paused_by or read_now would crash-loop the daemon and stop enforced
billing. Such a tick now bills shadow time unpaused (Q8) and logs a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
This commit is contained in:
Krzysztof Rudnicki 2026-10-19 12:50:41 +02:00
parent 4cf77b61c6
commit 79e5e9fc26
2 changed files with 27 additions and 5 deletions

View File

@ -24,6 +24,7 @@ Overlapping sources bill the wall clock once and split it evenly, so
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
import logging
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Final from typing import TYPE_CHECKING, Final
@ -44,6 +45,8 @@ CLAIMS_DIR: Final = Path("/run/user/1000/gatelock/claims")
_MAX_AGE_SECONDS: Final = 15 _MAX_AGE_SECONDS: Final = 15
FAILCLOSED_KEY: Final = "failclosed:watcher" FAILCLOSED_KEY: Final = "failclosed:watcher"
logger = logging.getLogger(__name__)
@dataclass(frozen=True) @dataclass(frozen=True)
class ShadowVerdict: class ShadowVerdict:
@ -90,12 +93,18 @@ def observe_shadow(owners: dict[int, str], *, stamp: float) -> ShadowVerdict:
stamp: Epoch seconds of this tick, the freshness reference. stamp: Epoch seconds of this tick, the freshness reference.
Returns: Returns:
The tick's shadow verdict. The tick's shadow verdict; the fail-closed one if an input raised.
""" """
paused = paused_by(CLAIMS_DIR) # The enforce loop has no per-tick guard, so a raise here would crash-loop
if paused is not None: # the daemon and stop *enforced* billing. An unreadable input bills, unpaused.
return ShadowVerdict(paused_by=paused, keys=()) try:
fact = read_now(NOW_PATH, stamp, max_age=_MAX_AGE_SECONDS) paused = paused_by(CLAIMS_DIR)
if paused is not None:
return ShadowVerdict(paused_by=paused, keys=())
fact = read_now(NOW_PATH, stamp, max_age=_MAX_AGE_SECONDS)
except (OSError, ValueError) as exc:
logger.warning("Shadow inputs unreadable (%s); failing closed.", exc)
fact = None
return ShadowVerdict(paused_by=None, keys=shadow_keys(owners, fact)) return ShadowVerdict(paused_by=None, keys=shadow_keys(owners, fact))

View File

@ -85,6 +85,19 @@ class TestObserveShadow:
assert verdict.keys == ("domain:youtube.com",) assert verdict.keys == ("domain:youtube.com",)
read.assert_called_once_with(_shadow.NOW_PATH, T0, max_age=15) read.assert_called_once_with(_shadow.NOW_PATH, T0, max_age=15)
@pytest.mark.parametrize("where", ["paused_by", "read_now"])
def test_a_failing_input_fails_closed_instead_of_raising(self, where: str) -> None:
"""The enforce loop has no per-tick guard: a raise would crash-loop it."""
with (
patch.object(_shadow, "paused_by", return_value=None),
patch.object(_shadow, "read_now", return_value=None),
patch.object(_shadow, where, side_effect=PermissionError("denied")),
):
assert observe_shadow({1: "app:440"}, stamp=T0) == ShadowVerdict(
paused_by=None, keys=("app:440",)
)
assert observe_shadow({}, stamp=T0).keys == (FAILCLOSED_KEY,)
def test_root_paths_are_the_desktop_users(self) -> None: def test_root_paths_are_the_desktop_users(self) -> None:
assert str(_shadow.NOW_PATH) == "/run/user/1000/leisure-watch/now.json" assert str(_shadow.NOW_PATH) == "/run/user/1000/leisure-watch/now.json"
assert str(_shadow.CLAIMS_DIR) == "/run/user/1000/gatelock/claims" assert str(_shadow.CLAIMS_DIR) == "/run/user/1000/gatelock/claims"