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)

View file

@ -574,6 +574,33 @@ func TestVMRunProgressRendererSuppressesDuplicateLines(t *testing.T) {
}
}
func TestWithHeartbeatNoOpForNonTTY(t *testing.T) {
var buf bytes.Buffer
called := false
err := withHeartbeat(&buf, "image pull", func() error {
called = true
return nil
})
if err != nil {
t.Fatalf("withHeartbeat: %v", err)
}
if !called {
t.Fatal("fn should have been called")
}
if buf.Len() != 0 {
t.Fatalf("stderr = %q, want empty for non-TTY", buf.String())
}
}
func TestWithHeartbeatPropagatesError(t *testing.T) {
sentinel := errors.New("boom")
var buf bytes.Buffer
err := withHeartbeat(&buf, "image pull", func() error { return sentinel })
if !errors.Is(err, sentinel) {
t.Fatalf("withHeartbeat error = %v, want %v", err, sentinel)
}
}
func TestVMSetParamsFromFlagsConflict(t *testing.T) {
if _, err := vmSetParamsFromFlags("devbox", -1, -1, "", true, true); err == nil {
t.Fatal("expected nat conflict error")