fix(sync): survive push heap exhaustion and instrument the push path

Run 4 (2026-07-13): remote.push() consumed ~6 MB of PSRAM over 66 s and
the UI thread aborted on Frame::new_white's per-draw vec![0xFF; 26928].
Run 5 confirmed the UI now survives, but the push still exhausts both
pools (a ~7 KB inflateInit fails inside the pack build) and the heap
telemetry never fired — hence the telemetry rework.

- display/editor: Editor::draw_into() renders into a caller-owned
  Frame; main.rs keeps two boot-time frames (shown/back) and mem::swaps
  on success, so steady-state repaints never allocate.
- git_sync: odb cache capped at 1 MB via raw libgit2-sys opts (git2
  0.20 doesn't wrap the total cap); log_push_heap at pre-push,
  post-push and push-failure with largest-PSRAM-block and per-pool
  min-evers; pack_progress logging is now time-gated (2 s) — the
  count gate (+256 objects) never fired because AddingObjects reports
  total=0 and a small push inserts only dozens of objects.
- editor: empty/whitespace snippets file parses as 0 snippets instead
  of a JSON error at boot.
- build.rs: refuse a git-feature build with unset TW_* publish vars —
  a bare `cargo build --features git` baked empty creds and produced a
  firmware whose :sync could never work.
This commit is contained in:
Julien Calixte
2026-07-13 21:04:18 +02:00
parent 05706478a2
commit f79d13453a
6 changed files with 182 additions and 23 deletions

View File

@@ -186,6 +186,13 @@ fn main() -> anyhow::Result<()> {
// (every FULL_REFRESH_EVERY updates) clears any residue.
let mut shown = ed.draw(true);
epd.display_frame_partial_window(shown.bytes(), 0, epd::HEIGHT)?;
// The only two framebuffers the loop ever uses, both allocated here at
// boot: every repaint below renders into `back` (`draw_into` reuses its
// allocation) and swaps it with `shown` on success. A repaint must never
// allocate — a background `:sync` push can take the heap to the floor, and
// a failed `Vec` alloc aborts the whole app (the 2026-07-13 OOM: 66 s into
// the push, one HalfPageUp repaint died on a 27 KB framebuffer).
let mut back = Frame::new_white();
// Boot-time measurement (the ≤ 5 s v0.1 / ≤ 3 s v1.0 target). Two clocks, and
// they disagree by ~1.4 s here, so report both. `esp_log_timestamp()` counts
@@ -292,26 +299,26 @@ fn main() -> anyhow::Result<()> {
UpToDate => "up to date".to_string(),
Failed(reason) => reason,
});
let f = ed.draw(true);
if let Err(e) = epd.display_frame_partial_window(f.bytes(), 0, epd::HEIGHT) {
ed.draw_into(&mut back, true);
if let Err(e) = epd.display_frame_partial_window(back.bytes(), 0, epd::HEIGHT) {
log::warn!("sync-notice repaint FAILED ({e}); full refresh next");
force_full = true;
continue;
}
shown = f;
std::mem::swap(&mut shown, &mut back);
cursor_shown = true;
continue;
}
// A connect/disconnect while idle must still repaint the panel flag —
// no keystroke will arrive to trigger it otherwise.
if kbd_changed {
let f = ed.draw(true);
if let Err(e) = epd.display_frame_partial_window(f.bytes(), 0, epd::HEIGHT) {
ed.draw_into(&mut back, true);
if let Err(e) = epd.display_frame_partial_window(back.bytes(), 0, epd::HEIGHT) {
log::warn!("kbd-flag repaint FAILED ({e}); full refresh next");
force_full = true;
continue;
}
shown = f;
std::mem::swap(&mut shown, &mut back);
cursor_shown = true;
log::info!("keyboard {}", if kbd { "connected" } else { "disconnected" });
continue;
@@ -348,12 +355,12 @@ fn main() -> anyhow::Result<()> {
&& last_activity.elapsed().as_millis() >= CURSOR_DEBOUNCE_MS
{
ed.refresh_stats();
let f = ed.draw(true);
if let Err(e) = epd.display_frame_partial_window(f.bytes(), 0, epd::HEIGHT) {
ed.draw_into(&mut back, true);
if let Err(e) = epd.display_frame_partial_window(back.bytes(), 0, epd::HEIGHT) {
log::warn!("caret repaint FAILED ({e}); full refresh next");
force_full = true;
} else {
shown = f;
std::mem::swap(&mut shown, &mut back);
cursor_shown = true;
log::info!("caret shown");
}
@@ -375,14 +382,13 @@ fn main() -> anyhow::Result<()> {
// and View render their caret regardless of this flag.
let insert_cursor_on = ed.mode() != Mode::Insert;
let prev_scroll = ed.scroll_top();
let frame = ed.draw(insert_cursor_on);
ed.draw_into(&mut back, insert_cursor_on);
let scrolled = ed.scroll_top() != prev_scroll;
// Only the rows that changed since the last shown frame need updating.
let Some((y0, y1)) = changed_rows(shown.bytes(), frame.bytes()) else {
shown = frame;
let Some((y0, y1)) = changed_rows(shown.bytes(), back.bytes()) else {
cursor_shown = ed.mode() != Mode::Insert;
continue; // no visible change
continue; // no visible change (the frames are identical — no swap needed)
};
// Snap the band to whole text lines so a partial-window boundary never
// lands mid-glyph — otherwise the boundary gate crops tall characters.
@@ -398,18 +404,18 @@ fn main() -> anyhow::Result<()> {
let periodic = updates % FULL_REFRESH_EVERY == 0;
let additive = ed.mode() == Mode::Insert
&& !scrolled
&& only_adds_ink(shown.bytes(), frame.bytes(), y0, y1);
&& only_adds_ink(shown.bytes(), back.bytes(), y0, y1);
let t0 = Instant::now();
// `force_full` promotes to a full refresh after a failed paint: it
// rewrites both RAM banks, recovering from a partial that may have died
// mid-transfer and desynced them.
let (result, refresh) = if periodic || force_full {
(epd.display_frame(frame.bytes()), "FULL")
(epd.display_frame(back.bytes()), "FULL")
} else if additive {
(epd.display_frame_partial_window(frame.bytes(), y0, y1 - y0 + 1), "windowed")
(epd.display_frame_partial_window(back.bytes(), y0, y1 - y0 + 1), "windowed")
} else {
(epd.display_frame_partial_window(frame.bytes(), 0, epd::HEIGHT), "full-area")
(epd.display_frame_partial_window(back.bytes(), 0, epd::HEIGHT), "full-area")
};
let ms = t0.elapsed().as_millis();
if let Err(e) = result {
@@ -428,7 +434,7 @@ fn main() -> anyhow::Result<()> {
"{refresh} refresh #{updates} [{:?}]: {ms} ms (rows {y0}..={y1}, {keys} key(s))",
ed.mode()
);
shown = frame;
std::mem::swap(&mut shown, &mut back);
cursor_shown = ed.mode() != Mode::Insert;
}
}