docs: record :sync latency budget from hardware measurement

New docs/notes/sync-latency.md breaks down the measured ~16 s cold :sync
(Wi-Fi + SNTP + one TLS push), explains the optimistic-retry handshake saving,
the reconcile/last-writer-wins semantics, and why the rest is near the protocol
floor. Linked from the notes index, the docs index, and the Git row of the root
README.
This commit is contained in:
Julien Calixte
2026-07-11 17:04:02 +02:00
parent 92068f1cce
commit a700412773
4 changed files with 91 additions and 2 deletions

View File

@@ -11,3 +11,4 @@
| [`ctrl-g-perceived-latency.md`](ctrl-g-perceived-latency.md) | Durability before delivery — surfacing "commit landed" at ~0.2 s makes the 510 s `Ctrl-G` push feel instant. |
| [`git-sync-images-and-repo-size.md`](git-sync-images-and-repo-size.md) | Why we don't shrink the notes repo — its 153 MB of media is remanso's image CDN, so rewriting history to slim the on-device clone breaks the web app. |
| [`boot-time-budget.md`](boot-time-budget.md) | Where the ~4.3 s to cursor goes — and why the ≤ 3 s v1.0 target is hard: one ~1.9 s full refresh is unavoidable at cold boot, so the splash is nearly free. |
| [`sync-latency.md`](sync-latency.md) | Where the ~16 s cold `:sync` goes — Wi-Fi + SNTP + one TLS push; why optimistic-retry cut a whole handshake, and why the rest is close to the protocol floor. |

View File

@@ -0,0 +1,88 @@
# Sync latency — where the ~16 s cold `:sync` goes
> **Measured 2026-07-11** on hardware, via the `:sync timing —` log line in
> [`firmware::git_sync`](../../firmware/src/git_sync.rs) (`publish_cycle`). A
> **cold** `:sync` (first of a power cycle) is **~16.0 s** power-on of Wi-Fi →
> `push done`; a **warm** one skips the one-time setup and is just the ~10 s
> publish. This note breaks the number down and records why most of it is a
> floor, not a bug.
>
> Notes index: [`README.md`](README.md). Docs index:
> [`../README.md`](../README.md). Why the raw number matters less than it looks:
> [`ctrl-g-perceived-latency.md`](ctrl-g-perceived-latency.md). Energy/keep-Wi-Fi-up
> tradeoff: [`../tradeoff-curves/wifi-auto-sync.md`](../tradeoff-curves/wifi-auto-sync.md).
> Sibling timing note: [`boot-time-budget.md`](boot-time-budget.md).
## The waterfall (cold sync)
From the serial log, first `:sync` after a cold boot
(`… wifi 3654ms, clock 2108ms, tls 304ms, publish(commit+push) 9944ms, total 16012ms`):
| Phase | ~ms | One-time? | Lever |
| --- | ---: | --- | --- |
| Wi-Fi assoc + DHCP | ~3650 | yes (per power cycle) | radio off until first `:sync`; association floor |
| SNTP first sync | ~2100 | yes | varies with NTP RTT (4.2 s the prior run); needed before TLS + commit time |
| TLS trust store install | ~300 | yes | write ~6 KB CA bundle to SD + set libgit2 option |
| **publish** = stage+commit + push | **~9900** | **every sync** | see below |
| **Total** | **~16000** | | |
The three one-time phases (~6.1 s) only pay on the *first* sync of a power cycle —
Wi-Fi, the clock, and the trust store are set up once and reused, so a **warm sync
is just the ~10 s publish**. Publish splits as:
| Sub-phase | ~ms | Note |
| --- | ---: | --- |
| stage + commit | ~3150 | `add_all(["*"])` walking the SD/FAT working tree, then commit to FAT |
| push: TLS handshake | ~2400 | one mbedTLS handshake to github.com |
| push: pack negotiate + upload | ~4400 | tiny delta — cost is negotiation/round-trips, not payload |
## The win: one TLS handshake, not two
The first hardware run (2026-07-11) measured **23.7 s** because it did a
**pre-commit fetch** — a second full TLS handshake plus a ref exchange — on every
sync, to absorb a foreign push before committing. That's ~3 s wasted on a normal
sync (remote unchanged), and it did ~6 s of real work the one time it absorbed a
maintenance commit.
The optimistic-retry rewrite (commit `3386969`) drops it: **push onto the current
tip first**; only if the remote *rejects* the push non-fast-forward do we fetch,
reconcile, and retry. The happy path — what runs ~99 % of the time — is now a
**single** handshake. That took the true normal-cold baseline from ~19 s to
**16.0 s** (and the inflated 23.7 s figure will never recur, since it was the
one-time reconcile).
## Foreign pushes: reconcile-and-replay, last-writer-wins
On a rejected push, `reconcile_onto_origin` fetches origin and does a **mixed**
reset onto it — moving the branch ref + index but leaving the working tree, so the
just-saved note survives — then `stage_and_commit` replays the note on the new tip
and retries. For this **single-writer appliance** that resolves last-writer-wins:
a concurrent remote *edit* to the same note loses to ours, and a remote-only
*added* file the card doesn't have would be dropped by the replay's `add --all`.
Both need a real merge (increment B) and don't arise from the device's own use.
This path is **hardware-verified for the happy case** (`9b635c42` fast-forwarded
clean); the reconcile branch itself is compile-verified but not yet exercised on
device.
## Can cold sync go lower?
The big rocks are physics or protocol, not slack:
- **Wi-Fi assoc ~3.6 s** and **SNTP ~24 s** are one-time per power cycle and
mostly out of our hands (association floor, NTP RTT). Keeping Wi-Fi up between
syncs trades battery for latency — see
[`../tradeoff-curves/wifi-auto-sync.md`](../tradeoff-curves/wifi-auto-sync.md).
- **TLS handshake ~2.4 s** and **push negotiate/upload ~4.4 s** are inherent to
libgit2-over-mbedTLS on this part; the payload is tiny, so there's little to
shave.
- **stage + commit ~3.1 s** is the one soft spot: staging `notes.md` directly
instead of `add_all(["*"])` would skip the SD/FAT tree walk (likely →
sub-second), at the cost of the file-agnostic design that a future multi-file
publish wants. Deferred, on purpose.
**Conclusion:** ~16 s cold / ~10 s warm is close to the floor for "commit to FAT +
one TLS push over Wi-Fi with a fresh clock." It reads as slow only if you wait on
it — and by design you don't: `:sync` is a deliberate action with a snackbar, and
[`ctrl-g-perceived-latency.md`](ctrl-g-perceived-latency.md) argues the perceived
cost is set by *when durability is surfaced*, not by wall-clock. Recorded here so
the number is scoped against the protocol, not treated as a regression.