feat: estimate the time remaining from bytes and observed rate
Build and publish container / build (pull_request) Successful in 2m13s

rsync reports each file's size with %l as it completes, which is all an
estimate needs: bytes done over time elapsed is the same arithmetic rsync would
do internally, and requires nothing it does not already print. The scan pass now
sums those sizes as well as counting files, so both a percentage and an estimate
have a real denominator.

The rate is measured over a trailing thirty seconds rather than the whole run,
so it follows a device that slows down instead of averaging the slowdown away --
which for a card reader that thermally throttles, or a USB link that renegotiates
after an hour, is the difference between a useful estimate and a reassuring one.

Below two seconds no rate is reported at all. The first handful of files arrive
microseconds apart, and dividing by that window produces a rate in the gigabytes
per second and an estimate of zero, which is worse than showing nothing.

Directory entries are excluded from the byte total as well as the file count.
rsync reports them with a 4096 inode size, which across six thousand album
directories is several megabytes of transfer that never happens.
This commit is contained in:
Emma Thorpe
2026-08-25 12:40:32 +01:00
parent 37b841f009
commit 131c80f5de
4 changed files with 219 additions and 27 deletions
+13 -4
View File
@@ -166,15 +166,24 @@ remove the Rockbox install.
Progress is a single line that rewrites itself: Progress is a single line that rewrites itself:
``` ```
[ 12,345 / 49,600 24%] King Gizzard & the Lizard Wizard / PetroDragonic Apoc [ 24%] 12,345/49,600 3.2 GiB/13.1 GiB 4.4 MiB/s ETA 38m12s King Gizzard / Petro
``` ```
The estimate comes from rsync's `%l`, which gives each file's size as it
completes. Bytes done over time elapsed is the same arithmetic rsync would do,
and needs nothing it does not already print. The rate is measured over a
trailing thirty seconds rather than the whole run, so it follows a device that
slows down instead of averaging the slowdown away — and it is suppressed
entirely for the first two seconds, where the window is microseconds wide and
would report gigabytes per second.
rsync says nothing at all while it builds its file list, which on fifty rsync says nothing at all while it builds its file list, which on fifty
thousand files over USB is minutes of apparent hang, and its own `progress2` thousand files over USB is minutes of apparent hang, and its own `progress2`
percentage is computed against a list it has not finished discovering. So the percentage is computed against a list it has not finished discovering. So the
script counts first — a second pass over the tree, which is what a percentage script counts first — files and bytes both, a second pass over the tree, which
that means something costs — and renders the rest itself. Piped to a log it is what a percentage and an estimate that mean something cost — and renders the
prints a plain line every thirty seconds instead, with no carriage returns. rest itself. Piped to a log it prints a plain line every thirty seconds
instead, with no carriage returns, and a summary at the end either way.
The unmount is the point of doing this in a script. FAT32 has no journal and The unmount is the point of doing this in a script. FAT32 has no journal and
the device is reached through disk mode, so an interrupted write is corruption the device is reached through disk mode, so an interrupted write is corruption
+82 -6
View File
@@ -19,9 +19,13 @@ class Terminal(io.StringIO):
return True return True
def run(lines, total=0, out=None): def run(lines, total=0, out=None, bytes_expected=0):
out = out or NotATerminal() out = out or NotATerminal()
rsync_progress.main(["--total", str(total)], stream=io.StringIO(lines), out=out) rsync_progress.main(
["--total", str(total), "--bytes", str(bytes_expected)],
stream=io.StringIO(lines),
out=out,
)
return out.getvalue() return out.getvalue()
@@ -40,15 +44,15 @@ def test_directories_are_not_counted():
"""rsync reports them too, and counting them puts the percentage past 100.""" """rsync reports them too, and counting them puts the percentage past 100."""
output = run("Artist/\nArtist/Album/\nArtist/Album/track.mp3\n", total=1) output = run("Artist/\nArtist/Album/\nArtist/Album/track.mp3\n", total=1)
assert "1 / 1" in output assert "1/1" in output
assert "100%" in output assert "100%" in output
def test_the_percentage_tracks_the_total(): def test_the_percentage_tracks_the_total():
output = run("".join(f"A/B/{i}.mp3\n" for i in range(5)), total=10) output = run("".join(f"A/B/{i}.mp3\n" for i in range(5)), total=10)
assert "5 / 10" in output assert "5/10" in output
assert " 50%" in output assert "50%" in output
def test_without_a_total_it_counts_instead_of_guessing(): def test_without_a_total_it_counts_instead_of_guessing():
@@ -64,7 +68,7 @@ def test_a_final_line_is_always_printed():
output = run("A/B/one.mp3\n", total=1) output = run("A/B/one.mp3\n", total=1)
assert output.endswith("\n") assert output.endswith("\n")
assert "1 / 1" in output assert "1/1" in output
def test_nothing_transferred_still_reports(): def test_nothing_transferred_still_reports():
@@ -102,3 +106,75 @@ def test_long_labels_are_trimmed_from_the_left(text, width, expected):
if len(text) > width: if len(text) > width:
assert trimmed.startswith("") assert trimmed.startswith("")
assert text.endswith(trimmed.lstrip("")) assert text.endswith(trimmed.lstrip(""))
def test_the_size_and_path_are_parsed():
assert rsync_progress.parse("5000 Artist/Album/Track.mp3\n") == (
5000,
"Artist/Album/Track.mp3",
)
def test_a_filename_containing_spaces_survives():
"""Splitting on every space would lose most of the library."""
assert rsync_progress.parse("1234 Artist/An Album/A Track With Spaces.mp3") == (
1234,
"Artist/An Album/A Track With Spaces.mp3",
)
def test_a_bare_path_is_tolerated():
"""In case this is fed --out-format='%n' by something older."""
assert rsync_progress.parse("Artist/Album/Track.mp3") == (0, "Artist/Album/Track.mp3")
def test_directory_sizes_do_not_inflate_the_total():
"""rsync reports directories with a 4096 inode size, which is several
megabytes of nothing across six thousand albums."""
output = run("4096 Artist/\n4096 Artist/Album/\n5000 Artist/Album/t.mp3\n", total=1)
assert "4.9 KiB" in output
assert "12" not in output.split("Artist")[0]
def test_a_rate_is_not_reported_until_it_means_something():
"""The first files arrive microseconds apart and would give a rate in the
gigabytes per second and an ETA of zero."""
rate = rsync_progress.Rate()
rate.add(100.0, 0)
rate.add(100.5, 5_000_000)
assert rate.per_second() == 0.0
def test_a_rate_over_a_long_enough_window_is_reported():
rate = rsync_progress.Rate()
rate.add(100.0, 0)
rate.add(110.0, 10_000_000)
assert rate.per_second() == pytest.approx(1_000_000)
def test_the_window_forgets_the_distant_past():
"""So the estimate follows a device that slows down rather than averaging
the slowdown away."""
rate = rsync_progress.Rate(window=30.0)
for second in range(0, 100, 10):
rate.add(float(second), second * 1_000_000)
rate.add(200.0, 100_000_000)
assert rate.samples[0][0] >= 90.0
@pytest.mark.parametrize(
("seconds", "expected"),
[(0, "0s"), (45, "45s"), (60, "1m00s"), (1092, "18m12s"), (7500, "2h05m")],
)
def test_durations_read_without_arithmetic(seconds, expected):
assert rsync_progress.human_duration(seconds) == expected
def test_a_summary_is_printed_at_the_end():
output = run("5000000 A/B/one.mp3\n", total=1, bytes_expected=5000000)
assert "copied 4.8 MiB in" in output
+111 -10
View File
@@ -1,7 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Render rsync's per-file output as a single updating status line. """Render rsync's per-file output as a single updating status line.
Fed the paths rsync reports with --out-format='%n', one per line. Prints one Fed the size and path rsync reports with --out-format='%l %n', one per line.
The size is what makes an estimate possible: rsync's own rate is not exposed
per file, but bytes completed over time elapsed is the same arithmetic and
needs nothing rsync does not already print. Prints one
line that rewrites itself, showing how far through the transfer is and which line that rewrites itself, showing how far through the transfer is and which
album is currently going across, rather than either scrolling fifty thousand album is currently going across, rather than either scrolling fifty thousand
filenames past or -- as rsync does while it builds its file list -- saying filenames past or -- as rsync does while it builds its file list -- saying
@@ -12,11 +15,76 @@ not fill up with carriage returns.
""" """
import argparse import argparse
import collections
import os import os
import shutil import shutil
import sys import sys
import time import time
# The estimate is taken over a trailing window rather than the whole run, so it
# follows a device that slows down instead of averaging the slowdown away.
RATE_WINDOW_SECONDS = 30.0
# Below this the window is too narrow to divide by: the first few files arrive
# in microseconds and produce a rate in the gigabytes per second, and an ETA of
# nothing at all. Better to show neither until the figure means something.
RATE_MINIMUM_SPAN_SECONDS = 2.0
def parse(line):
"""Return (bytes, path) for one line of rsync output.
Tolerates a bare path, in case someone runs this against --out-format='%n'.
"""
line = line.rstrip("\n")
size, separator, path = line.partition(" ")
if separator and size.isdigit():
return int(size), path
return 0, line
def human_bytes(count):
size = float(count)
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if size < 1024 or unit == "TiB":
return f"{size:.1f} {unit}"
size /= 1024
def human_duration(seconds):
"""Return a duration nobody has to do arithmetic on."""
seconds = int(seconds)
if seconds < 60:
return f"{seconds}s"
if seconds < 3600:
return f"{seconds // 60}m{seconds % 60:02d}s"
return f"{seconds // 3600}h{(seconds % 3600) // 60:02d}m"
class Rate:
"""Bytes per second over a trailing window."""
def __init__(self, window=RATE_WINDOW_SECONDS):
self.window = window
self.samples = collections.deque()
def add(self, when, total_bytes):
self.samples.append((when, total_bytes))
while len(self.samples) > 2 and when - self.samples[0][0] > self.window:
self.samples.popleft()
def per_second(self):
if len(self.samples) < 2:
return 0.0
(first_time, first_bytes), (last_time, last_bytes) = (
self.samples[0],
self.samples[-1],
)
elapsed = last_time - first_time
if elapsed < RATE_MINIMUM_SPAN_SECONDS:
return 0.0
return (last_bytes - first_bytes) / elapsed
def album_of(path): def album_of(path):
"""Return "Artist / Album" for a mirror-relative path.""" """Return "Artist / Album" for a mirror-relative path."""
@@ -35,18 +103,29 @@ def fit(text, width):
return "" + text[-(width - 1) :] return "" + text[-(width - 1) :]
def render(done, total, label, width): def render(done, total, copied, expected, rate, label, width):
"""Build the status line, giving whatever room is left to the album."""
if total > 0: if total > 0:
share = min(100, done * 100 // total) share = min(100, done * 100 // total)
head = f"[{done:>6,} / {total:<6,} {share:>3}%] " head = f"[{share:>3}%] {done:,}/{total:,}"
else: else:
head = f"[{done:>6,} files] " head = f"[{done:,} files]"
if expected > 0:
head += f" {human_bytes(copied)}/{human_bytes(expected)}"
if rate > 0:
head += f" {human_bytes(rate)}/s"
remaining = expected - copied
if remaining > 0:
head += f" ETA {human_duration(remaining / rate)}"
head += " "
return head + fit(label, max(0, width - len(head))) return head + fit(label, max(0, width - len(head)))
def main(argv=None, stream=None, out=None): def main(argv=None, stream=None, out=None):
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--total", type=int, default=0, help="files expected") parser.add_argument("--total", type=int, default=0, help="files expected")
parser.add_argument("--bytes", type=int, default=0, help="bytes expected")
parser.add_argument("--interval", type=float, default=0.1, help="seconds between redraws") parser.add_argument("--interval", type=float, default=0.1, help="seconds between redraws")
args = parser.parse_args(argv) args = parser.parse_args(argv)
@@ -56,31 +135,53 @@ def main(argv=None, stream=None, out=None):
width = shutil.get_terminal_size((100, 24)).columns - 1 width = shutil.get_terminal_size((100, 24)).columns - 1
done = 0 done = 0
copied = 0
rate = Rate()
started = time.monotonic()
rate.add(started, 0)
last_drawn = 0.0 last_drawn = 0.0
label = "" label = ""
for line in stream: for line in stream:
path = line.rstrip("\n") size, path = parse(line)
# rsync reports directories too, with a trailing slash. They are not # rsync reports directories too, with a trailing slash and an inode
# files and counting them would put the percentage past a hundred. # size. Counting them puts the percentage past a hundred and the byte
# total well over what will actually be transferred.
if not path or path.endswith("/"): if not path or path.endswith("/"):
continue continue
done += 1 done += 1
copied += size
label = album_of(path) or os.path.basename(path) label = album_of(path) or os.path.basename(path)
now = time.monotonic() now = time.monotonic()
rate.add(now, copied)
if interactive: if interactive:
if now - last_drawn >= args.interval: if now - last_drawn >= args.interval:
out.write("\r\033[2K" + render(done, args.total, label, width)) out.write(
"\r\033[2K"
+ render(done, args.total, copied, args.bytes, rate.per_second(),
label, width)
)
out.flush() out.flush()
last_drawn = now last_drawn = now
elif now - last_drawn >= 30: elif now - last_drawn >= 30:
out.write(render(done, args.total, label, width) + "\n") out.write(
render(done, args.total, copied, args.bytes, rate.per_second(), label, width)
+ "\n"
)
out.flush() out.flush()
last_drawn = now last_drawn = now
elapsed = max(1e-9, time.monotonic() - started)
if interactive: if interactive:
out.write("\r\033[2K") out.write("\r\033[2K")
out.write(render(done, args.total, label, width).rstrip() + "\n") summary = render(done, args.total, copied, args.bytes, 0, label, width).rstrip()
out.write(f"{summary}\n")
if copied:
out.write(
f"copied {human_bytes(copied)} in {human_duration(elapsed)}"
f" at {human_bytes(copied / elapsed)}/s\n"
)
out.flush() out.flush()
return 0 return 0
+13 -7
View File
@@ -46,9 +46,10 @@ catches an unmounted device: /media/IPOD/Music then resolves to the host's own
root filesystem, and this refuses to empty that. root filesystem, and this refuses to empty that.
Progress is one line that rewrites itself, showing the album currently going Progress is one line that rewrites itself, showing the album currently going
across and how far through the transfer is. Working the total out first means a across, how far through the transfer is, the rate, and an estimate of what is
second pass over the tree, which is the price of a percentage that means left. Working the totals out first means a second pass over the tree, which is
something; rsync's own is computed against a file list it is still building. the price of figures that mean something; rsync's own percentage is computed
against a file list it is still building.
Reach the device with the Apple firmware's disk mode: Menu+Select to reboot, Reach the device with the Apple firmware's disk mode: Menu+Select to reboot,
then immediately Select+Play. Power off afterwards by holding Play. then immediately Select+Play. Power off afterwards by holding Play.
@@ -175,12 +176,17 @@ fi
# second pass over the tree but means the transfer can show a real percentage # second pass over the tree but means the transfer can show a real percentage
# rather than a number that grows as rsync discovers more work. # rather than a number that grows as rsync discovers more work.
printf 'sync-to-ipod: working out what needs copying...\n' >&2 printf 'sync-to-ipod: working out what needs copying...\n' >&2
total=$(rsync "${options[@]}" --dry-run --out-format='%n' "$mirror/" "$destination/" | # %l is the file's size, which is what makes an estimate possible. Directories
grep -cve '/$' || true) # are dropped: rsync reports those too, with an inode size that would inflate
# the total by several megabytes of nothing.
counted=$(rsync "${options[@]}" --dry-run --out-format='%l %n' "$mirror/" "$destination/" |
awk '!/\/$/ { files++; bytes += $1 } END { print files + 0, bytes + 0 }')
total=${counted% *}
total_bytes=${counted#* }
printf 'sync-to-ipod: %s files to copy\n' "$total" >&2 printf 'sync-to-ipod: %s files to copy\n' "$total" >&2
rsync "${options[@]}" --out-format='%n' "$mirror/" "$destination/" | rsync "${options[@]}" --out-format='%l %n' "$mirror/" "$destination/" |
python3 "$here/rsync_progress.py" --total "$total" python3 "$here/rsync_progress.py" --total "$total" --bytes "$total_bytes"
status=${PIPESTATUS[0]} status=${PIPESTATUS[0]}
[ "$status" -eq 0 ] || die "rsync exited $status" [ "$status" -eq 0 ] || die "rsync exited $status"