mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 20:27:11 +01:00
The button ran a full sync tick inline on the Tk thread, so the fullscreen lock was dead for its whole duration -- ~18-27s measured against the live remote, and unbounded in the worst case. Two changes, and the second is what makes the first cheap: * the pull moves to a daemon worker thread, with the result marshalled back through a queue polled on the Tk thread. The worker touches no widget, by construction: Tcl is not thread-safe and that includes `after`. Polling also makes the completion path testable with no thread at all. * the button now runs the *narrow* pull. Its question is identical to the gate's -- "did a peer log this slot?" -- and `_reconcile_after_fetch` reads only `due_slots()`, so the budget and food banks it used to fetch were never consulted here. The full tick still runs after the window closes. Measured 84ms against the live remote. Narrowing also shrinks the write race from tens of seconds to imperceptible: the pull persists its merge, so a meal logged mid-fetch could be overwritten by a snapshot taken before it. A second click while a fetch is in flight is now ignored, which is the reentrancy guard and the race fix in one. That guard started out as a disabled button, which is how it surfaced a latent bug worth recording: the fetch button widget is never stored on `GateWidgets`, so `self._widgets.fetch_button` did not exist and the disable was a silent no-op -- hidden because the suppression tuple included `AttributeError`. Coverage would have reported 100% on a guard that did nothing. Guarding on `_fetch_result` needs no widget at all. Also adds `INTERACTIVE_TIMEOUT_SECONDS = 2.0` for the paths a user waits on. `SYNC_TIMEOUT_SECONDS` only ever reached GitHub: crdt_sync v0.6.0's `firebase_client_for` takes no timeout, so Firebase used its 15s default and one hung session refresh stalled the lock window -- the journal has five such refresh failures in five days. Verified to fail closed to the local decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz
60 lines
2.4 KiB
Python
60 lines
2.4 KiB
Python
"""Off-thread plumbing for the lock screen's "Fetch from sync" button.
|
|
|
|
Split out of :mod:`._gatelock_mealflow`, which is already near the repo's
|
|
250-line cap.
|
|
|
|
The pull used to run on the Tk thread, so the fullscreen lock froze for the
|
|
whole tick -- measured at ~18-27s against the live remote, and unbounded in
|
|
the worst case because the Firebase client's default timeout is 15s *per
|
|
request*. A user staring at a dead window is exactly the experience the gate
|
|
must not create.
|
|
|
|
Two rules shape the design:
|
|
|
|
* **The worker touches no widget.** Tcl is not thread-safe, and that includes
|
|
``after`` -- so the worker only ``put``s its result on a queue, and a poll
|
|
scheduled on the Tk thread picks it up. That also makes the completion path
|
|
testable with no thread at all: put a result, call the poll, assert.
|
|
* **The worker does not write the log either.** :func:`pull_shared_log`
|
|
persists the merged result, so running it while the user types would let a
|
|
merge snapshot taken *before* their meal was entered land *after* it,
|
|
silently dropping the entry. The button is therefore disabled for the
|
|
duration, which is both the race fix and the reentrancy guard.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import queue
|
|
import threading
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
#: How often the Tk thread checks whether the worker has finished.
|
|
FETCH_POLL_MS = 50
|
|
|
|
|
|
def start_fetch(pull: Callable[[], str | None]) -> queue.Queue[str | None]:
|
|
"""Run ``pull`` on a daemon thread; return the queue its result lands on.
|
|
|
|
Daemon so a hung request can never keep the process alive after the window
|
|
is gone -- the gate is a ``Type=oneshot`` unit and must exit.
|
|
"""
|
|
result: queue.Queue[str | None] = queue.Queue(maxsize=1)
|
|
|
|
def _worker() -> None:
|
|
"""Runs OFF the Tk thread. Touches no widget, by construction."""
|
|
reason: str | None = "sync failed (unexpected error)"
|
|
try:
|
|
reason = pull()
|
|
finally:
|
|
# Always feed the queue, even if ``pull`` raised: this thread has
|
|
# nowhere to propagate to, so an escaping exception would leave the
|
|
# poll waiting forever with the button disabled and the user stuck.
|
|
# ``pull`` is already fail-closed, so this is the unexpected case.
|
|
result.put(reason)
|
|
|
|
threading.Thread(target=_worker, daemon=True).start()
|
|
return result
|