From 2584f94828c45b1d7a0bf10cc96ea4561c458f49 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sat, 18 Apr 2026 17:08:30 -0300 Subject: [PATCH] image/kernel pull: heartbeat dots so slow pulls look alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/cli/banger.go | 45 ++++++++++++++++++++++++++++++++++++++-- internal/cli/cli_test.go | 27 ++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 393acbd..3e41337 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -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) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index aed211e..2795049 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -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")