diff --git a/steam_backlog_enforcer/_shadow.py b/steam_backlog_enforcer/_shadow.py index a059eb0..b47277f 100644 --- a/steam_backlog_enforcer/_shadow.py +++ b/steam_backlog_enforcer/_shadow.py @@ -24,6 +24,7 @@ Overlapping sources bill the wall clock once and split it evenly, so from __future__ import annotations from dataclasses import dataclass, replace +import logging from pathlib import Path from typing import TYPE_CHECKING, Final @@ -44,6 +45,8 @@ CLAIMS_DIR: Final = Path("/run/user/1000/gatelock/claims") _MAX_AGE_SECONDS: Final = 15 FAILCLOSED_KEY: Final = "failclosed:watcher" +logger = logging.getLogger(__name__) + @dataclass(frozen=True) 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. Returns: - The tick's shadow verdict. + The tick's shadow verdict; the fail-closed one if an input raised. """ - 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) + # The enforce loop has no per-tick guard, so a raise here would crash-loop + # the daemon and stop *enforced* billing. An unreadable input bills, unpaused. + try: + 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)) diff --git a/steam_backlog_enforcer/tests/test_shadow.py b/steam_backlog_enforcer/tests/test_shadow.py index a2894b1..8374d7f 100644 --- a/steam_backlog_enforcer/tests/test_shadow.py +++ b/steam_backlog_enforcer/tests/test_shadow.py @@ -85,6 +85,19 @@ class TestObserveShadow: assert verdict.keys == ("domain:youtube.com",) 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: assert str(_shadow.NOW_PATH) == "/run/user/1000/leisure-watch/now.json" assert str(_shadow.CLAIMS_DIR) == "/run/user/1000/gatelock/claims"