From c2e8d82cd35646b0a1def61c715e4127f9d48bec Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Fri, 30 Oct 2026 00:46:08 +0100 Subject: [PATCH] feat(progress): report uncounted status through the sink A run blocked on the cache-entry flock was silent: the wait went to the log sink, which renders nowhere by default, so the CLI read as a hang. The Progress trait gains message() - a status line outside the counted-operation protocol, defaulting to a no-op so existing sinks keep compiling - and the flock wait reports what it is waiting for. The CLI renders messages on the active bar and spins one up when no operation is active. --- SPEC.md | 5 +++-- crates/ecrou-cli/src/progress.rs | 20 ++++++++++++++++++++ crates/ecrou/src/cache.rs | 12 +++++++++--- crates/ecrou/src/progress.rs | 9 ++++++++- crates/ecrou/src/rootfs.rs | 3 ++- 5 files changed, 42 insertions(+), 7 deletions(-) diff --git a/SPEC.md b/SPEC.md index 89913f9..b572b6f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -18,8 +18,9 @@ propagates exit codes. The split is enforced by the dependency edges: the library links no clap and no rendering crate. It never writes to the terminal: diagnostics go through the `log` facade, filtered by `RunOptions::log_level` (an engine-owned enum, not UI booleans), long -operations report through the `Progress` sink, all output is the -CLI's. In-process failures come back as typed errors; the one +operations report through the `Progress` sink (sequential +`begin`/`advance`/`finish` operations plus uncounted `message` +status lines), all output is the CLI's. In-process failures come back as typed errors; the one exception is the forked machine child's last-gasp diagnostics, which land in the console file the parent streams. The CLI owns clap, the indicatif rendering of progress events, the log sink, and exit codes. diff --git a/crates/ecrou-cli/src/progress.rs b/crates/ecrou-cli/src/progress.rs index b448d0a..d9c61f0 100644 --- a/crates/ecrou-cli/src/progress.rs +++ b/crates/ecrou-cli/src/progress.rs @@ -88,4 +88,24 @@ impl Progress for TermProgress { bar.finish_and_clear(); } } + + fn message(&self, text: &str) { + let mut slot = self.bar.lock().unwrap(); + if slot.is_none() { + // No operation is active: the message becomes a spinner + // until the next `begin` replaces it. + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.green} {msg}") + .unwrap(), + ); + pb.set_message(text.to_string()); + *slot = Some(pb); + return; + } + if let Some(bar) = &*slot { + bar.set_message(text.to_string()); + } + } } diff --git a/crates/ecrou/src/cache.rs b/crates/ecrou/src/cache.rs index 35f205f..b9a8d7b 100644 --- a/crates/ecrou/src/cache.rs +++ b/crates/ecrou/src/cache.rs @@ -94,9 +94,11 @@ pub(crate) struct EntryLock { } impl EntryLock { - /// Block until the entry is free. `no_cache` callers skip the - /// lock entirely: an explicit fresh download never waits. - pub(crate) fn acquire(entry: &Path) -> anyhow::Result { + /// Block until the entry is free, telling the sink what the run + /// is waiting on (a silent block reads as a hang). `no_cache` + /// callers skip the lock entirely: an explicit fresh download + /// never waits. + pub(crate) fn acquire(entry: &Path, progress: &dyn Progress) -> anyhow::Result { use std::os::fd::AsRawFd; let lock_path = entry.with_extension("lock"); @@ -120,6 +122,10 @@ impl EntryLock { "waiting for another ecrou run to finish preparing {}", entry.display() ); + progress.message(&format!( + "Waiting for another ecrou run to finish preparing {}", + entry.display() + )); let held = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }; if held != 0 { return Err(anyhow::Error::from(std::io::Error::last_os_error()) diff --git a/crates/ecrou/src/progress.rs b/crates/ecrou/src/progress.rs index 58b3496..4bcbd66 100644 --- a/crates/ecrou/src/progress.rs +++ b/crates/ecrou/src/progress.rs @@ -19,7 +19,9 @@ pub enum Unit { /// /// Operations are sequential: `begin` starts the next operation (any /// previous one is implicitly over), `advance` counts completed units, -/// `finish` marks its end. Sinks must be callable from any thread. +/// `finish` marks its end. `message` reports uncounted status outside +/// that protocol — a wait, a decision, a retry — when there is nothing +/// to count. Sinks must be callable from any thread. pub trait Progress: Send + Sync { /// An operation labelled `label` starts, counting `unit`s; `total` /// is the unit count when known upfront. @@ -30,6 +32,11 @@ pub trait Progress: Send + Sync { /// The current operation ended. fn finish(&self); + + /// A status line outside the counted-operation protocol. The + /// default discards it; rendering sinks surface it as a spinner + /// message, starting one if no operation is active. + fn message(&self, _text: &str) {} } /// A [`Progress`] sink that discards every event. diff --git a/crates/ecrou/src/rootfs.rs b/crates/ecrou/src/rootfs.rs index d5cc4ad..516df33 100644 --- a/crates/ecrou/src/rootfs.rs +++ b/crates/ecrou/src/rootfs.rs @@ -88,7 +88,8 @@ impl RootfsCache { // One writer per entry: a concurrent run preparing the // same image waits here instead of downloading a second // copy the atomic rename would throw away. - let _entry_lock = EntryLock::acquire(&cache_path).map_err(Error::Other)?; + let _entry_lock = + EntryLock::acquire(&cache_path, progress).map_err(Error::Other)?; // Re-derive the freshness answer under the lock: a // competing run may have filled or refreshed the entry // while we waited.