typoena.dev / stories / real-repo-sync

Kaizen · git sync

The memory war

Press :sync on the real notes repo — 1179 files, a 263 MB pack — and the device froze for ten minutes. Power-cycle it and it re-entered the same freeze, forever. The true score was ∞: zero successful syncs, ever. Getting to a clean push took two rounds of this, and most of the obvious explanations turned out wrong.

1The goal

One keystroke should put your words safely on the remote, and you should be able to keep writing while it publishes. The metric: seconds from :sync to the snackbar, on the real jcalixte/notes clone on the device — not a toy repo, which turned out to understate everything by two orders of magnitude.

befores611 s, then never completes
target ≤ ~10 sa reset re-enters the freeze — the honest before-value is infinity

2The freeze

you change

one note, one line — then press :sync

:sync runs stage_and_commit

index.add_all(["*"])

index.update_all(["*"]) → stat all 1179 files

index.write() → re-hash every "racy" entry

FAT's mtime resolution is 2 s, so on a fresh clone the whole tree looks racy-clean — index.write re-reads all of it

⚑ weak point

~170 MB pulled back over the 10 MHz SPI bus

1179 files · 260 of them images the one-line edit never touched

result

611 s → freeze → power-cycle → the index stamp never advanced, so it re-enters the freeze →

One blob changed; the whole tree re-read. Because the index stamp never advances, a reset drops straight back into the freeze.

That points at the local commit stage. Its staging is O(N_tree) in files and bytes, and here N_tree ≈ 1179 over a 10 MHz bus. The network half is real too, but it doesn't brick the device, so this kaizen takes the freeze first. Everything below was benched on the device; tap a factor for its test and what the measurement said about the current method — most of these sounded right and measured wrong:

3The idea

The winning move is the only candidate whose cost is independent of repo size — so the problem can't come back as the notes tree grows. Everything index-shaped stays O(N_tree) and merely moves the wall.

  • chosenO(depth) TreeBuilder splice611 s → ~2–3 s, flat in repo size foreverNever opens the index, never materialises the tree; carries 150 MB of images forward by OID.
  • chosenFASTSEEK + 256-word CLMT bufferfar seek 198.7 → 20.4 msConfig, not code — the companion fix. Write-mode files fall back transparently.
  • rejectedExplicit-path index stagingremoves the O(N) walk onlyStill calls index.write() — hits the same racy-clean wall.
  • rejectedIndex-free write_tree_toSeeding read_tree(HEAD) is 77 s + OOM. Trades one O(N) for another.
  • rejectedesp_map.c window cachehoped ~150–250 ms / writeBuilt, instrumented, 0 hits in 4 runs, removed. The one build made on an unverified cause.
  • deferredShrink the repo (images off-card)shrinks N and the cloneThe images are load-bearing for another app. Composes with the splice later.

4What could go wrong

Stability

libgit2's 32-bit mwindow defaults malloc a 32 MB window on first pack access → instant OOM on the 8 MB PSRAM heap.

Set the mwindow options at service start, before any Repository::open. (The push loop later cut these to 64 KB / 1.5 MB.)

Stability

A power pull between a save and the next :sync loses the dirty set — nothing walks the tree, so an unrecorded file would never sync.

Journal the dirty set to /sd/.typoena-dirty atomically, before the file write. Over-reporting is a free no-op splice, so the semantics stay simple.

Method

A deliberate behaviour change: files edited on the card from a desktop are no longer committed — add_all used to sweep them in.

Accepted and documented as intent. The appliance's editor is the only writer that counts.

5The change

The whole re-hash wall comes from the index. So the fix removes the index: read each dirty file into a blob, rebuild only its ancestor subtree chain onto HEAD's tree — a handful of objects, no walk, no re-hash, images carried by reference.

6The push

beforesnever completed
after24.1smeasured, end-to-end
target ≤ ~10 s

The commit half was won — but the first real push then died silently, and fixing it took a second localization loop. Five more runs, each with telemetry built for that run. The theme was memory: libgit2 memory-maps its pack files, and on a 562 MB clone those maps alone overran 8 MB of RAM. Each fix bought back headroom and exposed the next ceiling.

min-ever free PSRAM during the push

run 7 · mmaps named472 BMB
run 8 · 64 KB windows2.05 MBMB
run 9 · single-pack card4.5 MBMB
472 bytes from the cliff, to 4.5 MB of room — same push, three fixes apart

The nine attempts, and what each one taught:

What it taught

  • Bench the real thing. The toy repo understated everything by ~2 orders of magnitude and hid a total brick.
  • Refute before building. Four explanations fell to measurement, and the only code written for an unverified hunch was the only code wasted.
  • Prefer mechanisms flat in N. The splice can't regress as the tree grows — the fix that stops the problem coming back.
  • The UI must not share a fate with git: a Rust Vec allocation failure aborts, a C malloc failure degrades to a snackbar. Repaints now render into pre-allocated frames.
  • Repo pack layout is a device-performance variable. Five packs vs one is a 32 s marking walk vs 3.5 s — and losing vs winning the keep-alive race.

Result: ∞ → 24.1 s. Still over the ~10 s target — but the failure changed category, from "bricks the device, forever" to "a finite number to shave."