diff --git a/README.md b/README.md index d994e53..9b1b3db 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ surface (mostly `usb_kbd.rs`) is in [`MEMORY_AUDIT.md`](MEMORY_AUDIT.md). | UI layer | Custom thin widget layer | Ratatui's API _shape_ without its char-grid terminal model ([ADR-002](docs/adr.md#adr-002-ui-strategy--custom-widgets-on-embedded-graphics-not-ratatui)). | | Editor core | Custom, in-tree (`src/editor.rs`) | Modal (Normal / Insert / View / Command), motions, operators + text objects. Plain-ASCII buffer until the v0.2 UTF-8 work. | | USB host | `esp-idf` TinyUSB bindings | Boot-protocol HID; verified on hardware (Spike 4). | -| Git | **libgit2 via `git2`**, built as an esp-idf component with mbedTLS (`firmware/components/libgit2/`) | `gix` was the original pick but can't push over HTTPS — the [ADR-004](docs/adr.md#adr-004-git-implementation--gitoxide-gix) kill-switch fired ([postmortem](docs/postmortems/2026-07-05-spike7-gix-https-push.md)). On-device add → commit → push verified. | +| Git | **libgit2 via `git2`**, built as an esp-idf component with mbedTLS (`firmware/components/libgit2/`) | `gix` was the original pick but can't push over HTTPS — the [ADR-004](docs/adr.md#adr-004-git-implementation--gitoxide-gix) kill-switch fired ([postmortem](docs/postmortems/2026-07-05-spike7-gix-https-push.md)). On-device add → commit → push verified; ~16 s cold-`:sync` [latency breakdown](docs/notes/sync-latency.md). | | TLS | `mbedtls` via `esp-idf` | GitHub HTTPS with the chain checked against embedded roots; ≈35 KB heap measured during handshake (Spike 6). | | Auth | HTTPS + GitHub PAT | v0.1 bakes credentials in at build time via `TW_*` env vars; provisioning + at-rest protection is [ADR-011](docs/adr.md#adr-011-credential-provisioning--how-the-pat-reaches-the-device-and-is-protected-at-rest) (open), on-device settings land in v0.9. | | Filesystem | FAT on SD (`esp_vfs_fat`) | Working copy lives here. Internal LittleFS holds config. | diff --git a/docs/README.md b/docs/README.md index 69c7fbc..46dda4d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,5 +31,5 @@ | --- | --- | | [`spikes.md`](spikes.md) | Rendering & UX spikes — display/UX risks proved outside the hardware stack. | | [`postmortems/`](postmortems/README.md) | Bring-up debugging write-ups: what broke, the root cause, and the decisions that came out of it. | -| [`notes/`](notes/README.md) | Longer-form essays on the thinking behind specific choices. | +| [`notes/`](notes/README.md) | Longer-form essays on the thinking behind specific choices — e.g. where the ~16 s cold [`:sync`](notes/sync-latency.md) goes. | | [`tradeoff-curves/`](tradeoff-curves/README.md) | Cost-vs-knob curves behind chosen defaults — energy, latency, memory. | diff --git a/docs/notes/README.md b/docs/notes/README.md index 314af8c..90519c5 100644 --- a/docs/notes/README.md +++ b/docs/notes/README.md @@ -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 5–10 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. | diff --git a/docs/notes/sync-latency.md b/docs/notes/sync-latency.md new file mode 100644 index 0000000..536bde3 --- /dev/null +++ b/docs/notes/sync-latency.md @@ -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 ~2–4 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.