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:
parent
b5c13e3938
commit
2584f94828
2 changed files with 70 additions and 2 deletions
|
|
@ -1795,7 +1795,12 @@ subcommand lands).
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -1920,7 +1925,12 @@ func newKernelPullCommand() *cobra.Command {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -3416,6 +3426,37 @@ func writerSupportsProgress(out io.Writer) bool {
|
||||||
return info.Mode()&os.ModeCharDevice != 0
|
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 {
|
func formatVMCreateProgress(op api.VMCreateOperation) string {
|
||||||
stage := strings.TrimSpace(op.Stage)
|
stage := strings.TrimSpace(op.Stage)
|
||||||
detail := strings.TrimSpace(op.Detail)
|
detail := strings.TrimSpace(op.Detail)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestVMSetParamsFromFlagsConflict(t *testing.T) {
|
||||||
if _, err := vmSetParamsFromFlags("devbox", -1, -1, "", true, true); err == nil {
|
if _, err := vmSetParamsFromFlags("devbox", -1, -1, "", true, true); err == nil {
|
||||||
t.Fatal("expected nat conflict error")
|
t.Fatal("expected nat conflict error")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue