From 3009dc4d9cdeca89fc3f0b4f3ff7c9c4df217d6c Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Tue, 14 Jul 2026 01:34:28 +0200 Subject: [PATCH] feat(boot): async splash refresh + background palette file walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The splash's ~2.2s full-refresh waveform now runs while the SD mounts and the note/prefs load (Epd::display_frame_async + wait_ready guard on every public display call), and the 4.3s palette walk moves to a 16KB background thread whose result lands in the idle branch — the first editor frame no longer waits on non-mandatory work. The post-pull re-walk rides the same channel, removing a 4.3s UI stall after :gl. Also: rustfmt pass on sd_bench, drop the spike-9 log line in splash. --- firmware/src/bin/sd_bench.rs | 163 +++++++++++++++++++++-------------- firmware/src/bin/splash.rs | 1 - firmware/src/epd.rs | 44 +++++++++- firmware/src/main.rs | 105 ++++++++++++++++------ 4 files changed, 222 insertions(+), 91 deletions(-) diff --git a/firmware/src/bin/sd_bench.rs b/firmware/src/bin/sd_bench.rs index 388d4c9..4f0537d 100644 --- a/firmware/src/bin/sd_bench.rs +++ b/firmware/src/bin/sd_bench.rs @@ -59,7 +59,10 @@ fn main() -> Result<()> { fn run() -> Result<()> { let sd = Storage::mount().context("mounting SD")?; let (max_khz, real_khz) = sd.negotiated_khz(); - log::info!("bus: max {max_khz} kHz, negotiated {real_khz} kHz — {N} iters, {}-byte payload", PAYLOAD.len()); + log::info!( + "bus: max {max_khz} kHz, negotiated {real_khz} kHz — {N} iters, {}-byte payload", + PAYLOAD.len() + ); // Fresh scratch dir. let _ = fs::remove_dir_all(BENCH_DIR); @@ -73,49 +76,66 @@ fn run() -> Result<()> { // 1) create + write(200B) + close, a fresh unique file each time. The drop at // the block's end is the close (FatFS f_close flushes dir entry + data). - summarize("create+write(200B)+close", time_each(|i| { - let mut f = File::create(format!("{BENCH_DIR}/c{i}"))?; - f.write_all(&PAYLOAD)?; - Ok(()) - })?); + summarize( + "create+write(200B)+close", + time_each(|i| { + let mut f = File::create(format!("{BENCH_DIR}/c{i}"))?; + f.write_all(&PAYLOAD)?; + Ok(()) + })?, + ); // 2) rename c{i} -> o{i}. Sources exist from step 1 (untimed setup). - summarize("rename", time_each(|i| { - fs::rename(format!("{BENCH_DIR}/c{i}"), format!("{BENCH_DIR}/o{i}")) - .map_err(Into::into) - })?); + summarize( + "rename", + time_each(|i| { + fs::rename(format!("{BENCH_DIR}/c{i}"), format!("{BENCH_DIR}/o{i}")).map_err(Into::into) + })?, + ); // 3) stat, hit. - summarize("stat (hit)", time_each(|i| { - fs::metadata(format!("{BENCH_DIR}/o{i}")).map(|_| ()).map_err(Into::into) - })?); + summarize( + "stat (hit)", + time_each(|i| { + fs::metadata(format!("{BENCH_DIR}/o{i}")) + .map(|_| ()) + .map_err(Into::into) + })?, + ); // 4) stat, miss (ENOENT) — the freshen-probe analogue. A read, expected cheap. - summarize("stat (miss/ENOENT)", time_each(|i| { - let _ = fs::metadata(format!("{BENCH_DIR}/nope{i}")); - Ok(()) - })?); + summarize( + "stat (miss/ENOENT)", + time_each(|i| { + let _ = fs::metadata(format!("{BENCH_DIR}/nope{i}")); + Ok(()) + })?, + ); // 5) remove o{i}. - summarize("remove", time_each(|i| { - fs::remove_file(format!("{BENCH_DIR}/o{i}")).map_err(Into::into) - })?); + summarize( + "remove", + time_each(|i| fs::remove_file(format!("{BENCH_DIR}/o{i}")).map_err(Into::into))?, + ); // 6) Composite: the exact loose-object write sequence libgit2 performs, with a // git-length (38-hex) final name so LFN directory-entry cost is included. // If the model is right this lands near the ~700 ms/object from the split. - summarize("loose-object composite", time_each(|i| { - let tmp = format!("{BENCH_DIR}/tmp_obj{i}"); - let fin = format!("{BENCH_DIR}/{i:038x}"); - let _ = fs::metadata(&fin); // freshen probe, misses - { - let mut f = File::create(&tmp)?; // temp create + write + close - f.write_all(&PAYLOAD)?; - } - let _ = fs::remove_file(&fin); // p_rename's remove(to) — ENOENT - fs::rename(&tmp, &fin)?; // temp -> final - Ok(()) - })?); + summarize( + "loose-object composite", + time_each(|i| { + let tmp = format!("{BENCH_DIR}/tmp_obj{i}"); + let fin = format!("{BENCH_DIR}/{i:038x}"); + let _ = fs::metadata(&fin); // freshen probe, misses + { + let mut f = File::create(&tmp)?; // temp create + write + close + f.write_all(&PAYLOAD)?; + } + let _ = fs::remove_file(&fin); // p_rename's remove(to) — ENOENT + fs::rename(&tmp, &fin)?; // temp -> final + Ok(()) + })?, + ); // 6b) Directory-entry scaling — the ~360 ms/loose-write residual suspect // (2026-07-13, post-FASTSEEK; see sync-commit-staging.md). FAT has no @@ -132,25 +152,36 @@ fn run() -> Result<()> { for j in 0..n { File::create(format!("{dir}/e{j:04}"))?; // sibling entries (untimed setup) } - summarize(&format!("stat hit, {n:>3} siblings"), time_each(|i| { - fs::metadata(format!("{dir}/e{:04}", i % n)).map(|_| ()).map_err(Into::into) - })?); - summarize(&format!("stat miss, {n:>3} siblings"), time_each(|i| { - let _ = fs::metadata(format!("{dir}/nope{i}")); - Ok(()) - })?); - summarize(&format!("loose composite, {n:>3} sib"), time_each(|i| { - let tmp = format!("{dir}/tmp_obj{i}"); - let fin = format!("{dir}/{i:038x}"); - let _ = fs::metadata(&fin); // freshen probe, misses - { - let mut f = File::create(&tmp)?; - f.write_all(&PAYLOAD)?; - } - let _ = fs::remove_file(&fin); // p_rename's remove(to) — ENOENT - fs::rename(&tmp, &fin)?; - Ok(()) - })?); + summarize( + &format!("stat hit, {n:>3} siblings"), + time_each(|i| { + fs::metadata(format!("{dir}/e{:04}", i % n)) + .map(|_| ()) + .map_err(Into::into) + })?, + ); + summarize( + &format!("stat miss, {n:>3} siblings"), + time_each(|i| { + let _ = fs::metadata(format!("{dir}/nope{i}")); + Ok(()) + })?, + ); + summarize( + &format!("loose composite, {n:>3} sib"), + time_each(|i| { + let tmp = format!("{dir}/tmp_obj{i}"); + let fin = format!("{dir}/{i:038x}"); + let _ = fs::metadata(&fin); // freshen probe, misses + { + let mut f = File::create(&tmp)?; + f.write_all(&PAYLOAD)?; + } + let _ = fs::remove_file(&fin); // p_rename's remove(to) — ENOENT + fs::rename(&tmp, &fin)?; + Ok(()) + })?, + ); } // Clean up so the card is left as we found it. @@ -177,21 +208,27 @@ fn run() -> Result<()> { let mut f = File::open(&pack).with_context(|| format!("opening {pack}"))?; let mut buf = vec![0u8; 4096]; // Baseline: rewind + read at the chain head — no walk to resolve. - summarize("pack seek+read 4KB @start", time_each(|_| { - f.seek(SeekFrom::Start(0))?; - f.read_exact(&mut buf)?; - Ok(()) - })?); + summarize( + "pack seek+read 4KB @start", + time_each(|_| { + f.seek(SeekFrom::Start(0))?; + f.read_exact(&mut buf)?; + Ok(()) + })?, + ); // Rewind (cheap, measured above), then seek near the end — pays // one full cluster-chain walk per iteration if fast-seek is off. let high = len - 4096; - summarize("pack seek+read 4KB @end", time_each(|_| { - f.seek(SeekFrom::Start(0))?; - f.read_exact(&mut buf)?; - f.seek(SeekFrom::Start(high))?; - f.read_exact(&mut buf)?; - Ok(()) - })?); + summarize( + "pack seek+read 4KB @end", + time_each(|_| { + f.seek(SeekFrom::Start(0))?; + f.read_exact(&mut buf)?; + f.seek(SeekFrom::Start(high))?; + f.read_exact(&mut buf)?; + Ok(()) + })?, + ); } } None => log::info!("no packfile under /sd/repo/.git/objects/pack — skipping seek bench"), diff --git a/firmware/src/bin/splash.rs b/firmware/src/bin/splash.rs index d9ba5f0..bcbb5d2 100644 --- a/firmware/src/bin/splash.rs +++ b/firmware/src/bin/splash.rs @@ -63,7 +63,6 @@ fn main() -> anyhow::Result<()> { log::info!("painting splash…"); epd.display_frame(Frame::splash().bytes())?; - log::info!("✅ Spike 9 complete — splash on panel"); // Idle so the splash stays up and the result stays on the monitor. loop { diff --git a/firmware/src/epd.rs b/firmware/src/epd.rs index 05b0d0d..1031fb5 100644 --- a/firmware/src/epd.rs +++ b/firmware/src/epd.rs @@ -37,6 +37,10 @@ pub struct Epd<'d> { rst: PinDriver<'d, Output>, cs: PinDriver<'d, Output>, busy: PinDriver<'d, Input>, + /// A refresh kicked off by `display_frame_async` whose waveform may still + /// be running. Every public display call waits it out (`wait_ready`) + /// before sending further controller traffic. + refresh_pending: bool, } impl<'d> Epd<'d> { @@ -47,7 +51,17 @@ impl<'d> Epd<'d> { cs: PinDriver<'d, Output>, busy: PinDriver<'d, Input>, ) -> Self { - Self { spi, dc, rst, cs, busy } + Self { spi, dc, rst, cs, busy, refresh_pending: false } + } + + /// Wait out a refresh started by `display_frame_async`, if one is still + /// running. Safe to call anytime; a no-op when nothing is pending. + pub fn wait_ready(&mut self) -> Result<(), EspError> { + if self.refresh_pending { + self.wait_while_busy(2500)?; // full_refresh_time ≈ 2200 ms + self.refresh_pending = false; + } + Ok(()) } // ---- low-level SPI framing (DC low = command, DC high = data) ---- @@ -205,6 +219,15 @@ impl<'d> Epd<'d> { /// Port of GxEPD2 `refresh(false)` → `_Update_Full` (fast full update). fn update_full(&mut self) -> Result<(), EspError> { + self.kick_update_full()?; + self.wait_while_busy(2500)?; // full_refresh_time ≈ 2200 ms + Ok(()) + } + + /// The command half of `update_full`: starts the full-refresh waveform + /// (~2.2 s) and returns while it runs. The caller owns the eventual BUSY + /// wait before any further controller traffic. + fn kick_update_full(&mut self) -> Result<(), EspError> { self.set_ram_area(0, 0, WIDTH / 2, HEIGHT, 0x03, 0x80)?; // slave self.set_ram_area(0, 0, WIDTH / 2, HEIGHT, 0x03, 0x00)?; // master self.cmd(0x21)?; // display update control 1 @@ -214,7 +237,6 @@ impl<'d> Epd<'d> { self.cmd(0x22)?; self.data(&[0xD7])?; // fast full update self.cmd(0x20)?; // master activation - self.wait_while_busy(2500)?; // full_refresh_time ≈ 2200 ms Ok(()) } @@ -247,6 +269,7 @@ impl<'d> Epd<'d> { /// Fill the whole panel with one value and full-refresh. /// `0xFF` = white, `0x00` = black. Port of GxEPD2 `clearScreen`. pub fn clear_screen(&mut self, value: u8) -> Result<(), EspError> { + self.wait_ready()?; self.write_buffer(0x26, value)?; // previous self.write_buffer(0x24, value)?; // current self.update_full()?; @@ -288,12 +311,28 @@ impl<'d> Epd<'d> { /// consistent "previous" image. pub fn display_frame(&mut self, fb: &[u8]) -> Result<(), EspError> { assert_eq!(fb.len(), FB_BYTES, "framebuffer must be 99 x 272 bytes"); + self.wait_ready()?; self.write_frame_bank(0x26, fb, 0, HEIGHT)?; // previous self.write_frame_bank(0x24, fb, 0, HEIGHT)?; // current self.update_full()?; Ok(()) } + /// `display_frame` minus the wait: writes both RAM banks, starts the + /// full-refresh waveform (~2.2 s), and returns immediately so the caller + /// can do other work (SD mount, note load) while the panel paints itself. + /// Every public display call waits out the pending refresh (`wait_ready`) + /// before its own controller traffic, so nothing can collide with it. + pub fn display_frame_async(&mut self, fb: &[u8]) -> Result<(), EspError> { + assert_eq!(fb.len(), FB_BYTES, "framebuffer must be 99 x 272 bytes"); + self.wait_ready()?; + self.write_frame_bank(0x26, fb, 0, HEIGHT)?; // previous + self.write_frame_bank(0x24, fb, 0, HEIGHT)?; // current + self.kick_update_full()?; + self.refresh_pending = true; + Ok(()) + } + /// Partial-refresh only rows `y0..y0+h` of the panel from a full /// framebuffer — the fast per-keystroke path (pass `(0, HEIGHT)` for the /// whole panel). Requires the `0x26` (previous) bank to already hold the @@ -310,6 +349,7 @@ impl<'d> Epd<'d> { ) -> Result<(), EspError> { assert_eq!(fb.len(), FB_BYTES, "framebuffer must be 99 x 272 bytes"); assert!(h > 0 && y0 + h <= HEIGHT, "row window out of range"); + self.wait_ready()?; self.write_frame_bank(0x24, fb, y0, h)?; // current = new self.update_part(y0, h)?; // transition previous (0x26) -> current (0x24) self.write_frame_bank(0x26, fb, y0, h)?; // previous = new, for next time diff --git a/firmware/src/main.rs b/firmware/src/main.rs index 24fb377..4cf84fc 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -65,11 +65,14 @@ fn main() -> anyhow::Result<()> { log::info!("EPD reset + init…"); epd.reset()?; epd.init()?; - // Boot splash (Spike 9): the Typoena mark, shown while the SD mounts and the - // note loads below. Its full refresh doubles as the baseline the old white - // clear used to establish (writes both RAM banks); the editor's first render - // further down cleanly replaces it with a second full refresh. - epd.display_frame(Frame::splash().bytes())?; + // Boot splash (Spike 9): the Typoena mark, kicked off *async* — the ~2.2 s + // full-refresh waveform runs while the SD mounts and the note loads below, + // so the splash starts painting as early as the app can drive it and its + // wait overlaps the mandatory boot work instead of preceding it. Its full + // refresh doubles as the baseline the old white clear used to establish + // (writes both RAM banks); the first editor render further down implicitly + // waits it out (`wait_ready`) and then replaces it. + epd.display_frame_async(Frame::splash().bytes())?; // Mount the SD and load the saved note. We bring the SD up *after* the EPD — // the doc's boot order is SD-first, but a dead panel can't explain a missing @@ -78,6 +81,15 @@ fn main() -> anyhow::Result<()> { // the next `:w`. See docs/v0.1-mvp-technical.md, boot sequence. let (storage, saved) = boot_storage(&mut epd); + // Feed the file palette (Ctrl-P) from a background walk. Enumerating + // /sd/repo + /sd/local takes seconds on a big tree (4.3 s at 1098 files, + // readdir-over-SPI bound) and the palette is not needed to type, so it + // must not hold up the first editor frame. The list lands on `walk_rx` and + // the idle branch of the main loop feeds it to the editor; until then the + // palette shows recents only. A pull re-feeds it the same way. + let (walk_tx, walk_rx) = std::sync::mpsc::channel::>(); + spawn_file_walk(walk_tx.clone()); + // Bring up the USB keyboard in the background; keys arrive via next_key(). usb_kbd::start()?; @@ -121,20 +133,6 @@ fn main() -> anyhow::Result<()> { .and_then(|s| s.to_str()) .unwrap_or("notes"); ed.set_notice(format!("loaded {name}")); - // Feed the file palette (Ctrl-P). Enumerated once at boot — the v0.5 slices - // that create/delete files (`:enew`, delete) will re-feed it then. - // Bracketed with internal-DRAM readings: each path is a small String, kept - // internal by the SPIRAM malloc threshold (16 KB), so the list competes - // with Wi-Fi/TLS for DRAM. Estimate to confirm: ~60-70 KB at 1098 files — - // this number decides whether interning the paths into one shared buffer - // (a single >16 KB alloc, which lands in PSRAM) is worth the refactor. - let dram_before = internal_free_heap(); - ed.set_file_list(enumerate_files()); - let dram_after = internal_free_heap(); - log::info!( - "file list: internal heap {dram_before} -> {dram_after} ({} KB consumed)", - dram_before.saturating_sub(dram_after) / 1024 - ); // Editor preferences (.typoena.toml, git-tracked). Read before the first // render so `line_numbers` shapes the opening frame. A missing / unreadable / // partial file falls back to defaults, so a fresh card just works. @@ -177,8 +175,12 @@ fn main() -> anyhow::Result<()> { ed.set_keyboard_present(last_kbd); ed.refresh_stats(); - // First editor render. The splash's full refresh above already seeded both - // RAM banks (its image is the `0x26` "previous" baseline), so the editor + // First editor render — the moment the splash disappears. Everything + // mandatory is ready here: SD mounted, note loaded, prefs applied, input + // running (the palette walk continues in the background). The splash's + // full refresh already seeded both RAM banks (its image is the `0x26` + // "previous" baseline) — the partial below first waits out its waveform + // (`wait_ready`), which the boot work above overlapped — so the editor // comes up with a full-area *partial* (~630 ms) instead of a second full // refresh (~1.9 s): the splash→editor swap rides the partial waveform, // shaving ~1.3 s off cold boot. This large-area partial is the one boot @@ -328,7 +330,9 @@ fn main() -> anyhow::Result<()> { // clean active buffer is re-read now, and a RAM-dirty // buffer is left alone — its edits win, last-writer- // wins like the publish reconcile. The palette list is - // re-walked for files the pull added or removed. + // re-walked in the background for files the pull added + // or removed (it lands on `walk_rx` a few seconds + // later, instead of stalling the UI for the walk). PullOutcome::Pulled(oid) => { ed.drop_clean_parked(); if ed.dirty() { @@ -345,7 +349,7 @@ fn main() -> anyhow::Result<()> { ), } } - ed.set_file_list(enumerate_files()); + spawn_file_walk(walk_tx.clone()); format!("pulled {oid}") } PullOutcome::UpToDate => "up to date".to_string(), @@ -365,6 +369,25 @@ fn main() -> anyhow::Result<()> { cursor_shown = true; continue; } + // A finished background file walk (boot or post-pull) feeds the + // palette. Repaint only if the visible frame changed — the list + // is only visible through the palette overlay, which is usually + // closed, and a no-op full-area partial would be a pointless + // ~630 ms panel drive. Caret visibility is passed through + // unchanged so this can't reveal a debounced Insert caret early. + if let Ok(files) = walk_rx.try_recv() { + ed.set_file_list(files); + ed.draw_into(&mut back, cursor_shown); + if changed_rows(shown.bytes(), back.bytes()).is_some() { + if let Err(e) = epd.display_frame_partial_window(back.bytes(), 0, epd::HEIGHT) { + log::warn!("palette repaint FAILED ({e}); full refresh next"); + force_full = true; + continue; + } + std::mem::swap(&mut shown, &mut back); + } + continue; + } // A connect/disconnect while idle must still repaint the panel flag — // no keystroke will arrive to trigger it otherwise. if kbd_changed { @@ -629,8 +652,9 @@ fn delete_buffer(storage: &Storage, ed: &mut Editor, path: String, scope: Scope) /// level (so `.git` and its thousands of object files, `.typoena.toml`, and the /// like never show or get walked). Best-effort: an unreadable directory (e.g. /// no `/sd/local` yet) contributes nothing rather than failing. The editor -/// sorts and dedupes. Runs once at boot, so the walk time is logged — on a big -/// repo the FAT directory IO is the cost to watch. +/// sorts and dedupes. Runs on the `walk` thread (`spawn_file_walk`), so the +/// walk time is logged — on a big repo the FAT directory IO is the cost to +/// watch (~4 ms/file over SPI). fn enumerate_files() -> Vec { let start = std::time::Instant::now(); let mut out = Vec::new(); @@ -641,6 +665,37 @@ fn enumerate_files() -> Vec { out } +/// Run [`enumerate_files`] on its own short-lived thread and send the result +/// over `tx`; the main loop's idle branch feeds it to the editor. Off the boot +/// path (and off the UI loop on a post-pull re-walk) because the walk takes +/// seconds on a big tree and the palette is not mandatory for typing. The +/// walk is pure directory reads, serialized against the editor's and the git +/// thread's SD traffic by the FatFS volume lock. Bracketed with internal-DRAM +/// readings: each path is a small String, kept internal by the SPIRAM malloc +/// threshold (16 KB), so the list competes with Wi-Fi/TLS for DRAM — the +/// logged number decides whether interning the paths into one shared buffer +/// (a single >16 KB alloc, which lands in PSRAM) is worth the refactor. +fn spawn_file_walk(tx: std::sync::mpsc::Sender>) { + // Explicit stack: the default pthread stack (4 KB) is tight for 8 levels + // of readdir recursion plus FatFS underneath. + let spawned = std::thread::Builder::new() + .name("walk".into()) + .stack_size(16 * 1024) + .spawn(move || { + let dram_before = internal_free_heap(); + let files = enumerate_files(); + let dram_after = internal_free_heap(); + log::info!( + "file list: internal heap {dram_before} -> {dram_after} ({} KB consumed)", + dram_before.saturating_sub(dram_after) / 1024 + ); + let _ = tx.send(files); // receiver gone = shutting down; nothing to do + }); + if let Err(e) = spawned { + log::warn!("file-walk thread spawn FAILED ({e}); palette list not refreshed"); + } +} + /// Depth bound for [`walk_files`] — belt-and-braces against pathological /// nesting on a hand-edited card; notes trees are a couple of levels deep. const WALK_MAX_DEPTH: usize = 8;