From 3c857dbc896f58150252e23da354529c1caa9ad5 Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Fri, 10 Jul 2026 09:55:23 -0700 Subject: [PATCH 1/7] ESD-1644-fix(output): stop spinner from racing os.Stdout swaps The spinner animation goroutine wrote its ANSI frames straight to os.Stdout with an unsynchronized fmt.Printf, while RunWithPager and CaptureOutput/CaptureOutputErr reassign os.Stdout (and, for the latter two, os.Stderr) under their own mutexes. A live spinner overlapping one of those swaps is a data race under -race, and any frame that lands in the pager's temp file shows up as corruption in the paged output. Route spinner frames to os.Stderr, matching every other status message in the package, and add a narrowly-scoped mutex so a frame write and a CaptureOutput/CaptureOutputErr os.Stderr swap can never race. The lock is held only for the instant of each read or reassignment, never across a whole captured callback, so starting/stopping a spinner from inside a captured call cannot deadlock against it. RunWithPager and CaptureStdout only ever touch os.Stdout, which the spinner no longer writes to, so they need no new locking. --- internal/base/output/messages.go | 33 ++++++- internal/base/output/messages_test.go | 24 ++--- internal/base/output/output.go | 14 ++- .../base/output/spinner_stream_race_test.go | 88 +++++++++++++++++++ 4 files changed, 141 insertions(+), 18 deletions(-) create mode 100644 internal/base/output/spinner_stream_race_test.go diff --git a/internal/base/output/messages.go b/internal/base/output/messages.go index a07ea527..2665ac17 100644 --- a/internal/base/output/messages.go +++ b/internal/base/output/messages.go @@ -240,6 +240,31 @@ func (s *Spinner) renderFrame(i int) string { return color.CyanString(frame) } +// stdErrStreamMu synchronizes the spinner's animation writes against +// CaptureOutput/CaptureOutputErr reassigning os.Stderr. It is held only for +// the instant of a single read-and-write or a single reassignment, never +// across an entire captured callback, so a callback that itself starts and +// stops a spinner cannot deadlock against it. +var stdErrStreamMu sync.RWMutex + +// writeSpinnerLine writes s to os.Stderr, matching the rest of the package's +// status-message convention, synchronized against concurrent os.Stderr +// reassignment via stdErrStreamMu. +func writeSpinnerLine(s string) { + stdErrStreamMu.RLock() + defer stdErrStreamMu.RUnlock() + fmt.Fprint(os.Stderr, s) +} + +// setStderr reassigns os.Stderr under stdErrStreamMu, synchronizing the +// change against concurrent spinner writes (see stdErrStreamMu). Held only +// for the instant of the reassignment, never across the caller's function. +func setStderr(f *os.File) { + stdErrStreamMu.Lock() + os.Stderr = f + stdErrStreamMu.Unlock() +} + // nonInteractive reports whether the spinner's sink is non-interactive: a // machine-readable output format, or output not attached to a TTY. In those // sinks the carriage-return/clear-line escapes don't collapse anything, so the @@ -295,7 +320,7 @@ func (s *Spinner) runLoop(prefix string, startTime *time.Time) { msg = fmt.Sprintf("%s (%s elapsed)", prefix, elapsed) } - fmt.Printf("\r\033[K%s %s", styledFrame, msg) + writeSpinnerLine(fmt.Sprintf("\r\033[K%s %s", styledFrame, msg)) s.mu.Unlock() time.Sleep(s.frameRate) } @@ -322,10 +347,10 @@ func (s *Spinner) Stop() { s.stopped = true s.mu.Unlock() s.stop <- true - // Only the animated TTY path leaves a frame on stdout to clear; in a - // non-interactive sink a bare clear sequence would just be junk in logs. + // Only the animated TTY path leaves a frame to clear; in a non-interactive + // sink a bare clear sequence would just be junk in logs. if !s.nonInteractive() { - fmt.Print("\r\033[K") + writeSpinnerLine("\r\033[K") } } diff --git a/internal/base/output/messages_test.go b/internal/base/output/messages_test.go index 1bd9cb56..816c8029 100644 --- a/internal/base/output/messages_test.go +++ b/internal/base/output/messages_test.go @@ -181,12 +181,16 @@ func TestSpinner(t *testing.T) { assert.Equal(t, 100*time.Millisecond, spinner.frameRate) assert.True(t, spinner.noColor) - output := captureOutput(func() { - spinner.Start("Testing spinner") - time.Sleep(500 * time.Millisecond) - spinner.Stop() + var stdout string + stderr := captureStderr(t, func() { + stdout = captureOutput(func() { + spinner.Start("Testing spinner") + time.Sleep(500 * time.Millisecond) + spinner.Stop() + }) }) - assert.NotEmpty(t, output) + assert.NotEmpty(t, stderr, "spinner frames must be written to stderr") + assert.Empty(t, stdout, "spinner frames must never be written to stdout") } func TestPrintResourceSpinners(t *testing.T) { @@ -241,7 +245,7 @@ func TestPrintResourceSpinners(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - output := captureOutput(func() { + output := captureStderr(t, func() { spinner := tt.function(tt.resourceType, tt.uid, tt.noColor) time.Sleep(200 * time.Millisecond) spinner.Stop() @@ -259,7 +263,7 @@ func TestPrintResourceListing(t *testing.T) { t.Cleanup(func() { SetIsTerminal(orig) }) SetIsTerminal(true) - output := captureOutput(func() { + output := captureStderr(t, func() { spinner := PrintResourceListing("Port", true) time.Sleep(200 * time.Millisecond) spinner.Stop() @@ -513,7 +517,7 @@ func TestPrintResourceProvisioning(t *testing.T) { SetIsTerminal(true) t.Run("shows provisioning message with elapsed time", func(t *testing.T) { - output := captureOutput(func() { + output := captureStderr(t, func() { spinner := PrintResourceProvisioning("Port", "port-123", true) time.Sleep(200 * time.Millisecond) spinner.Stop() @@ -541,7 +545,7 @@ func TestStartWithElapsed(t *testing.T) { t.Run("appends elapsed time to message", func(t *testing.T) { spinner := NewSpinner(true) - output := captureOutput(func() { + output := captureStderr(t, func() { spinner.StartWithElapsed("Provisioning Port...") time.Sleep(1100 * time.Millisecond) spinner.Stop() @@ -566,7 +570,7 @@ func TestStartWithElapsed(t *testing.T) { t.Run("wasm style uses wasm chars", func(t *testing.T) { spinner := NewSpinner(true) spinner.style = "wasm" - output := captureOutput(func() { + output := captureStderr(t, func() { spinner.StartWithElapsed("Provisioning...") time.Sleep(200 * time.Millisecond) spinner.Stop() diff --git a/internal/base/output/output.go b/internal/base/output/output.go index 7287bec6..90987002 100644 --- a/internal/base/output/output.go +++ b/internal/base/output/output.go @@ -197,8 +197,11 @@ func CaptureOutput(f func()) string { defer tmp.Close() os.Stdout = tmp - os.Stderr = tmp - defer func() { os.Stdout = oldOut; os.Stderr = oldErr }() + setStderr(tmp) + defer func() { + os.Stdout = oldOut + setStderr(oldErr) + }() f() @@ -254,8 +257,11 @@ func CaptureOutputErr(f func() error) (string, error) { defer tmp.Close() os.Stdout = tmp - os.Stderr = tmp - defer func() { os.Stdout = oldOut; os.Stderr = oldErr }() + setStderr(tmp) + defer func() { + os.Stdout = oldOut + setStderr(oldErr) + }() runErr := f() diff --git a/internal/base/output/spinner_stream_race_test.go b/internal/base/output/spinner_stream_race_test.go new file mode 100644 index 00000000..ab56f20e --- /dev/null +++ b/internal/base/output/spinner_stream_race_test.go @@ -0,0 +1,88 @@ +//go:build !wasm + +package output + +import ( + "fmt" + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestSpinnerRunWithPagerRace is a regression test for ESD-1644: a live +// spinner animating in the background must not race with RunWithPager +// concurrently swapping os.Stdout, and its frames (now routed to stderr) +// must never bleed into the pager's captured stdout content. Run with +// -race to exercise the regression this guards against. +func TestSpinnerRunWithPagerRace(t *testing.T) { + if testing.Short() { + t.Skip("skipping timing-sensitive race regression test") + } + orig := isTerminalCached.Load() + t.Cleanup(func() { SetIsTerminal(orig) }) + SetIsTerminal(true) + + // Tall terminal so RunWithPager always takes the direct-write path. + setTerminalHeightForTesting(1000) + t.Cleanup(func() { setTerminalHeightForTesting(0) }) + + const iterations = 30 + + stdout := captureStdout(t, func() { + captureStderr(t, func() { + spinner := NewSpinner(true) + spinner.Start("Racing...") + + var wg sync.WaitGroup + for i := 0; i < iterations; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + _ = RunWithPager(func() error { + fmt.Printf("row%d\n", i) + return nil + }) + }(i) + } + wg.Wait() + spinner.Stop() + }) + }) + + for i := 0; i < iterations; i++ { + assert.Contains(t, stdout, fmt.Sprintf("row%d", i)) + } + assert.NotContains(t, stdout, "\r\033[K", + "spinner frames must never bleed into the pager's stdout content") +} + +// TestSpinnerCaptureOutputRace is a regression test for ESD-1644: a live +// spinner writing to os.Stderr must not race with CaptureOutput +// concurrently reassigning both os.Stdout and os.Stderr. Run with -race to +// exercise the regression this guards against. +func TestSpinnerCaptureOutputRace(t *testing.T) { + if testing.Short() { + t.Skip("skipping timing-sensitive race regression test") + } + orig := isTerminalCached.Load() + t.Cleanup(func() { SetIsTerminal(orig) }) + SetIsTerminal(true) + + spinner := NewSpinner(true) + spinner.Start("Racing...") + + var wg sync.WaitGroup + const iterations = 30 + for i := 0; i < iterations; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + CaptureOutput(func() { + fmt.Printf("captured%d\n", i) + }) + }(i) + } + wg.Wait() + spinner.Stop() +} From 5b219c8a2bf276792542439b8b572462d565b115 Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Fri, 10 Jul 2026 09:59:53 -0700 Subject: [PATCH 2/7] fix(output): move setStderr to output.go to fix wasm lint setStderr's only caller (CaptureOutput/CaptureOutputErr) lives in output.go, which is //go:build !wasm. Defining setStderr in the untagged messages.go left it with no reference under a js/wasm build, which golangci-lint's unused check correctly flagged. --- internal/base/output/messages.go | 9 --------- internal/base/output/output.go | 10 ++++++++++ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/internal/base/output/messages.go b/internal/base/output/messages.go index 2665ac17..50c4f379 100644 --- a/internal/base/output/messages.go +++ b/internal/base/output/messages.go @@ -256,15 +256,6 @@ func writeSpinnerLine(s string) { fmt.Fprint(os.Stderr, s) } -// setStderr reassigns os.Stderr under stdErrStreamMu, synchronizing the -// change against concurrent spinner writes (see stdErrStreamMu). Held only -// for the instant of the reassignment, never across the caller's function. -func setStderr(f *os.File) { - stdErrStreamMu.Lock() - os.Stderr = f - stdErrStreamMu.Unlock() -} - // nonInteractive reports whether the spinner's sink is non-interactive: a // machine-readable output format, or output not attached to a TTY. In those // sinks the carriage-return/clear-line escapes don't collapse anything, so the diff --git a/internal/base/output/output.go b/internal/base/output/output.go index 90987002..4ee6b6fa 100644 --- a/internal/base/output/output.go +++ b/internal/base/output/output.go @@ -176,6 +176,16 @@ var createTempFile = func() (*os.File, error) { return os.CreateTemp("", "capture-stdout-*") } +// setStderr reassigns os.Stderr under stdErrStreamMu, synchronizing the +// change against concurrent spinner writes (see stdErrStreamMu in +// messages.go). Held only for the instant of the reassignment, never across +// the caller's function. +func setStderr(f *os.File) { + stdErrStreamMu.Lock() + os.Stderr = f + stdErrStreamMu.Unlock() +} + // CaptureOutput runs f and returns everything it writes to stdout and stderr // combined. Status messages route to stderr and data to stdout, so a test that // wants all user-facing output captures both. Use CaptureStdout when asserting From 32b8d0d0bcebdc4511c399524d77634b8a1c7b74 Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Fri, 10 Jul 2026 12:26:56 -0700 Subject: [PATCH 3/7] fix(output): synchronize spinner non-interactive stderr writes too runLoop's non-interactive prefix line and StopWithSuccess's non-interactive success line wrote straight to os.Stderr, bypassing stdErrStreamMu. Route both through writeSpinnerLine so they can't race CaptureOutput/CaptureOutputErr reassigning os.Stderr either. --- internal/base/output/messages.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/base/output/messages.go b/internal/base/output/messages.go index 50c4f379..15f35d40 100644 --- a/internal/base/output/messages.go +++ b/internal/base/output/messages.go @@ -240,11 +240,12 @@ func (s *Spinner) renderFrame(i int) string { return color.CyanString(frame) } -// stdErrStreamMu synchronizes the spinner's animation writes against -// CaptureOutput/CaptureOutputErr reassigning os.Stderr. It is held only for -// the instant of a single read-and-write or a single reassignment, never -// across an entire captured callback, so a callback that itself starts and -// stops a spinner cannot deadlock against it. +// stdErrStreamMu synchronizes the spinner's stderr writes (animation frames, +// non-interactive status lines) against CaptureOutput/CaptureOutputErr +// reassigning os.Stderr. It is held only for the instant of a single +// read-and-write or a single reassignment, never across an entire captured +// callback, so a callback that itself starts and stops a spinner cannot +// deadlock against it. var stdErrStreamMu sync.RWMutex // writeSpinnerLine writes s to os.Stderr, matching the rest of the package's @@ -287,7 +288,7 @@ func (s *Spinner) runLoop(prefix string, startTime *time.Time) { s.mu.Unlock() if s.nonInteractive() { - fmt.Fprintf(os.Stderr, "%s\n", prefix) + writeSpinnerLine(fmt.Sprintf("%s\n", prefix)) return } @@ -367,10 +368,9 @@ func (s *Spinner) StopWithSuccess(msg string) { // corrupting machine-readable output streams. if s.nonInteractive() { if s.noColor { - fmt.Fprintf(os.Stderr, "✓ %s\n", msg) + writeSpinnerLine(fmt.Sprintf("✓ %s\n", msg)) } else { - fmt.Fprint(os.Stderr, color.GreenString("✓ ")) - fmt.Fprintln(os.Stderr, msg) + writeSpinnerLine(color.GreenString("✓ ") + msg + "\n") } } else { if s.noColor { From 3182669466e56ebe409df6dcb6b31fb50eb95e4a Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Fri, 10 Jul 2026 12:38:48 -0700 Subject: [PATCH 4/7] perf(output): avoid per-frame Sprintf allocation in spinner animation loop writeSpinnerLine(fmt.Sprintf(...)) built a formatted string on every animation tick before writing it. Add writeSpinnerLinef, a synchronized fmt.Fprintf wrapper, and use it in the hot loop to skip the extra allocation. --- internal/base/output/messages.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/base/output/messages.go b/internal/base/output/messages.go index 15f35d40..42812045 100644 --- a/internal/base/output/messages.go +++ b/internal/base/output/messages.go @@ -257,6 +257,14 @@ func writeSpinnerLine(s string) { fmt.Fprint(os.Stderr, s) } +// writeSpinnerLinef is the formatted counterpart to writeSpinnerLine, used in +// the animation loop to avoid a fmt.Sprintf allocation on every frame. +func writeSpinnerLinef(format string, args ...interface{}) { + stdErrStreamMu.RLock() + defer stdErrStreamMu.RUnlock() + fmt.Fprintf(os.Stderr, format, args...) +} + // nonInteractive reports whether the spinner's sink is non-interactive: a // machine-readable output format, or output not attached to a TTY. In those // sinks the carriage-return/clear-line escapes don't collapse anything, so the @@ -312,7 +320,7 @@ func (s *Spinner) runLoop(prefix string, startTime *time.Time) { msg = fmt.Sprintf("%s (%s elapsed)", prefix, elapsed) } - writeSpinnerLine(fmt.Sprintf("\r\033[K%s %s", styledFrame, msg)) + writeSpinnerLinef("\r\033[K%s %s", styledFrame, msg) s.mu.Unlock() time.Sleep(s.frameRate) } From cd032de19700ef0c52385369d0fbed6f057ae9e1 Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Fri, 10 Jul 2026 12:49:23 -0700 Subject: [PATCH 5/7] refactor(output): relocate stdErrStreamMu to common.go, quiet a race test stdErrStreamMu guards os.Stderr reassignment for both the spinner (messages.go) and CaptureOutput/CaptureOutputErr (output.go), but lived in messages.go with a spinner-specific doc comment, making it easy to miss as a general stream-swap lock. Move it to common.go next to the equivalent stdoutMu. Also wrap TestSpinnerCaptureOutputRace's body in captureStderr so a live spinner's frames don't leak into test output on -v or failure. --- internal/base/output/common.go | 8 +++++ internal/base/output/messages.go | 8 ----- internal/base/output/output.go | 7 ++-- .../base/output/spinner_stream_race_test.go | 32 ++++++++++--------- 4 files changed, 28 insertions(+), 27 deletions(-) diff --git a/internal/base/output/common.go b/internal/base/output/common.go index 8e97c56c..2ab7fbc3 100644 --- a/internal/base/output/common.go +++ b/internal/base/output/common.go @@ -16,6 +16,14 @@ import ( // Both native and WASM builds use this mutex. var stdoutMu sync.Mutex +// stdErrStreamMu guards os.Stderr reassignment (CaptureOutput/CaptureOutputErr, +// via setStderr in output.go) against concurrent stderr writes (the spinner's +// animation frames and status lines, via writeSpinnerLine/writeSpinnerLinef in +// messages.go). It is held only for the instant of a single read-and-write or +// a single reassignment, never across an entire captured callback, so a +// callback that itself starts and stops a spinner cannot deadlock against it. +var stdErrStreamMu sync.RWMutex + // OutputConfig holds all user-facing output configuration as a single struct. // Use ApplyOutputConfig to write and GetOutputConfig to read atomically. type OutputConfig struct { diff --git a/internal/base/output/messages.go b/internal/base/output/messages.go index 42812045..2fbf6661 100644 --- a/internal/base/output/messages.go +++ b/internal/base/output/messages.go @@ -240,14 +240,6 @@ func (s *Spinner) renderFrame(i int) string { return color.CyanString(frame) } -// stdErrStreamMu synchronizes the spinner's stderr writes (animation frames, -// non-interactive status lines) against CaptureOutput/CaptureOutputErr -// reassigning os.Stderr. It is held only for the instant of a single -// read-and-write or a single reassignment, never across an entire captured -// callback, so a callback that itself starts and stops a spinner cannot -// deadlock against it. -var stdErrStreamMu sync.RWMutex - // writeSpinnerLine writes s to os.Stderr, matching the rest of the package's // status-message convention, synchronized against concurrent os.Stderr // reassignment via stdErrStreamMu. diff --git a/internal/base/output/output.go b/internal/base/output/output.go index 4ee6b6fa..a43fe73c 100644 --- a/internal/base/output/output.go +++ b/internal/base/output/output.go @@ -176,10 +176,9 @@ var createTempFile = func() (*os.File, error) { return os.CreateTemp("", "capture-stdout-*") } -// setStderr reassigns os.Stderr under stdErrStreamMu, synchronizing the -// change against concurrent spinner writes (see stdErrStreamMu in -// messages.go). Held only for the instant of the reassignment, never across -// the caller's function. +// setStderr reassigns os.Stderr under stdErrStreamMu (declared in common.go), +// synchronizing the change against concurrent spinner writes. Held only for +// the instant of the reassignment, never across the caller's function. func setStderr(f *os.File) { stdErrStreamMu.Lock() os.Stderr = f diff --git a/internal/base/output/spinner_stream_race_test.go b/internal/base/output/spinner_stream_race_test.go index ab56f20e..c7605141 100644 --- a/internal/base/output/spinner_stream_race_test.go +++ b/internal/base/output/spinner_stream_race_test.go @@ -69,20 +69,22 @@ func TestSpinnerCaptureOutputRace(t *testing.T) { t.Cleanup(func() { SetIsTerminal(orig) }) SetIsTerminal(true) - spinner := NewSpinner(true) - spinner.Start("Racing...") + captureStderr(t, func() { + spinner := NewSpinner(true) + spinner.Start("Racing...") - var wg sync.WaitGroup - const iterations = 30 - for i := 0; i < iterations; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() - CaptureOutput(func() { - fmt.Printf("captured%d\n", i) - }) - }(i) - } - wg.Wait() - spinner.Stop() + var wg sync.WaitGroup + const iterations = 30 + for i := 0; i < iterations; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + CaptureOutput(func() { + fmt.Printf("captured%d\n", i) + }) + }(i) + } + wg.Wait() + spinner.Stop() + }) } From 67ea5fadbe3e4d9dd103ff3b149ee058db107589 Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Fri, 10 Jul 2026 12:59:02 -0700 Subject: [PATCH 6/7] perf(output): use writeSpinnerLinef for the remaining formatted spinner writes Two more spinner writes built a string via fmt.Sprintf before handing it to writeSpinnerLine. Switch both to writeSpinnerLinef to drop the intermediate allocation, consistent with the animation loop. --- internal/base/output/messages.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/base/output/messages.go b/internal/base/output/messages.go index 2fbf6661..7ebb5033 100644 --- a/internal/base/output/messages.go +++ b/internal/base/output/messages.go @@ -288,7 +288,7 @@ func (s *Spinner) runLoop(prefix string, startTime *time.Time) { s.mu.Unlock() if s.nonInteractive() { - writeSpinnerLine(fmt.Sprintf("%s\n", prefix)) + writeSpinnerLinef("%s\n", prefix) return } @@ -368,7 +368,7 @@ func (s *Spinner) StopWithSuccess(msg string) { // corrupting machine-readable output streams. if s.nonInteractive() { if s.noColor { - writeSpinnerLine(fmt.Sprintf("✓ %s\n", msg)) + writeSpinnerLinef("✓ %s\n", msg) } else { writeSpinnerLine(color.GreenString("✓ ") + msg + "\n") } From e5375000249347a9e28b749f6fa504da09d0f29a Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Mon, 13 Jul 2026 06:52:07 -0700 Subject: [PATCH 7/7] fix(output): route interactive StopWithSuccess line through writeSpinnerLine The animated-TTY branch still wrote its success line to stdout via an unsynchronized fmt.Printf, contradicting the package's stderr-only status-message convention and leaving the same unsynchronized stdout write class this PR fixes elsewhere. Route it through writeSpinnerLine so it's synchronized against stream reassignment and lands on stderr like every other status message. --- internal/base/output/messages.go | 5 ++--- internal/base/output/messages_test.go | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/internal/base/output/messages.go b/internal/base/output/messages.go index 7ebb5033..eed6854e 100644 --- a/internal/base/output/messages.go +++ b/internal/base/output/messages.go @@ -374,10 +374,9 @@ func (s *Spinner) StopWithSuccess(msg string) { } } else { if s.noColor { - fmt.Printf("✓ %s\n", msg) + writeSpinnerLinef("✓ %s\n", msg) } else { - fmt.Print(color.GreenString("✓ ")) - fmt.Println(msg) + writeSpinnerLine(color.GreenString("✓ ") + msg + "\n") } } } diff --git a/internal/base/output/messages_test.go b/internal/base/output/messages_test.go index 816c8029..a49de53b 100644 --- a/internal/base/output/messages_test.go +++ b/internal/base/output/messages_test.go @@ -607,7 +607,7 @@ func TestSpinnerStopWithSuccess(t *testing.T) { t.Cleanup(func() { SetIsTerminal(orig) }) SetIsTerminal(true) - output := captureOutput(func() { + output := captureStderr(t, func() { spinner := NewSpinner(true) spinner.Start("Testing") time.Sleep(200 * time.Millisecond)