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:
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user