Skip to content

vt: make Emulator.closed atomic to fix the Close/Read data race - #931

Closed
cameronsjo wants to merge 1 commit into
charmbracelet:mainfrom
cameronsjo:vt-close-race-atomic
Closed

vt: make Emulator.closed atomic to fix the Close/Read data race#931
cameronsjo wants to merge 1 commit into
charmbracelet:mainfrom
cameronsjo:vt-close-race-atomic

Conversation

@cameronsjo

Copy link
Copy Markdown

Closes #930

Emulator.closed is a plain bool that Close writes and Read reads with no synchronization between them, so -race flags every shutdown of an Emulator whose output is being consumed. Read blocks on an empty pipe and Close is the only thing that unblocks it, so there is no usage pattern that avoids the pair running concurrently.

SafeEmulator does not cover it either: its Read takes no lock (deliberately, so a parked reader can't hold mu against every writer) and it does not override Close, so the embedded *Emulator.Close is promoted unchanged.

The change

closed becomes an atomic.Bool. The guard is an early-out rather than the mechanism — the actual unblocking is pw.CloseWithError(io.EOF) on an io.Pipe, which is internally synchronized and idempotent — so no mutex is needed and Read stays lock-free.

Close uses CompareAndSwap rather than a Load then a Store, which also makes it single-winner: two concurrent Close calls previously could both pass the guard and both call CloseWithError. Harmless, since that call is idempotent, but the CAS form costs nothing and says what it means.

Verification

  • go test -race ./... in vt/ passes.
  • A reproducer that reliably fails on main passes with this change:
func TestCloseRacesParkedRead(t *testing.T) {
	e := vt.NewEmulator(80, 24)

	done := make(chan struct{})
	go func() {
		defer close(done)
		buf := make([]byte, 64)
		for {
			if _, err := e.Read(buf); err != nil {
				return
			}
		}
	}()

	time.Sleep(100 * time.Millisecond) // let the reader park inside Read
	if err := e.Close(); err != nil {
		t.Fatalf("Close: %v", err)
	}
	<-done
}

On main this reports the race at emulator.go:252 (Read) against emulator.go:265 (Close). With this change it passes.

  • Downstream check: a terminal-multiplexing consumer of vt that had to exclude every shutdown-touching test from its -race job runs its full suite green, with no exclusions, against a patched vt.

I did not add the reproducer as a test in this PR since it needs a sleep to set up the condition; happy to include it in whatever form suits the package if you'd like it covered.

Emulator.closed is a plain bool that Close writes and Read reads with no
synchronization between them, so -race flags every shutdown of an Emulator
whose output is being consumed. Read blocks on an empty pipe and Close is the
only thing that unblocks it, so no usage pattern avoids the pair running
concurrently. SafeEmulator does not cover it either: its Read takes no lock and
it does not override Close.

The guard is an early-out rather than the mechanism — the actual unblocking is
pw.CloseWithError(io.EOF) on an io.Pipe, which is internally synchronized and
idempotent — so an atomic.Bool is enough and Read stays lock-free.

Close uses CompareAndSwap rather than a Load then a Store, which also makes it
single-winner: two concurrent Close calls could previously both pass the guard.
Harmless given CloseWithError is idempotent, but the CAS form costs nothing.

Refs charmbracelet#930

Session-Name: cedar-fugue
Session-Id: a516020b-4fa8-4479-ab35-fc781fb6025a
Model: claude-opus-5
Harness: claude-code 2.1.220
Machine: cf6e768835c7

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cameronsjo

Copy link
Copy Markdown
Author

Closing this — #881 already proposes the same atomic.Bool change and does it better, using Swap(true) for the close-once guard and adding a regression test this PR lacks. I missed it before opening; apologies for the noise.

Moving the verification detail over to #881.

@cameronsjo cameronsjo closed this Jul 30, 2026
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.

vt: data race on Emulator.closed between Close and a parked Read

1 participant