Skip to content

Commit 370d2bf

Browse files
committed
init: show copy progress for init --from
`init --from /` on a multi-GB rootfs takes minutes; the previous single 'one-time copy...' line made it look hung. SeedDir now takes an optional Progress callback (files, bytes); InitFrom renders a throttled (~4/s) stderr line and a final 'N files, X copied in Ys' summary. Wiring: image.SeedDir gains a Progress param (nil = silent); repo gains a local humanBytes helper (repo can't import the cli copy). Test exercises the callback counts.
1 parent c57f96c commit 370d2bf

5 files changed

Lines changed: 73 additions & 6 deletions

File tree

.devcontainer/devcontainer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
// behind deb.debian.org from mainland China. The env var flows in via
2727
// containerEnv below; OSS users outside China leave it unset and the
2828
// mirror swap is a no-op.
29-
"postCreateCommand": ".devcontainer/postcreate.sh",
29+
"postCreateCommand": "bash .devcontainer/postcreate.sh",
3030
"containerEnv": {
3131
"AGENTENV_CN_MIRROR": "${localEnv:AGENTENV_CN_MIRROR}"
3232
},

internal/image/image.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,26 @@ func openTarballSource(src string) (io.Reader, func(), error) {
8585
return f, func() { f.Close() }, nil
8686
}
8787

88+
// Progress reports seeding progress. It is called after each regular file is
89+
// copied, with the running count of files and bytes copied so far. Callers
90+
// typically throttle their own rendering (it fires very frequently); pass nil
91+
// to SeedDir to disable. Directories and symlinks don't trigger it (they carry
92+
// ~no bytes and would just add noise to a "GB copied" readout).
93+
type Progress func(files int, bytes int64)
94+
8895
// SeedDir copies the tree at src into dst, skipping any path in exclude (and not
8996
// descending into excluded directories). It is best-effort: unreadable files
9097
// (e.g. root-owned files when running unprivileged) and special files (devices,
9198
// FIFOs, sockets) are skipped rather than aborting — so seeding from "/" inside a
9299
// container works even as a non-root user.
93-
func SeedDir(src, dst string, exclude map[string]bool) error {
100+
//
101+
// If progress is non-nil it is called after each regular file with cumulative
102+
// (files, bytes) counts — `init --from /` on a multi-GB image can take minutes,
103+
// and a silent copy looks like a hang.
104+
func SeedDir(src, dst string, exclude map[string]bool, progress Progress) error {
94105
src = filepath.Clean(src)
106+
var files int
107+
var bytes int64
95108
return filepath.Walk(src, func(path string, fi os.FileInfo, err error) error {
96109
if err != nil {
97110
return nil // tolerate unreadable/disappearing entries (e.g. /proc races)
@@ -120,7 +133,11 @@ func SeedDir(src, dst string, exclude map[string]bool) error {
120133
_ = os.Symlink(link, target)
121134
}
122135
case fi.Mode().IsRegular():
123-
_ = seedCopyFile(path, target, fi)
136+
if seedCopyFile(path, target, fi) == nil && progress != nil {
137+
files++
138+
bytes += fi.Size()
139+
progress(files, bytes)
140+
}
124141
}
125142
return nil
126143
})

internal/image/image_test.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,20 @@ func TestSeedDirCopiesAndExcludes(t *testing.T) {
5252

5353
dst := t.TempDir()
5454
exclude := map[string]bool{filepath.Join(src, "proc"): true}
55-
if err := SeedDir(src, dst, exclude); err != nil {
55+
var lastFiles int
56+
var lastBytes int64
57+
prog := func(files int, bytes int64) { lastFiles, lastBytes = files, bytes }
58+
if err := SeedDir(src, dst, exclude, prog); err != nil {
5659
t.Fatalf("SeedDir: %v", err)
5760
}
61+
// Two regular files copied (root/a.txt + usr/bin/tool; proc/fake excluded,
62+
// the symlink doesn't count). Bytes = len("A") + len("bin") = 4.
63+
if lastFiles != 2 {
64+
t.Errorf("progress files = %d, want 2", lastFiles)
65+
}
66+
if lastBytes != 4 {
67+
t.Errorf("progress bytes = %d, want 4", lastBytes)
68+
}
5869

5970
if b, err := os.ReadFile(filepath.Join(dst, "root/a.txt")); err != nil || string(b) != "A" {
6071
t.Errorf("root/a.txt = %q, %v", b, err)

internal/repo/repo.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,10 +221,31 @@ func (r *Repo) InitFrom(src string) (*dag.Node, error) {
221221
exclude[p] = true
222222
}
223223
}
224-
fmt.Printf("seeding rootfs from %s (this is a one-time copy) ...\n", src)
225-
if err := image.SeedDir(src, r.workRoot(), exclude); err != nil {
224+
fmt.Fprintf(os.Stderr, "seeding rootfs from %s (one-time copy)...\n", src)
225+
// Throttled progress: `init --from /` on a multi-GB image takes minutes,
226+
// and a silent copy is indistinguishable from a hang. Repaint a single
227+
// stderr line ~4×/s with running file + byte counts. Throttling keeps the
228+
// hot copy loop from doing a syscall per file just to print.
229+
start := time.Now()
230+
var last time.Time
231+
var totFiles int
232+
var totBytes int64
233+
prog := func(files int, bytes int64) {
234+
totFiles, totBytes = files, bytes
235+
now := time.Now()
236+
if now.Sub(last) < 250*time.Millisecond {
237+
return
238+
}
239+
last = now
240+
fmt.Fprintf(os.Stderr, "\r %d files, %s copied...", files, humanBytes(bytes))
241+
}
242+
if err := image.SeedDir(src, r.workRoot(), exclude, prog); err != nil {
243+
fmt.Fprintln(os.Stderr)
226244
return nil, err
227245
}
246+
// \r + trailing spaces overwrite the last (possibly longer) progress line.
247+
fmt.Fprintf(os.Stderr, "\r %d files, %s copied in %s%s\n",
248+
totFiles, humanBytes(totBytes), time.Since(start).Round(time.Second), strings.Repeat(" ", 12))
228249
id := r.newID()
229250
if err := r.be.Snapshotter.Freeze(id, ""); err != nil {
230251
return nil, err

internal/repo/status.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package repo
44

55
import (
6+
"fmt"
67
"os"
78
"path/filepath"
89
"syscall"
@@ -96,3 +97,20 @@ func dirRealSize(dir string) int64 {
9697
})
9798
return total
9899
}
100+
101+
// humanBytes formats a byte count as a short human-readable string (B/KB/MB/GB).
102+
// Used by the init --from progress readout. (The CLI layer has its own copy for
103+
// `status` output; this one keeps the repo package self-contained.)
104+
func humanBytes(n int64) string {
105+
const k = 1024
106+
switch {
107+
case n < k:
108+
return fmt.Sprintf("%dB", n)
109+
case n < k*k:
110+
return fmt.Sprintf("%.1fKB", float64(n)/k)
111+
case n < k*k*k:
112+
return fmt.Sprintf("%.1fMB", float64(n)/(k*k))
113+
default:
114+
return fmt.Sprintf("%.2fGB", float64(n)/(k*k*k))
115+
}
116+
}

0 commit comments

Comments
 (0)