image/kernel pull: heartbeat dots so slow pulls look alive

Bundle downloads can take 20–60s on a typical connection and the
CLI was going silent between "resolving daemon" and the final image
summary. Users wondered whether banger had wedged.

New `withHeartbeat` helper wraps an RPC call with a dot-every-2s
ticker on stderr. No-op when stderr isn't a terminal, so piped or
scripted invocations stay quiet. Wired into `image pull` and `kernel
pull`, the two commands that actually download bytes.

Example:

    $ banger image pull debian-bookworm
    [image pull] ..........
    id  name             managed  ...

Tests cover the non-TTY short-circuit and error propagation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-18 17:08:30 -03:00
parent b5c13e3938
commit 2584f94828
No known key found for this signature in database
GPG key ID: 33112E6833C34679
2 changed files with 70 additions and 2 deletions

View file

@ -1795,7 +1795,12 @@ subcommand lands).
if err != nil {
return err
}
result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.pull", params)
var result api.ImageShowResult
err = withHeartbeat(cmd.ErrOrStderr(), "image pull", func() error {
var callErr error
result, callErr = rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.pull", params)
return callErr
})
if err != nil {
return err
}
@ -1920,7 +1925,12 @@ func newKernelPullCommand() *cobra.Command {
if err != nil {
return err
}
result, err := rpc.Call[api.KernelShowResult](cmd.Context(), layout.SocketPath, "kernel.pull", api.KernelPullParams{Name: args[0], Force: force})
var result api.KernelShowResult
err = withHeartbeat(cmd.ErrOrStderr(), "kernel pull", func() error {
var callErr error
result, callErr = rpc.Call[api.KernelShowResult](cmd.Context(), layout.SocketPath, "kernel.pull", api.KernelPullParams{Name: args[0], Force: force})
return callErr
})
if err != nil {
return err
}
@ -3416,6 +3426,37 @@ func writerSupportsProgress(out io.Writer) bool {
return info.Mode()&os.ModeCharDevice != 0
}
// withHeartbeat runs fn while emitting a dot to stderr every 2
// seconds so the user sees long-running RPCs (bundle downloads, etc.)
// aren't wedged. No-op when stderr isn't a terminal, so piped or
// logged output stays clean.
func withHeartbeat(stderr io.Writer, label string, fn func() error) error {
if !writerSupportsProgress(stderr) {
return fn()
}
fmt.Fprintf(stderr, "[%s] ", label)
stop := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
fmt.Fprint(stderr, ".")
}
}
}()
err := fn()
close(stop)
<-done
fmt.Fprintln(stderr)
return err
}
func formatVMCreateProgress(op api.VMCreateOperation) string {
stage := strings.TrimSpace(op.Stage)
detail := strings.TrimSpace(op.Detail)