charmbracelet / charmbracelet/x
vt: data race on Emulator.closed between Read and Close
- Dominant language
- Go
- Stars
- 314
- Forks
- 94
- Avg merge
- 3d 22h
- Merged PRs (30d)
- 2
Description
`Emulator.Read` and `Emulator.Close` both touch the unexported `closed bool` with no synchronization, and `SafeEmulator` doesn't protect it either — so calling `Close()` to unblock a goroutine parked in `Read()` is a data race.
`Emulator.Read` reads `closed`, then blocks on the pipe (`vt/emulator.go:251`):
```go
func (e *Emulator) Read(p []byte) (n int, err error) {
if e.closed { // unsynchronized read
return 0, io.EOF
}
return e.pr.Read(p) // blocks here
}
```
`Emulator.Close` writes it (`vt/emulator.go:260`):
```go
func (e *Emulator) Close() error {
if e.closed { return nil } // unsynchronized read
e.closed = true // unsynchronized write
return e.pw.CloseWithError(io.EOF)
}
```
`SafeEmulator.Read` deliberately takes **no** lock (so it can block without holding the mutex across the read), and there's no `SafeEmulator.Close` override, so the embedded `Emulator.Close` also runs unlocked (`vt/safe_emulator.go:33`).
The underlying `io.Pipe` is already concurrency-safe — `pw.CloseWithError(io.EOF)` correctly unblocks a blocked `pr.Read`. The **only** unsafe shared state is the `closed` flag.
### Impact
The natural teardown pattern — one goroutine draining `Read()`, another calling `Close()` to unblock it — trips `go test -race` with a data race on `closed` (read at `emulator.go:252`, write at `emulator.go:265`). Consumers are forced to choose between leaking the `Read` goroutine or accepting the race.
### Suggested fix
Make `closed` an `atomic.Bool` (load in `Read`/`Write`, `Swap` in `Close`) — or drop the `closed` short-circuit entirely and rely on the pipe (a closed pipe already makes `pr.Read` return the close error and `pw.Write` return `ErrClosedPipe`). Either way no mutex needs to be held across the blocking read.
Observed on `vt` module version `v0.0.0-20260323091123-df7b1bcffcca`.
Contributor guide
Research direction
Start with vt/emulator.go around Emulator.Read and Emulator.Close, then inspect vt/safe_emulator.go around SafeEmulator.Read and its embedded Close behavior. Run the relevant tests with go test -race using one goroutine blocked in Read and another calling Close. Done means teardown no longer reports a race and the blocked Read is still unblocked with the expected close result.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- cli
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100