The previous commit turned tracked `CLAUDE.md` into a symlink to an *untracked* `AGENTS.md`, deleting 237 lines of documentation from the repo -- the content survived only in my working tree, and a fresh clone would have got a dangling symlink. The symlink predates this session (it was already a type-change in the working tree at the start); committing it without tracking its target is what broke it. Tracking `AGENTS.md` then tripped the 250-line cap, so the sync-latency material moves to `docs/sync-latency.md` behind a pointer -- the same shape the repo already uses for `docs/meal-schedule.md` and the Flutter rules. That file carries the measurements, the three narrow-pull state invariants, the Tk threading rules, why the gate's publish must stay synchronous under `Type=oneshot`, the timeout asymmetry, and the `revs/` marker trap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz
4.7 KiB
Sync latency: what must never block the user
Measured 2026-08-21 against the live remote. Before this work the gate ran a full sync tick before deciding whether to lock: ~27s, and the lock screen's "Fetch from sync" button ran the same tick inline on the Tk thread.
| Path | Before | After |
|---|---|---|
gate --check (nothing due) |
216ms | ~105ms |
| "Fetch from sync" button | 18-27s | ~84ms |
diet-guard log ... |
15.5s | ~2ms |
| Background full tick | ~27s | ~8.9s |
The narrow pull
_sync_refresh.pull_peer_logs() answers one question — "has a peer already
logged this slot?" — and only the peer logs can answer it. No budget, no
food banks, no rebuild_food_bank, and no push.
Three state invariants, each with a regression test, because each is silent when broken:
pushed_revsurvives verbatim. This pass never pushes; overwriting it convinces the next full tick it has already published, and the device goes invisible to every peer, permanently.peer_revsis merged, not replaced. Only changed peers are visited, so a wholesale replace drops every untouched entry and re-downloads them next tick.- The log is written before the state. The other order records a peer as merged while its records are absent — silent data loss.
Peer enumeration deliberately avoids list_directory (~445ms mirrored, because
the GitHub half alone is ~360ms) and unions the revision map with peer_revs
instead. Membership is tested with in, never .get(...) is not None:
peer_revs legitimately holds explicit nulls, and treating those as "never
seen" re-downloads them forever.
Threading
The fetch button runs the pull on a daemon worker and marshals the result
back through a queue.Queue polled by root.after. The worker touches no
widget — Tcl is not thread-safe, and that includes after. Polling also
makes the completion path testable with no thread at all: put a result on the
queue, call the poll, assert.
A second click while a fetch is in flight is ignored. That is the reentrancy
guard and the write-race fix: the pull persists its merge, so two workers
would race two write_raw_log calls and orphan one result.
diet-guard log publishes through publish_after_log_detached (non-daemon, so
the process waits for the push at exit while the user already has their
output). The gate does not: diet-guard-gate.service is Type=oneshot, so
systemd reaps the unit the moment the main thread exits and a background push
would be killed halfway.
Timeouts
INTERACTIVE_TIMEOUT_SECONDS (2s) bounds anything a user waits on.
SYNC_TIMEOUT_SECONDS (10s) only ever reached GitHub: firebase_client_for
accepts no timeout in crdt_sync v0.6.0, so Firebase silently used its 15s
default and one hung session refresh stalled the lock window. The journal
recorded five such failures in five days, one of them a meal that logged
locally and never published.
_apply_timeout reaches for the private _timeout_seconds because the pinned
library exposes no other lever. The clean fix is a timeout_seconds kwarg
upstream; that is a cross-repo change (five consumers, tag bump).
Stale peers
Every install mints a uuid and pushes under it, and nothing cleaned them up. This remote reached 25 devices. Each dead one cost a round trip in the log pull and in each of the three bank syncs — roughly 400ms per peer per tick.
They were not phone reinstalls: seventeen were frozen at the same instant (2026-08-13T16:45:04) holding near-identical ~400-record copies of one log, the signature of repeated fresh-install runs. 20 were pruned on 2026-08-21 after verifying zero records would be lost.
prune-peers is dry-run by default, refuses --apply without --backup-dir,
and never proposes pc/phone/desktop (they carry pre-migration history and
SYNC_LEGACY_DEVICE_ID names one as this device's own former path).
It must delete each device's revs/ marker along with its data.
_candidate_peers enumerates peers from that map rather than from
list_directory, so an orphaned marker resurrects a deleted device as a
candidate and buys a round trip discovering it is gone. Pruning without this
took the narrow pull from ~90ms to ~15s — slower than before the prune.
Import cost
gate --check used to import requests (~78ms) and tkinter for a window it
never opens. Both are now resolved through module-level __getattr__
(PEP 562) plus import_module, which keeps every existing patch.object
target working and needs no lint suppression. The chains were
_cli_args -> _cli_sync -> crdt_sync -> requests, and
_cli_log -> _resolve -> _estimator_off -> requests.
Verify with python -X importtime -c "import diet_guard._cli_gate" | grep -c requests (expect 0).