Data race: the position readers guard the decoder with the speaker lock, which the read-ahead goroutine does not take #127

Closed
opened 2026-08-19 12:47:45 +00:00 by yonlu · 1 comment
Owner

Findings

Found by the race detector while adding a test for #123 that loads a file and lets the position be emitted while the read-ahead goroutine is running. The existing suite never does this — TestPlayer is behind YELLOWJACKET_INTEGRATION — so make test is green and the race is real:

WARNING: DATA RACE
Write at 0x00c0002c6030 by goroutine 12:
  github.com/gopxl/beep/v2/mp3.(*decoder).Stream()
  yellowjacket/backend/player.(*BufferedStreamer).readAhead()

Previous read at 0x00c0002c6030 by goroutine 9:
  github.com/gopxl/beep/v2/mp3.(*decoder).Position()
  yellowjacket/backend/player.(*Player).displayPositionSecsLocked()
  yellowjacket/backend/player.(*Player).emitPositionLocked()
  yellowjacket/backend/player.(*Player).loadFileLocked()

BufferedStreamer.srcMu exists precisely because the decoder is not safe for concurrent use, and seekLocked/rewindLocked take it through LockSource before touching the seeker. The position reads do not. displayPositionSecsLocked (player.go:1244), trackLengthLocked and CurrentPosition guard p.seeker.Position()/Len() with speaker.Lock() — a lock the read-ahead goroutine has no reason to hold and never takes. So the guard is against the wrong writer.

This runs on every position emit, which is once a second for the whole of playback, against a goroutine reading the same decoder continuously. The consequence in go-mp3 is a torn read of the decoder's position — a bogus seek-bar reading — and in principle anything, since it is undefined behaviour.

Why it is awkward to fix: seekLocked already holds srcMu for the duration of the seek and then calls emitPositionLocked inside that region, so simply taking LockSource in the position read deadlocks on a non-reentrant mutex. The guarded region needs extracting so the lock is released before the emit.

Direction

Extract the seek-and-flush part of seekLocked into a helper that owns srcMu and returns before anything is emitted, then guard the three position readers with LockSource/UnlockSource. Keep the srcMuspeaker.Lock ordering the existing callers already use.

**Findings** Found by the race detector while adding a test for #123 that loads a file and lets the position be emitted while the read-ahead goroutine is running. The existing suite never does this — `TestPlayer` is behind `YELLOWJACKET_INTEGRATION` — so `make test` is green and the race is real: ``` WARNING: DATA RACE Write at 0x00c0002c6030 by goroutine 12: github.com/gopxl/beep/v2/mp3.(*decoder).Stream() yellowjacket/backend/player.(*BufferedStreamer).readAhead() Previous read at 0x00c0002c6030 by goroutine 9: github.com/gopxl/beep/v2/mp3.(*decoder).Position() yellowjacket/backend/player.(*Player).displayPositionSecsLocked() yellowjacket/backend/player.(*Player).emitPositionLocked() yellowjacket/backend/player.(*Player).loadFileLocked() ``` `BufferedStreamer.srcMu` exists precisely because the decoder is not safe for concurrent use, and `seekLocked`/`rewindLocked` take it through `LockSource` before touching the seeker. **The position reads do not.** `displayPositionSecsLocked` (`player.go:1244`), `trackLengthLocked` and `CurrentPosition` guard `p.seeker.Position()`/`Len()` with `speaker.Lock()` — a lock the read-ahead goroutine has no reason to hold and never takes. So the guard is against the wrong writer. This runs on every position emit, which is once a second for the whole of playback, against a goroutine reading the same decoder continuously. The consequence in `go-mp3` is a torn read of the decoder's position — a bogus seek-bar reading — and in principle anything, since it is undefined behaviour. **Why it is awkward to fix**: `seekLocked` already holds `srcMu` for the duration of the seek and *then* calls `emitPositionLocked` inside that region, so simply taking `LockSource` in the position read deadlocks on a non-reentrant mutex. The guarded region needs extracting so the lock is released before the emit. **Direction** Extract the seek-and-flush part of `seekLocked` into a helper that owns `srcMu` and returns before anything is emitted, then guard the three position readers with `LockSource`/`UnlockSource`. Keep the `srcMu` → `speaker.Lock` ordering the existing callers already use.
yonlu self-assigned this 2026-08-19 12:47:51 +00:00
yonlu added the
Status
In Progress
label 2026-08-19 12:47:52 +00:00
Author
Owner

Same branch as #122-#126 (fix/player-playing-state) — it blocks the test added for #123, which is what found it.

Approach: extract the srcMu-owning part of seekLocked so the lock is released before emitPositionLocked runs, then guard displayPositionSecsLocked, trackLengthLocked and CurrentPosition with LockSource/UnlockSource, keeping the existing srcMu -> speaker.Lock ordering. Verified by make test, which runs the race detector.

Same branch as #122-#126 (`fix/player-playing-state`) — it blocks the test added for #123, which is what found it. Approach: extract the srcMu-owning part of `seekLocked` so the lock is released before `emitPositionLocked` runs, then guard `displayPositionSecsLocked`, `trackLengthLocked` and `CurrentPosition` with `LockSource`/`UnlockSource`, keeping the existing `srcMu` -> `speaker.Lock` ordering. Verified by `make test`, which runs the race detector.
logan closed this issue 2026-08-19 14:53:46 +00:00
gitea-actions bot removed the
Status
In Progress
label 2026-08-19 14:55:14 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: yonlu/yellowjacket#127