feat(boot): async splash refresh + background palette file walk

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.
This commit is contained in:
Julien Calixte
2026-07-14 01:34:28 +02:00
parent 8c4866df1f
commit 3009dc4d9c
4 changed files with 222 additions and 91 deletions

View File

@@ -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"),

View File

@@ -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 {

View File

@@ -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

View File

@@ -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::<Vec<String>>();
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<String> {
let start = std::time::Instant::now();
let mut out = Vec::new();
@@ -641,6 +665,37 @@ fn enumerate_files() -> Vec<String> {
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<Vec<String>>) {
// 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;