Sync before locking the gate + manual "Fetch from sync" button

The PC gate forced a re-log when a meal was already logged on the phone
but the 15-min sync had not yet propagated it. Two ways to close that gap:

- The gate now pulls the shared log right before it would lock -- and only
  when a lock is otherwise due, so the network cost is paid on the rare
  tick that matters. A meal already pushed by the phone then unlocks
  without re-entry. Fails closed: any sync error (no token, network, disk
  write-back) falls through to the local lock decision, never skips it.
- A "Fetch from sync" button on the lock screen runs the same pull on
  manual request: it auto-unlocks when the pull satisfies every pending
  slot, advances to the next meal on a partial pull, or reports no change.
  Disabled in --demo so the sandbox never triggers a real sync/push.
- Both paths share one fail-closed wrapper, _sync.pull_shared_log(), which
  also keeps the single broad-except in one place.
- _write_log is now atomic (temp file + Path.replace): the gate and the
  15-min sync timer can now both write the log, so a torn read must be
  impossible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtSmzACzJSGyEatgK9exkd
This commit is contained in:
Krzysztof Rudnicki 2026-07-11 17:54:38 +02:00
parent e6812f7b4f
commit be94b4e147
11 changed files with 319 additions and 9 deletions

View File

@ -13,17 +13,36 @@ from typing import TYPE_CHECKING
from diet_guard._gate import gate_is_due
from diet_guard._gatelock import MealGate, acquire_gate_lock, release_gate_lock
from diet_guard._gatelock_support import wait_for_display
from diet_guard._sync import pull_shared_log
if TYPE_CHECKING:
from collections.abc import Callable
def _is_due(emit: Callable[[str], None]) -> bool:
"""Return the gate decision, refreshing from sync only when a lock is due.
The cheap local check runs first; the network pull happens solely on the
rare tick a lock would otherwise fire (via :func:`pull_shared_log`, which
fails closed), then the decision is re-read from the freshly written log.
"""
if not gate_is_due():
return False
reason = pull_shared_log() # only pay the network cost when about to lock
if reason is not None:
emit(f"{reason}; using local log.")
return gate_is_due()
def cmd_gate(emit: Callable[[str], None], *, check: bool, demo: bool) -> int:
"""Run the log-to-unlock gate.
Three modes: ``--check`` is a headless decision (no window) whose exit code
a timer reads; ``--demo`` always shows a safe demo window; bare ``gate``
shows the real lock only when one is due. A flock guard stops a second
shows the real lock only when one is due. Both real modes route through
:func:`_is_due`, which -- only when a lock is otherwise due -- pulls the
shared log first (see :func:`_refresh_from_sync`) so a meal just logged on
the phone unlocks without a manual re-entry. A flock guard stops a second
window from stacking on top of the first, and a window-opening mode first
waits for the X display so a session-start launch never crashes unshown.
@ -37,10 +56,10 @@ def cmd_gate(emit: Callable[[str], None], *, check: bool, demo: bool) -> int:
For ``--check``: 0 if not due, 1 if a lock is due. Otherwise 0.
"""
if check:
due = gate_is_due()
due = _is_due(emit)
emit("due (a lock is warranted)" if due else "ok (no lock needed)")
return 1 if due else 0
if not demo and not gate_is_due():
if not demo and not _is_due(emit):
emit("ok - no lock needed right now.")
return 0
handle = acquire_gate_lock()

View File

@ -155,6 +155,7 @@ class MealGate(_GateMealFlow):
on_submit=self._on_submit,
on_close=self.close,
on_add_item=self._on_add_item,
on_fetch_sync=self._on_fetch_sync,
)
self._widgets = build_layout(
self.root,

View File

@ -15,6 +15,7 @@ from typing import TYPE_CHECKING
from diet_guard._budget import BudgetError, daily_budget, protein_target_g
from diet_guard._foodbank import remember_food, remember_meal
from diet_guard._gate import due_slots
from diet_guard._gatelock_nutrition import _GateNutrition
from diet_guard._gatelock_ui import ERR, FG, UNIT_GRAMS
from diet_guard._meal import MealItem, item_to_component, meal_total
@ -27,6 +28,7 @@ from diet_guard._state import (
today_total_kcal,
today_total_macros,
)
from diet_guard._sync import pull_shared_log
if TYPE_CHECKING:
from diet_guard._estimator import Nutrition
@ -257,6 +259,46 @@ class _GateMealFlow(_GateNutrition):
self._set_status(f"{logged} — all meals logged, unlocking…")
self.root.after(_UNLOCK_DELAY_MS, self.close)
# -- manual sync ------------------------------------------------------------
def _on_fetch_sync(self) -> None:
"""Pull the shared log on demand and unlock any slots it now satisfies.
For a meal already logged on another device (typically the phone) that
has not yet propagated here: rather than re-entering it to unlock, the
user pulls it in. The pull blocks the window briefly (bounded by the
sync timeout) and fails closed -- a failure leaves the lock untouched.
"""
if self.demo_mode:
self._set_status("Fetch from sync is only available on the real lock.")
return
self._set_status("Fetching from sync…")
self.root.update_idletasks()
reason = pull_shared_log()
if reason is not None:
self._set_status(f"{reason} — still locked.", error=True)
return
self._reconcile_after_fetch()
def _reconcile_after_fetch(self) -> None:
"""Drop pending slots a freshly pulled meal now covers; unlock if none."""
still_due = set(due_slots())
satisfied = [slot for slot in self._pending if slot not in still_due]
self._refresh_dashboard()
if not satisfied:
self._set_status("No new meals found in sync.")
return
self._pending = [slot for slot in self._pending if slot in still_due]
if not self._pending:
self._unlock("Synced from another device")
return
self._clear_inputs()
self._refresh_slot_header()
count = len(satisfied)
meal_word = "meal" if count == 1 else "meals"
self._set_status(f"Pulled {count} {meal_word} — next meal, please.")
self._widgets.desc_text.focus_set()
# -- dashboard --------------------------------------------------------------
def _refresh_dashboard(self) -> None:

View File

@ -93,6 +93,7 @@ class GateCallbacks:
on_submit: Callable[[], None]
on_close: Callable[[], None]
on_add_item: Callable[[], None]
on_fetch_sync: Callable[[], None]
def make_vars(root: tk.Misc) -> GateVars:
@ -422,6 +423,19 @@ def build_layout(
command=callbacks.on_submit,
).pack(pady=(4, 6))
# Manual pull for a meal already logged on another device (the phone) but
# not yet propagated to this machine -- saves re-typing it to unlock.
tk.Button(
frame,
text="⟳ Fetch from sync",
font=("Arial", 12),
bg="#334455",
fg="white",
activebackground="#445566",
cursor="hand2",
command=callbacks.on_fetch_sync,
).pack(pady=(0, 6))
status_label = tk.Label(
frame,
textvariable=vars_.status,

View File

@ -13,6 +13,7 @@ from __future__ import annotations
from datetime import datetime, timezone
import json
import logging
import os
from typing import TYPE_CHECKING
import uuid
@ -89,10 +90,21 @@ def _read_raw_log() -> DayLog:
def _write_log(log: DayLog) -> None:
"""Persist the full log to disk, creating the data directory if needed."""
"""Persist the full log to disk, creating the data directory if needed.
Written atomically -- a temp file in the same directory, then
:func:`os.replace` -- so a concurrent reader (the gate now syncs while the
15-min timer may also be writing) never sees a half-written file and
mistakes a torn read for an empty log.
"""
FOOD_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
with FOOD_LOG_FILE.open("w") as handle:
json.dump(log, handle, indent=2)
tmp_path = FOOD_LOG_FILE.with_name(f"{FOOD_LOG_FILE.name}.{os.getpid()}.tmp")
try:
with tmp_path.open("w") as handle:
json.dump(log, handle, indent=2)
tmp_path.replace(FOOD_LOG_FILE)
finally:
tmp_path.unlink(missing_ok=True)
def _hmac_key_available() -> bool:

View File

@ -130,3 +130,20 @@ def run_sync() -> DayLog:
message="diet_guard sync",
)
return resigned
def pull_shared_log() -> str | None:
"""Run a sync tick, failing closed instead of raising.
A thin wrapper over :func:`run_sync` for callers that must never crash on a
sync error: the gate's automatic pre-lock refresh and the lock screen's
manual "Fetch from sync" button. Returns ``None`` on success, or a short
human-readable reason when the pull could not complete (no token, network
down, a disk error writing the merged log back), so the caller keeps its
own lock decision rather than failing open.
"""
try:
run_sync()
except Exception as exc: # pylint: disable=broad-exception-caught
return f"sync unavailable ({exc})"
return None

View File

@ -4,10 +4,19 @@ alongside its source module (see _cli_gate.py's module docstring).
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from diet_guard import _cli_gate
from diet_guard._cli_gate import cmd_gate
from diet_guard._cli_gate import _is_due, cmd_gate
from diet_guard._estimator import Nutrition
from diet_guard._gate import gate_is_due
from diet_guard._state import log_meal
def _no_sync() -> object:
"""Neutralize the pre-lock pull (as if it found nothing) for mode tests."""
return patch.object(_cli_gate, "pull_shared_log", return_value=None)
class TestCmdGate:
@ -16,7 +25,7 @@ class TestCmdGate:
def test_check_due(self) -> None:
"""--check exits 1 and announces a due lock."""
lines: list[str] = []
with patch.object(_cli_gate, "gate_is_due", return_value=True):
with patch.object(_cli_gate, "gate_is_due", return_value=True), _no_sync():
assert cmd_gate(lines.append, check=True, demo=False) == 1
assert "due" in lines[0]
@ -46,10 +55,11 @@ class TestCmdGate:
assert "no lock needed" in lines[0]
def test_bare_gate_due_opens_window(self) -> None:
"""A bare gate that is due opens the real window."""
"""A bare gate that is still due after a sync opens the real window."""
gate = MagicMock()
with (
patch.object(_cli_gate, "gate_is_due", return_value=True),
_no_sync(),
patch.object(_cli_gate, "MealGate", return_value=gate),
patch.object(_cli_gate, "acquire_gate_lock", return_value=MagicMock()),
patch.object(_cli_gate, "release_gate_lock"),
@ -58,11 +68,24 @@ class TestCmdGate:
assert cmd_gate([].append, check=False, demo=False) == 0
gate.run.assert_called_once()
def test_bare_gate_cleared_by_sync_opens_no_window(self) -> None:
"""A meal pulled in by the pre-lock sync clears the lock: no window."""
lines: list[str] = []
with (
patch.object(_cli_gate, "gate_is_due", side_effect=[True, False]),
patch.object(_cli_gate, "pull_shared_log", return_value=None),
patch.object(_cli_gate, "MealGate") as factory,
):
assert cmd_gate(lines.append, check=False, demo=False) == 0
factory.assert_not_called()
assert "no lock needed" in lines[0]
def test_gate_already_running(self) -> None:
"""A held single-instance lock means a second window is not opened."""
lines: list[str] = []
with (
patch.object(_cli_gate, "gate_is_due", return_value=True),
_no_sync(),
patch.object(_cli_gate, "acquire_gate_lock", return_value=None),
patch.object(_cli_gate, "MealGate") as factory,
):
@ -75,6 +98,7 @@ class TestCmdGate:
lines: list[str] = []
with (
patch.object(_cli_gate, "gate_is_due", return_value=True),
_no_sync(),
patch.object(_cli_gate, "acquire_gate_lock", return_value=MagicMock()),
patch.object(_cli_gate, "release_gate_lock"),
patch.object(_cli_gate, "wait_for_display", return_value=False),
@ -83,3 +107,70 @@ class TestCmdGate:
assert cmd_gate(lines.append, check=False, demo=False) == 0
factory.assert_not_called()
assert "display not ready" in lines[0]
class TestIsDue:
"""The decision helper only pays the network cost when a lock is due."""
def test_not_due_skips_sync(self) -> None:
"""When nothing is due locally, no pull is attempted."""
pull = MagicMock(return_value=None)
with (
patch.object(_cli_gate, "gate_is_due", return_value=False),
patch.object(_cli_gate, "pull_shared_log", pull),
):
assert _is_due([].append) is False
pull.assert_not_called()
def test_due_then_cleared_by_sync(self) -> None:
"""A due lock that the pull clears re-reads as not due."""
pull = MagicMock(return_value=None)
with (
patch.object(_cli_gate, "gate_is_due", side_effect=[True, False]),
patch.object(_cli_gate, "pull_shared_log", pull),
):
assert _is_due([].append) is False
pull.assert_called_once()
def test_due_and_still_due_after_sync(self) -> None:
"""A due lock the pull cannot satisfy stays due."""
pull = MagicMock(return_value=None)
with (
patch.object(_cli_gate, "gate_is_due", side_effect=[True, True]),
patch.object(_cli_gate, "pull_shared_log", pull),
):
assert _is_due([].append) is True
pull.assert_called_once()
def test_pull_failure_is_reported(self) -> None:
"""A failed pull emits its reason but the local decision still stands."""
lines: list[str] = []
with (
patch.object(_cli_gate, "gate_is_due", side_effect=[True, True]),
patch.object(
_cli_gate, "pull_shared_log", return_value="sync unavailable (x)"
),
):
assert _is_due(lines.append) is True
assert "sync unavailable (x)" in lines[0]
class TestGateReadsFreshState:
"""The fix's whole correctness rests on the re-check reading fresh disk.
Mocked ``gate_is_due`` tests would pass whether or not production re-reads
state, so this one exercises the real uncached read path: a slot written to
``food_log.json`` (what ``run_sync`` does when it pulls a phone meal) must be
visible to the very next ``gate_is_due`` call.
"""
@staticmethod
def _nutrition() -> Nutrition:
return Nutrition(200.0, 10.0, 20.0, 5.0, 100.0, "test")
def test_written_slot_clears_the_gate_immediately(self) -> None:
"""At 09:00 only the 08:00 slot is due; logging it flips the decision."""
now = datetime(2026, 1, 1, 9, 0, tzinfo=timezone.utc)
assert gate_is_due(now) is True
log_meal("oatmeal", self._nutrition(), slot=8)
assert gate_is_due(now) is False

View File

@ -423,3 +423,75 @@ class TestMealFlow:
with patch.object(gate, "_unlock") as unlock:
gate._finish_slot("done")
unlock.assert_called_once()
class TestFetchFromSync:
"""The manual "Fetch from sync" button on the lock screen."""
def test_demo_mode_does_not_sync(self, gate: MealGate) -> None:
"""In demo the button is inert and never touches the network."""
with patch.object(_gatelock_mealflow, "pull_shared_log") as pull:
gate._on_fetch_sync()
pull.assert_not_called()
assert "only available on the real lock" in gate._vars.status.get()
def test_pull_failure_keeps_lock(self, gate: MealGate) -> None:
"""A failed pull shows the reason and leaves pending slots intact."""
gate.demo_mode = False
gate._pending = [8, 12]
with patch.object(
_gatelock_mealflow,
"pull_shared_log",
return_value="sync unavailable (x)",
):
gate._on_fetch_sync()
assert gate._pending == [8, 12]
assert "still locked" in gate._vars.status.get()
def test_no_new_meals_keeps_all_slots(self, gate: MealGate) -> None:
"""A clean pull that satisfies nothing reports so and keeps the slots."""
gate.demo_mode = False
gate._pending = [8, 12]
with (
patch.object(_gatelock_mealflow, "pull_shared_log", return_value=None),
patch.object(_gatelock_mealflow, "due_slots", return_value=(8, 12)),
):
gate._on_fetch_sync()
assert gate._pending == [8, 12]
assert "No new meals" in gate._vars.status.get()
def test_partial_advances_to_next_slot(self, gate: MealGate) -> None:
"""One slot pulled in leaves the rest; the window advances (singular)."""
gate.demo_mode = False
gate._pending = [8, 12]
with (
patch.object(_gatelock_mealflow, "pull_shared_log", return_value=None),
patch.object(_gatelock_mealflow, "due_slots", return_value=(12,)),
):
gate._on_fetch_sync()
assert gate._pending == [12]
assert "Pulled 1 meal " in gate._vars.status.get()
def test_partial_plural_wording(self, gate: MealGate) -> None:
"""Two slots pulled in uses the plural 'meals'."""
gate.demo_mode = False
gate._pending = [8, 12, 16]
with (
patch.object(_gatelock_mealflow, "pull_shared_log", return_value=None),
patch.object(_gatelock_mealflow, "due_slots", return_value=(16,)),
):
gate._on_fetch_sync()
assert gate._pending == [16]
assert "Pulled 2 meals" in gate._vars.status.get()
def test_all_satisfied_unlocks(self, gate: MealGate) -> None:
"""When the pull satisfies every pending slot, the gate unlocks."""
gate.demo_mode = False
gate._pending = [8, 12]
with (
patch.object(_gatelock_mealflow, "pull_shared_log", return_value=None),
patch.object(_gatelock_mealflow, "due_slots", return_value=()),
):
gate._on_fetch_sync()
assert gate._pending == []
assert "unlocking" in gate._vars.status.get()

View File

@ -357,6 +357,27 @@ class TestRawLogAccess:
write_raw_log(log)
assert read_raw_log() == log
def test_write_leaves_no_temp_file(self) -> None:
"""A successful atomic write cleans up its temp file."""
write_raw_log({"2026-06-22": [{"id": "x"}]})
assert list(_state.FOOD_LOG_FILE.parent.glob("*.tmp")) == []
def test_write_failure_preserves_prior_log(self) -> None:
"""A failed replace leaves the old log intact and no temp behind.
This is the point of the atomic write: a concurrent reader (the gate
now syncs while the timer may also write) never sees a torn or empty
log just because a write was interrupted.
"""
write_raw_log({"2026-06-22": [{"id": "original"}]})
with (
patch("pathlib.Path.replace", side_effect=OSError("no space")),
pytest.raises(OSError, match="no space"),
):
write_raw_log({"2026-06-22": [{"id": "clobbered"}]})
assert read_raw_log() == {"2026-06-22": [{"id": "original"}]}
assert list(_state.FOOD_LOG_FILE.parent.glob("*.tmp")) == []
class TestResignEntry:
"""resign_entry recomputes the hmac so a merged entry validates again."""

View File

@ -202,3 +202,20 @@ class TestRunSync:
with patch.object(_sync, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert lookup_food("oatmeal") is not None
class TestPullSharedLog:
"""The fail-closed wrapper the gate and the lock-screen button share."""
def test_returns_none_on_success(self) -> None:
"""A clean pull returns None (no reason to report)."""
with patch.object(_sync, "run_sync") as run_sync:
assert _sync.pull_shared_log() is None
run_sync.assert_called_once_with()
def test_returns_reason_on_any_failure(self) -> None:
"""Any run_sync error becomes a reason string, never an exception."""
with patch.object(_sync, "run_sync", side_effect=RuntimeError("boom")):
reason = _sync.pull_shared_log()
assert reason is not None
assert "boom" in reason

View File

@ -42,6 +42,10 @@ unfixable = []
[tool.ruff.lint.per-file-ignores]
"**/tests/**/*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "S107", "SLF001"]
"**/test_*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "S107", "SLF001"]
# pull_shared_log must fail closed: any run_sync error (no token, network, disk
# write-back) is returned as a reason string so the gate keeps locking rather
# than failing open. A deliberately broad catch, so BLE001 does not apply here.
"**/_sync.py" = ["BLE001"]
[tool.ruff.lint.pydocstyle]
convention = "google"