docs(postmortems): record on-device Spike 7 push completion

The full init -> commit -> push over mbedTLS HTTPS now runs on hardware
(verified device-side + via git ls-remote). Documents the three fixes that
got there -- 96 KB main stack, remove-then-rename p_rename, and the utimes
existence-gate that had silently dropped every ODB write -- plus the
shortcuts still standing (cert bypass, PAT-in-flash, SSH-vs-HTTPS remote).
This commit is contained in:
Julien Calixte
2026-07-06 22:59:49 +02:00
parent a15789a1b4
commit bcb4ffa465

View File

@@ -1,9 +1,10 @@
# Spike 7 (git push) — the ADR-004 kill-switch fired: gix can't push over HTTPS
> Date: 2026-07-05
> Status: **turned, not failed** — gix ruled out for the push path; pivoted to
> `libgit2` (`git2`) and proved the git mechanics on desktop. On-device build is
> the next gate.
> Date: 2026-07-05 (on-device push completed 2026-07-06)
> Status: **DONE** — gix ruled out for the push path; pivoted to `libgit2`
> (`git2`), proved the git mechanics on desktop, then landed the full
> `init → commit → push` over mbedTLS HTTPS **on hardware** (2026-07-06). See
> "On-device push COMPLETE" below.
>
> Context: Spike 7 in
> [`../v0.1-mvp-technical.md`](../v0.1-mvp-technical.md#hardware-bring-up-order),
@@ -204,8 +205,17 @@ up.
(`jcalixte/typoena-test`, fine-grained PAT): HTTPS handshake + PAT auth +
push confirmed. Still open: the `push_update_reference` rejection path over
HTTPS (needs a non-fast-forward against a real remote to trigger it).
- [x] On-device `init → commit → push` over mbedTLS HTTPS — **DONE +
hardware-verified 2026-07-06** (see "On-device push COMPLETE").
- [ ] Revise the `git` module section of the technical doc (it still describes
gix crates/transport) once the device path is confirmed.
gix crates/transport) now the device path is confirmed.
- [ ] Retire the spike shortcuts before product: real cert trust-store (drop the
`certificate_check` bypass), and no PAT-in-flash (ADR-005).
- [ ] Settle the product sync transport — the real remote is SSH but on-device
libgit2 is HTTPS-only (ADR-level). Then fold the push into the editor's
`git` module (persistent clone + fast-forward, not a fresh per-boot branch).
- [ ] Optional tidy: move git to a dedicated large-stack task so the shared
main-task stack (and the editor build) can drop back to ~16 KB.
## Path 2 result — libgit2 compiles and links on xtensa (Gate A + Gate B)
@@ -302,8 +312,105 @@ libgit2-sys/pkg-config. The component's CMake now registers *empty* when
`git2 crate is talking to libgit2 1.9.4`, then `sha1(blob "hello") =
b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0` + "hash matches" — i.e. git2 →
libgit2 → mbedTLS SHA1 all ran correctly on device. Full chain proven.
4. **The real thing**`repository_init``commit``push` over mbedTLS
HTTPS (needs Wi-Fi/SNTP from Spike 6 + a working-copy location).
4. ~~**The real thing** — `repository_init``commit``push` over mbedTLS
HTTPS~~ — **DONE + hardware-verified 2026-07-06** (flash-FAT working copy);
see "On-device push COMPLETE" below.
## On-device push COMPLETE — 2026-07-06
The real thing runs on hardware. `just flash-git-push` (bin
`firmware/src/bin/git_push.rs`, build `@a15789a`) did the whole loop on the
ESP32-S3 and pushed to GitHub over HTTPS:
```
init OK at /spiflash/wc-1783370910
wrote device.md
staged + tree written
committed to master
origin set; pushing refs/heads/master:refs/heads/device/1783370910
cert-check BYPASSED for github.com
push accepted by remote
✅ Spike 7 complete — pushed master → origin/device/1783370910 over mbedTLS HTTPS
```
Verified from both ends: the device logged `push accepted`, and `git ls-remote
https://github.com/jcalixte/typoena-test.git` independently shows
`refs/heads/device/1783370910` at commit `a96a7996`. So Wi-Fi/SNTP → mount
flash-FAT → `repository_init``add_all``commit` → smart-HTTP push + pack
upload + PAT auth all work on-device through libgit2 + esp-idf mbedTLS.
**Heap:** started at 8.44 MB free, min-ever **6.85 MB** — the whole TLS handshake
+ packfile build cost ~1.6 MB, all served from PSRAM; internal DRAM was never
stressed. **Timing:** first-boot FATFS format of the working-copy dir ~7.7 s,
commit sub-second, TLS handshake→accept ~6 s.
### Three bugs stood between "links + runs" and "pushes"
Gate D proved SHA1 on device; getting from there to a real push took three fixes
on the flash-FAT + FATFS path, each found on hardware. All are committed (5
microcommits through `a15789a`).
1. **Main task stack 12 KB → 96 KB.** libgit2 is stack-hungry: nearly every
function puts a `char path[GIT_PATH_MAX]` (4 KB) buffer on the stack, and the
`repository_init → config-write → FATFS → wear-leveling` chain nests ~10 of
them — a *trivial config write* measured ~67 KB of stack. At 48 KB it
overflowed and smashed an adjacent newlib lock handle → `LoadProhibited` in
`xQueueGenericSend`. **This corrected an earlier misdiagnosis:** the "`time()`
only works on the main task, not a std::thread" conclusion from the first
on-device attempt was wrong — that thread had the *default 4 KB* stack, so the
same deep chain just overflowed sooner. It was always stack depth, not
thread-vs-main. (Caveat: `sdkconfig.defaults` is shared with the editor build,
which now over-reserves this stack; a dedicated large-stack git task would let
it drop back to ~16 KB.)
2. **`p_rename` = remove-then-rename** (`esp_stubs.c`). FATFS `f_rename` fails
`EEXIST` if the target exists and FAT has no hardlinks, so libgit2's own
`p_rename` (link-then-rename in `posix.c`) can't overwrite the
`config`/`refs`/`HEAD`/`index` files its lock→commit sequence depends on. Ours
drops the target then renames; `posix.c`'s original is compiled under a
throwaway name via a file-scoped CMake `COMPILE_DEFINITIONS`, so ours is the
`p_rename` every caller links. (Not crash-atomic, but FAT offers no atomic
replace — acceptable for the working copy.) Verified on hardware: cleared the
`failed to rename lockfile to '.git/config'` error.
3. **`utimes` existence-gate — the killer.** This one silently defeated every
object write. Our first `utimes` stub returned `0` unconditionally ("VFS can't
set times; ignore"). But libgit2's `git_futils_touch()``p_utimes()` is how
the loose ODB's `freshen` probe answers *"does this object already exist?"*,
and `git_odb_write()` (`odb.c:1629`) **skips the write entirely** when freshen
succeeds. So a blanket `return 0` made freshen always report "exists" →
libgit2 believed every object was already on disk → **every** blob/tree/commit
write was silently dropped. `.git/objects/` stayed empty (only `info/` +
`pack/`), and `write_tree` failed with `invalid object specified - device.md`.
Fix: `stat`-gate the stub — present → `0` (setting the time is a cosmetic
no-op we skip), absent → `-1`/`ENOENT`, so freshen correctly reports "not
found" and the real write proceeds.
Diagnosed with an in-binary A/B/C/D ODB probe — write an in-memory blob, a
file blob, run `add_all`, then walk `.git/objects` — which showed `exists =
false` for every OID and an empty objects dir, isolating it to the *write*
path (not read, not mmap, not the index). The vendored `odb.c` /
`odb_loose.c` / `futils.c` source then pinned it to the freshen→touch→utimes
chain. **Lesson:** a "harmless" no-op POSIX stub is actively dangerous when a
caller reads its return value as a semantic signal.
### Shortcuts still standing (retire before product)
- **Cert verification is bypassed.** libgit2's mbedTLS stream has no CA wired in
(`GIT_DEFAULT_CERT_LOCATION` NULL + `VERIFY_OPTIONAL`), so the
`certificate_check` callback accepts the peer cert with a WARN — MITM-open.
Real trust needs either `GIT_OPT_SET_SSL_CERT_LOCATIONS` → an embedded CA PEM
on FAT, or validating the presented chain against esp-idf's cert bundle inside
the callback.
- **PAT baked into flash** (ADR-005 spike shortcut). `build.rs` embeds `TW_PAT`
in the git_push image via `env!()`. A product must not ship the token in flash.
- **Product remote is SSH, on-device is HTTPS-only.** The spike pushes to a
throwaway HTTPS repo with a PAT; the real project remote is
`git@github.com:jcalixte/typewriter.git` (SSH), and on-device libgit2 has **no
SSH transport** (mbedTLS-only build; no ssh client, libssh2 unported). Folding
this into the editor's `git` module needs the sync transport settled first — an
HTTPS+PAT product remote, a libssh2 port, or another mechanism. ADR-level, not
wiring.
## Artifacts (this session)