diff --git a/.github/workflows/steam.sh b/.github/workflows/steam.sh index a170f8eea..f35609665 100644 --- a/.github/workflows/steam.sh +++ b/.github/workflows/steam.sh @@ -10,4 +10,4 @@ rm -rf /usr/local/go && tar -C /usr/local -xzf ${GO_FILENAME} Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & # Run the tests -go test -v ./... +env GITHUB_ACTIONS=true go test -v ./... diff --git a/audio/audio.go b/audio/audio.go index 3d163c89c..a863ecc73 100644 --- a/audio/audio.go +++ b/audio/audio.go @@ -198,22 +198,37 @@ func (c *Context) removePlayer(p *playerImpl) { } func (c *Context) gcPlayers() error { + // A Context must not call playerImpl's functions with a lock, or this causes a deadlock (#2737). + // Copy the playerImpls and iterate them without a lock. + var players []*playerImpl c.m.Lock() - defer c.m.Unlock() + players = make([]*playerImpl, 0, len(c.players)) + for p := range c.players { + players = append(players, p) + } + c.m.Unlock() + + var playersToRemove []*playerImpl // Now reader players cannot call removePlayers from themselves in the current implementation. // Underlying playering can be the pause state after fishing its playing, // but there is no way to notify this to players so far. // Instead, let's check the states proactively every frame. - for p := range c.players { + for _, p := range players { if err := p.Err(); err != nil { return err } if !p.IsPlaying() { - delete(c.players, p) + playersToRemove = append(playersToRemove, p) } } + c.m.Lock() + for _, p := range playersToRemove { + delete(c.players, p) + } + c.m.Unlock() + return nil } diff --git a/internal/processtest/testdata/issue2737.go b/internal/processtest/testdata/issue2737.go new file mode 100644 index 000000000..045909334 --- /dev/null +++ b/internal/processtest/testdata/issue2737.go @@ -0,0 +1,140 @@ +// Copyright 2023 The Ebitengine Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build ignore + +package main + +import ( + "errors" + "io" + "os" + "runtime" + "strings" + "sync" + "time" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/audio" +) + +type emptyStream struct { + length int64 + n int64 +} + +func (e *emptyStream) Read(buf []byte) (int, error) { + n := int64(len(buf)) + if e.n+n >= e.length { + n := e.length - e.n + e.n = e.length + return int(n), io.EOF + } + e.n += n + return int(n), nil +} + +func (e *emptyStream) Seek(offset int64, whence int) (int64, error) { + switch whence { + case io.SeekStart: + e.n = offset + case io.SeekCurrent: + e.n += offset + case io.SeekEnd: + e.n = e.length + offset + } + if e.n > e.length || e.n < 0 { + return 0, errors.New("out of range") + } + return e.n, nil +} + +type Game struct { + playerCount int + finishedPlayerCount int + tickCount int + + m sync.Mutex +} + +func (g *Game) countUpFinishedPlayer() { + g.m.Lock() + defer g.m.Unlock() + g.finishedPlayerCount++ +} + +func (g *Game) Update() error { + g.tickCount++ + if g.tickCount > 600 { + return errors.New("time out") + } + + g.m.Lock() + c := g.finishedPlayerCount + g.m.Unlock() + if g.playerCount == c { + return ebiten.Termination + } + return nil +} + +func (g *Game) Draw(screen *ebiten.Image) { +} + +func (g *Game) Layout(width, height int) (int, int) { + return width, height +} + +func main() { + // Drivers might not be available, especially on Linux on GitHub Actions. + // TODO: Enable this by install a dummy driver. + if strings.TrimSpace(os.Getenv("GITHUB_ACTIONS")) == "true" && runtime.GOOS == "linux" { + return + } + + game := &Game{ + playerCount: 1000, + } + + ctx := audio.NewContext(48000) + var players []*audio.Player + for i := 0; i < game.playerCount; i++ { + p, err := ctx.NewPlayer(&emptyStream{length: 48000 * 2 * 2}) + if err != nil { + panic(err) + } + players = append(players, p) + + // Play players in different goroutines from the game's goroutine in order to call the context's gcPlayers and addPlayer + // at the same time. + go func() { + p.Play() + for i := 0; i < 3; i++ { + for { + if !p.IsPlaying() { + p.Rewind() + p.Play() + break + } + time.Sleep(100 * time.Millisecond) + } + } + game.countUpFinishedPlayer() + }() + } + + if err := ebiten.RunGame(game); err != nil { + panic(err) + } +}