Skip to content

ESD-1644-fix(output): stop spinner from racing os.Stdout swaps#531

Open
Phil-Browne wants to merge 9 commits into
mainfrom
worktree-esd-1644-spinner-stdout-race
Open

ESD-1644-fix(output): stop spinner from racing os.Stdout swaps#531
Phil-Browne wants to merge 9 commits into
mainfrom
worktree-esd-1644-spinner-stdout-race

Conversation

@Phil-Browne

Copy link
Copy Markdown
Contributor

Fixes ESD-1644.

The spinner's animation goroutine wrote 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 was a data race under -race, and any frame that landed in the pager's temp file showed up as visible corruption in the paged output.

  • Route spinner frames to os.Stderr, matching every other status message in the package.
  • Add a narrowly-scoped stdErrStreamMu so a spinner frame write and a CaptureOutput/CaptureOutputErr stderr swap can never race. It's 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 can't deadlock against it.
  • RunWithPager and CaptureStdout need no changes: they only ever touch os.Stdout, and the spinner no longer writes there.
  • Update the handful of existing tests that asserted spinner frame content on stdout to assert on stderr instead.
  • Add a -race regression test that runs a live spinner concurrently with RunWithPager and CaptureOutput, asserting no race and no frame bleed into captured output.

Test plan

  • go build ./...
  • go test ./...
  • go test -race ./internal/base/output/...
  • golangci-lint run
  • Verified the new race tests actually catch the regression (temporarily stripped the new locking and confirmed -race flags the exact write/read reported in the ticket, then restored the fix)

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.
Copilot AI review requested due to automatic review settings July 10, 2026 16:55
@Phil-Browne
Phil-Browne requested review from a team and penzeliz-megaport as code owners July 10, 2026 16:55
@Phil-Browne
Phil-Browne requested a review from ianb-mp July 10, 2026 16:55
@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.55172% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 79.33%. Comparing base (dee5b8d) to head (a7debc3).

Files with missing lines Patch % Lines
internal/base/output/messages.go 93.33% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #531      +/-   ##
==========================================
+ Coverage   79.29%   79.33%   +0.03%     
==========================================
  Files         193      193              
  Lines       18683    18699      +16     
==========================================
+ Hits        14814    14834      +20     
+ Misses       2820     2817       -3     
+ Partials     1049     1048       -1     
Files with missing lines Coverage Δ
internal/base/output/common.go 77.55% <ø> (ø)
internal/base/output/output.go 71.85% <100.00%> (+1.79%) ⬆️
internal/base/output/messages.go 85.54% <93.33%> (+0.57%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a concurrency issue in the internal/base/output package where the spinner’s background animation output could race with helpers that swap os.Stdout/os.Stderr (pager buffering and output capture), potentially triggering -race failures and corrupting captured/paged output.

Changes:

  • Routes spinner animation frames to os.Stderr and synchronizes frame writes with os.Stderr reassignment.
  • Updates CaptureOutput / CaptureOutputErr to swap os.Stderr via a synchronized helper.
  • Adjusts existing tests to assert spinner output on stderr and adds new -race regression tests for spinner vs pager/capture interactions.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
internal/base/output/spinner_stream_race_test.go Adds race regression tests covering spinner concurrency with RunWithPager and CaptureOutput.
internal/base/output/output.go Switches stderr swapping in capture helpers to a synchronized setStderr(...) call.
internal/base/output/messages.go Moves spinner frame output to stderr and adds stdErrStreamMu + writeSpinnerLine(...) / setStderr(...) synchronization.
internal/base/output/messages_test.go Updates spinner-related assertions to validate stderr output (and stdout cleanliness).
Comments suppressed due to low confidence (1)

internal/base/output/messages.go:323

  • writeSpinnerLine now synchronizes the animated TTY spinner frames against setStderr, but runLoop's non-interactive path still writes to os.Stderr directly (fmt.Fprintf(os.Stderr, ...)). That write can still race with CaptureOutput/CaptureOutputErr swapping os.Stderr, so the spinner isn’t fully protected in non-interactive mode.

Consider routing the non-interactive write through writeSpinnerLine (or taking stdErrStreamMu) as well, so all spinner stderr writes are synchronized with setStderr.

	s.mu.Lock()

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread internal/base/output/messages.go
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread internal/base/output/messages.go Outdated
… 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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread internal/base/output/output.go Outdated
Comment thread internal/base/output/spinner_stream_race_test.go Outdated
…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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Comment thread internal/base/output/messages.go
Comment thread internal/base/output/messages.go
…er 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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

@penzeliz-megaport

Copy link
Copy Markdown
Collaborator

The race fix is correct: frames now go to os.Stderr under the RLock. One gap: the interactive branch of StopWithSuccess (messages.go) still writes the success line to stdout via fmt.Printf, unsynchronised. That contradicts this PR's own invariant of routing spinner output to stderr, and can race a stdout swap (CaptureOutput / RunWithPager). Low real-world risk since StopWithSuccess is synchronous and the CLI runs one command at a time, but the fix is incomplete. Route that line through writeSpinnerLine too.

@penzeliz-megaport penzeliz-megaport left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed for correctness; findings noted in comments are non-blocking. LGTM.

…nerLine

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.
@Phil-Browne

Copy link
Copy Markdown
Contributor Author

Good catch, fixed in e537500. Routed the interactive branch of StopWithSuccess through writeSpinnerLine/writeSpinnerLinef, so it's now synchronized against stream reassignment and lands on stderr like every other status message in the package.

Note this reverses the earlier call to leave it as-is: on reflection the stdout write was the same unsynchronized-stream-write pattern this PR exists to fix, just with lower odds of firing, so it's in scope. Updated TestSpinnerStopWithSuccess to assert on stderr instead of stdout. Full test suite, -race, and lint all pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants