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

@@ -352,9 +352,13 @@ impl Snippets {
/// display name, each value `{ prefix, body, description? }` where `body` is a
/// string or an array of lines. Labels in `${n:label}` stops are stripped to
/// `$n`. Entries come back sorted by name (a `BTreeMap` parse — deterministic,
/// and the empty-`$` palette order; a query re-ranks by fuzzy score anyway). A
/// malformed file is an `Err` the host logs before booting with no snippets.
/// and the empty-`$` palette order; a query re-ranks by fuzzy score anyway). An
/// empty (or whitespace-only) file means "no snippets", same as a missing one —
/// only a malformed file is an `Err` the host logs before booting with none.
pub fn parse(src: &str) -> Result<Self, serde_json::Error> {
if src.trim().is_empty() {
return Ok(Self::default());
}
let raw: std::collections::BTreeMap<String, RawSnippet> = serde_json::from_str(src)?;
let snippets = raw
.into_iter()
@@ -3166,11 +3170,25 @@ impl Editor {
/// text (e.g. a boot message). View never draws a caret. In the main loop
/// Normal always passes `true`, so its block caret is unaffected.
pub fn draw(&mut self, cursor_on: bool) -> Frame {
let mut f = Frame::empty();
self.draw_into(&mut f, cursor_on);
f
}
/// [`draw`](Self::draw) into a caller-owned frame, reusing its allocation.
/// Firmware keeps two boot-time frames and round-trips them through here so
/// a repaint never allocates: the editor must stay drawable even when a
/// background `:sync` push has taken the heap to the floor — a failed
/// framebuffer alloc aborts the whole app (the 2026-07-13 OOM).
pub fn draw_into(&mut self, out: &mut Frame, cursor_on: bool) {
let lay = self.layout();
let (crow, ccol) = self.caret_rc(&lay);
self.adjust_scroll(crow, lay.len());
let mut f = Frame::new_white();
// Take the caller's buffer (no copy, no alloc), render into it as an
// owned frame — the body predates this signature — and hand it back.
let mut f = std::mem::replace(out, Frame::empty());
f.clear_white();
let text_style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On);
let gutter = self.gutter_cols();
let cols = WRITE_COLS - gutter; // text columns after the gutter
@@ -3335,7 +3353,7 @@ impl Editor {
if self.prefs.theme == "dark" {
f.invert();
}
f
*out = f;
}
/// Draw the side panel: a full-height rule, word count at the top, and the
@@ -5910,6 +5928,8 @@ mod tests {
#[test]
fn parse_snippets_empty_and_malformed() {
assert!(Snippets::parse("{}").unwrap().0.is_empty());
assert!(Snippets::parse("").unwrap().0.is_empty()); // empty file = no snippets
assert!(Snippets::parse(" \n\t").unwrap().0.is_empty()); // whitespace-only too
assert!(Snippets::parse("{ not json").is_err()); // host logs, boots with none
}