Progress / Log Channel Pattern
June 2, 2026 · View on GitHub
This document codifies how long-running operations report progress, log messages, errors, and cancellation back to the GUI.
TL;DR
Two layers, on purpose:
- Leaf I/O functions (anything in
rbformats/,backup::run_backup,restore::*,fs::partition_minimum_size,fs::resize_*) take callbacks:progress_cb,cancel_check,log_cb. They never depend on aStatustype. - Runner / orchestrator functions (everything in
model/*_runner.rs, plus the per-tab worker spawns still inline ingui/) ownArc<Mutex<XxxStatus>>. They spawn the worker thread, install closures that translate callback arguments intoStatusfield writes, and return theArcto the GUI. - GUI polls the
Statusonce per frame, drainslog_messagesintoLogPanel, snapshots scalars for rendering, and clears the handle whenfinished.
This is the dominant pattern already. We considered migrating to typed mpsc
channels and rejected it (see "Why not mpsc?" below).
Status struct conventions
All Status structs live in src/model/status.rs (or, for ones whose worker is
also in model/, alongside their runner — e.g. min_size_runner::MinSizeStatus,
archive_edit::ArchiveEditProgress).
Required fields:
| Field | Type | Meaning |
|---|---|---|
finished | bool | Worker has stopped; GUI should clear the handle. |
error | Option<String> | Set when the worker exited via Err. Display once and clear the handle. |
Conditional fields (use the standard names below — don't invent new ones):
| Field | Type | Use when |
|---|---|---|
cancel_requested | bool | Cancellation is supported. GUI sets it; worker polls it via the cancel callback. |
current_bytes / total_bytes | u64 / u64 | Numeric progress with a known total. |
current_step | String | Phase label (e.g. "Reading partition table…"). Use instead of bytes when there's no meaningful denominator, or alongside bytes for a sub-phase tag. |
log_messages | Vec<String> or Vec<(Level, String)> | Worker emits human-readable log lines. GUI drains each frame. |
| Result fields | typed | Populated only on success; the GUI consumes them when finished && error.is_none(). Examples: partition_table, clone_report, cache_path. |
Naming: XxxStatus for terminal worker state, XxxProgress only when the
struct is also exposed by name through callbacks (legacy: BackupProgress,
RestoreProgress, ExtractionProgress, ArchiveEditProgress). New runners
should default to XxxStatus.
Lock discipline
- Worker holds the lock only while writing one batch of fields. Never run I/O or compute while holding the lock.
- The cancel-check callback locks, reads
cancel_requested, and drops the lock — one-shot. - The progress and log callbacks lock, write, drop. They are invoked from inside the leaf I/O loop, so keep the critical section tiny.
- GUI poll:
let Ok(mut s) = arc.lock() else { return; };thendrain(..)the log buffer, copy out scalars, checkfinished. Never hold the lock across rendering.
Concretely, the standard worker shape is:
let status = Arc::new(Mutex::new(XxxStatus { /* defaults */ }));
let status_thread = Arc::clone(&status);
std::thread::spawn(move || {
let progress_status = Arc::clone(&status_thread);
let cancel_status = Arc::clone(&status_thread);
let log_status = Arc::clone(&status_thread);
let result = leaf_io_function(
/* … */,
move |bytes_done| {
if let Ok(mut s) = progress_status.lock() {
s.current_bytes = bytes_done;
}
},
move || cancel_status.lock().map(|s| s.cancel_requested).unwrap_or(false),
move |msg| {
if let Ok(mut s) = log_status.lock() {
s.log_messages.push(msg.to_string());
}
},
);
if let Ok(mut s) = status_thread.lock() {
s.finished = true;
if let Err(e) = result {
s.error = Some(format!("{e:#}"));
}
}
});
status
The matching GUI poll:
fn poll_xxx(&mut self, ctx: &mut TabContext) {
let Some(arc) = self.xxx_status.as_ref().map(Arc::clone) else { return };
let Ok(mut s) = arc.lock() else { return };
for msg in s.log_messages.drain(..) {
ctx.log.info(msg);
}
let progress = (s.current_bytes, s.total_bytes);
let finished = s.finished;
let error = s.error.take();
drop(s);
/* … render `progress`, surface `error`, etc. … */
if finished {
self.xxx_status = None;
}
}
Use mem::take / Option::take when extracting result fields so the next poll
doesn't see them again.
Why not mpsc?
Considered: a typed mpsc::Sender<XxxEvent> per worker, with the GUI draining a
receiver each frame.
Rejected because:
- All leaf I/O already uses callbacks (
rbformats::compress_partition,rbformats::export::*,backup::run_backup,restore::*,fs::resize_*,fs::partition_minimum_size, …). Migrating to mpsc means either rewriting every leaf to take aSender(loses CLI/test usability) or wrapping each one with aSender → callbackadapter — same code shape, more boilerplate. - Lock contention isn't a measured problem. GUI polls at frame rate (~16 ms); workers write to the lock from inside an I/O loop where each callback fires every few MB. Critical sections are microseconds.
- Status structs serve as a snapshot. Many fields aren't event-shaped
(e.g.
partition_min_sizes: HashMap<usize, u64>accumulates gradually; mpsc would require an "upsert" event variant per such field). - mpsc adds a failure mode (sender dropped →
SendError) without removing any existing one (lock poisoning is rarer in practice and we already handle it withif let Ok(s) = arc.lock()).
The decision can be revisited if we ever:
- Need to multiplex one worker's events into multiple consumers (e.g. CLI + GUI + log file). The current pattern handles this by having multiple callbacks on the leaf function — works fine for now.
- Hit measurable contention. Profile first.
Existing runners
These follow the convention and are the reference implementations:
model::min_size_runner—MinSizeStatus. Phase strings, no bytes.model::archive_edit—ArchiveEditProgress. Bytes + cancel.model::export_runner—ExportStatus. Bytes + cancel + log + three start fns.model::fsck_runner— synchronous; no Status (returnsResultdirectly).
Per-tab spawns still inline in gui/ (acceptable, but should follow the
field-naming + lock-discipline rules above; promote to a *_runner.rs module
when convenient):
| Site | Status type |
|---|---|
gui/inspect_tab.rs (disk inspect, seekable cache, block-cache scan) | InspectStatus, CacheStatus, BlockCacheScan |
gui/backup_tab.rs (run_backup, VHD whole-disk export) | BackupProgress, VhdExportStatus |
gui/restore_tab.rs (full restore, single-partition, new-disk) | RestoreProgress |
gui/resize_popup.rs (in-place resize) | ResizeStatus |
gui/browse_view.rs (file extraction) | ExtractionProgress |
gui/expand_hfs_dialog.rs (HFS clone + APM emit) | ExpandStatus |
gui/bulk_convert_dialog.rs (folder bulk-convert) | BulkConvertStatus |
gui/optical_tab.rs (optical dump) | tab-internal types |
When adding a new long-running operation
- Define
XxxStatusinsrc/model/status.rswithfinished+errorplus whatever standard fields apply. Keep all fieldspub. - Either:
- Put the spawn-and-poll glue in a new
model/xxx_runner.rs(preferred), or - Inline the spawn in the GUI tab if the tab is the only consumer. Follow the worker-shape template above; resist inventing field names.
- Put the spawn-and-poll glue in a new
- Leaf I/O takes callbacks, not
Arc<Mutex<…>>. The runner is the bridge. - The GUI tab's
show()callspoll_xxx(ctx)once per frame before rendering.
Migration / lint targets
These are the concrete things this doc codifies (none currently violate it, but listing for future regressions):
- A worker that writes to
LogPaneldirectly via a captured reference instead of pushing tolog_messages— would block the GUI thread on any contended lock, and breaks the "no GUI types in workers" rule. - A leaf I/O function that takes
Arc<Mutex<XxxStatus>>instead of callbacks — forces every caller (CLI tools, tests, future remote runners) to fabricate a Status just to get progress. - A
Statusstruct with non-pubfields. Workers writing to it from another module need full access; encapsulation isn't valuable here.