charmbracelet / charmbracelet/x

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

Open
#879 1 comment 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.