rsync progress: smooth byte-weighted bar via --info=progress2

- Use rsync --info=progress2 + --out-format=%n for device sync so the gauge
  shows byte-weighted overall progress instead of per-completed-file jumps
- Fix oscillation: starting a new file no longer resets the bar to 0% (set_current_file
  only updates the filename, never the percentage)
- Mirror step: per-file byte segments only update speed/ETA, not the overall pct
This commit is contained in:
Sebastian
2026-08-22 09:50:12 +02:00
parent cf2285fd56
commit 416776bdf4
2 changed files with 35 additions and 39 deletions
+21 -39
View File
@@ -147,6 +147,15 @@ impl WorkerCtx {
} }
self.changed(); self.changed();
} }
/// Update only the "currently syncing" file display without touching the
/// progress percentage (so a new file's start never resets the bar to 0).
fn set_current_file(&self, path: &str) {
if let Ok(mut s) = self.state.lock() {
s.device_prog.current = path.to_string();
}
self.changed();
}
} }
pub struct Engine { pub struct Engine {
@@ -345,6 +354,7 @@ fn run_mirror(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result<(), S
return stream(ctx, "rsync", &args, |line| { return stream(ctx, "rsync", &args, |line| {
if let Some(l) = parse_rsync_line(line) { if let Some(l) = parse_rsync_line(line) {
match l { match l {
// overall byte-weighted progress (progress2)
RsyncLine::Progress(p) => { RsyncLine::Progress(p) => {
if let Ok(mut s) = ctx.state.lock() { if let Ok(mut s) = ctx.state.lock() {
s.mirror.pct = p.pct; s.mirror.pct = p.pct;
@@ -355,9 +365,9 @@ fn run_mirror(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result<(), S
} }
true true
} }
// per-file byte segment: only speed/eta, never the pct
RsyncLine::ByteProgress(p) => { RsyncLine::ByteProgress(p) => {
if let Ok(mut s) = ctx.state.lock() { if let Ok(mut s) = ctx.state.lock() {
s.mirror.pct = p.pct;
s.mirror.speed = p.speed.clone(); s.mirror.speed = p.speed.clone();
s.mirror.eta = p.eta.clone(); s.mirror.eta = p.eta.clone();
} }
@@ -535,33 +545,28 @@ fn run_rsync_device(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result
"-t".to_string(), "-t".to_string(),
"--files-from".to_string(), "--files-from".to_string(),
tmp.clone(), tmp.clone(),
"--progress".to_string(), "--info=progress2".to_string(),
"--out-format=%n".to_string(),
"--exclude=.DS_Store".to_string(), "--exclude=.DS_Store".to_string(),
"--exclude=._*".to_string(), "--exclude=._*".to_string(),
format!("{}/", src_dir.display()), format!("{}/", src_dir.display()),
dest.display().to_string(), dest.display().to_string(),
]; ];
let total = to_copy.len() as u64; let total = to_copy.len() as u64;
let mut xfr_done: u64 = 0;
let mut cur_pct: u16 = 0;
stream(ctx, "rsync", &args, |line| { stream(ctx, "rsync", &args, |line| {
match parse_rsync_line(line) { match parse_rsync_line(line) {
// overall byte-weighted progress (progress2) — use directly
Some(RsyncLine::Progress(p)) => { Some(RsyncLine::Progress(p)) => {
xfr_done = p.done.max(1); let done = if p.done > 0 { p.done } else { total };
cur_pct = 100; ctx.set_device_progress(done, p.total.max(total), &p.speed, &p.eta, "", Some(p.pct));
let pct = smooth_pct(xfr_done, cur_pct, total);
ctx.set_device_progress(xfr_done, total, &p.speed, &p.eta, "", Some(pct));
true
}
Some(RsyncLine::ByteProgress(p)) => {
cur_pct = p.pct;
let pct = smooth_pct(xfr_done, cur_pct, total);
ctx.set_device_progress(xfr_done, total, &p.speed, &p.eta, "", Some(pct));
true true
} }
// live per-file byte segment: consumed, overall % comes from
// the progress2 line above
Some(RsyncLine::ByteProgress(_)) => true,
Some(RsyncLine::Total(_)) => true, Some(RsyncLine::Total(_)) => true,
Some(RsyncLine::File(path)) => { Some(RsyncLine::File(path)) => {
ctx.set_device_progress(0, total, "", "", &path, None); ctx.set_current_file(&path);
ctx.log(LogLevel::Transfer, path); ctx.log(LogLevel::Transfer, path);
true true
} }
@@ -626,16 +631,6 @@ fn write_file_list(items: &[String]) -> Result<String, String> {
// ── mlove finalize ──────────────────────────────────────────────────────────── // ── mlove finalize ────────────────────────────────────────────────────────────
/// Combine completed files with the current file's byte fraction for a smooth
/// overall percentage, e.g. 3 files done + file at 50% over 5 total → 70%.
fn smooth_pct(xfr_done: u64, cur_pct: u16, total: u64) -> u16 {
if total == 0 {
return 0;
}
let ratio = xfr_done as f64 + (cur_pct as f64 / 100.0);
((ratio / total as f64) * 100.0).clamp(0.0, 100.0) as u16
}
fn capture(cmd: &str, args: &[&str]) -> Result<(i32, String), String> { fn capture(cmd: &str, args: &[&str]) -> Result<(i32, String), String> {
let out = Command::new(cmd) let out = Command::new(cmd)
.args(args) .args(args)
@@ -811,18 +806,5 @@ where
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::smooth_pct; // engine logic is exercised through parser + integration tests
#[test]
fn smooth_progress_advances_within_a_file() {
// 3 files done, current at 50%, 5 total → (3 + 0.5)/5 = 70%
assert_eq!(smooth_pct(3, 50, 5), 70);
// 3 files done, current at 0% → 60%
assert_eq!(smooth_pct(3, 0, 5), 60);
// 1 file done, current at 100% → 40% (xfr#1 line arrives before next file)
assert_eq!(smooth_pct(1, 100, 5), 40);
// everything done
assert_eq!(smooth_pct(5, 100, 5), 100);
assert_eq!(smooth_pct(0, 100, 0), 0);
}
} }
+14
View File
@@ -256,4 +256,18 @@ mod tests {
other => panic!("expected byte progress, got {other:?}"), other => panic!("expected byte progress, got {other:?}"),
} }
} }
#[test]
fn parses_progress2_overall_pct() {
// --info=progress2 line: byte-weighted overall progress
match parse_rsync_line(" 209.715.200 66% 566,48MB/s 0:00:00 (xfr#1, to-chk=1/2)") {
Some(RsyncLine::Progress(p)) => {
assert_eq!(p.pct, 66);
assert_eq!(p.done, 1);
assert_eq!(p.total, 2);
assert_eq!(p.speed, "566,48MB/s");
}
other => panic!("expected progress, got {other:?}"),
}
}
} }