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):
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):
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.
Emulator.ReadandEmulator.Closeboth touch the unexportedclosed boolwith no synchronization, andSafeEmulatordoesn't protect it either — so callingClose()to unblock a goroutine parked inRead()is a data race.Emulator.Readreadsclosed, then blocks on the pipe (vt/emulator.go:251):Emulator.Closewrites it (vt/emulator.go:260):SafeEmulator.Readdeliberately takes no lock (so it can block without holding the mutex across the read), and there's noSafeEmulator.Closeoverride, so the embeddedEmulator.Closealso runs unlocked (vt/safe_emulator.go:33).The underlying
io.Pipeis already concurrency-safe —pw.CloseWithError(io.EOF)correctly unblocks a blockedpr.Read. The only unsafe shared state is theclosedflag.Impact
The natural teardown pattern — one goroutine draining
Read(), another callingClose()to unblock it — tripsgo test -racewith a data race onclosed(read atemulator.go:252, write atemulator.go:265). Consumers are forced to choose between leaking theReadgoroutine or accepting the race.Suggested fix
Make
closedanatomic.Bool(load inRead/Write,SwapinClose) — or drop theclosedshort-circuit entirely and rely on the pipe (a closed pipe already makespr.Readreturn the close error andpw.WritereturnErrClosedPipe). Either way no mutex needs to be held across the blocking read.Observed on
vtmodule versionv0.0.0-20260323091123-df7b1bcffcca.