aboutsummaryrefslogtreecommitdiff
path: root/internal/game/owner.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/owner.go')
-rw-r--r--internal/game/owner.go54
1 files changed, 53 insertions, 1 deletions
diff --git a/internal/game/owner.go b/internal/game/owner.go
index 4a3646e..8546d10 100644
--- a/internal/game/owner.go
+++ b/internal/game/owner.go
@@ -1,6 +1,12 @@
package game
-import "sync"
+import (
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+)
// owner is the single execution authority for live game state. Every
// mutation of players, sessions, the Hub membership, runtime maps, and all
@@ -27,6 +33,12 @@ type owner struct {
closed bool
tasks chan func()
done chan struct{}
+ // goid is the goroutine ID of the owner loop, recorded once the loop
+ // starts. Re-entrancy checks (Game.executingOnOwner) compare it to the
+ // CALLER's goroutine ID so only code actually running on the owner
+ // detects itself — an "owner is busy" flag would wrongly let outside
+ // callers bypass the queue while a task is in flight.
+ goid atomic.Uint64
}
const ownerTaskQueue = 4096
@@ -42,6 +54,7 @@ func newOwner() *owner {
}
func (o *owner) loop() {
+ o.goid.Store(currentGoroutineID())
defer close(o.done)
for task := range o.tasks {
task()
@@ -102,6 +115,16 @@ func (o *owner) stop() {
// exists (synthetic Games in unit tests) fn runs inline on the caller's
// goroutine; production Games always have one, started by New.
func (g *Game) run(fn func()) {
+ // Already running on the owner goroutine itself (e.g. a tick callback
+ // re-entering through a Hub removal callback): run inline, exactly like
+ // owner code calling functions directly. Re-submitting would deadlock
+ // waiting for the running task to finish — itself. Only true identity
+ // passes: an outside caller while the owner is merely BUSY still
+ // queues (and blocks), preserving the single-owner invariant.
+ if g.executingOnOwner() {
+ fn()
+ return
+ }
o := g.owner
if o == nil {
fn()
@@ -165,6 +188,35 @@ func (g *Game) tryRun(fn func()) bool {
return o.submit(fn)
}
+// currentGoroutineID returns the calling goroutine's ID parsed from its
+// stack header ("goroutine 123 [running]:"), 0 when unparseable.
+func currentGoroutineID() uint64 {
+ var buf [64]byte
+ n := runtime.Stack(buf[:], false)
+ s := strings.TrimPrefix(string(buf[:n]), "goroutine ")
+ end := strings.IndexByte(s, ' ')
+ if end <= 0 {
+ return 0
+ }
+ id, err := strconv.ParseUint(s[:end], 10, 64)
+ if err != nil {
+ return 0
+ }
+ return id
+}
+
+// executingOnOwner reports whether the CALLING goroutine is the owner loop
+// goroutine. Owner-executed code must invoke callbacks directly instead of
+// re-submitting through run(), which would deadlock waiting for itself.
+func (g *Game) executingOnOwner() bool {
+ o := g.owner
+ if o == nil {
+ return false
+ }
+ id := o.goid.Load()
+ return id != 0 && id == currentGoroutineID()
+}
+
// stopOwner drains and stops the owner loop. Called once during graceful
// shutdown after all entry points have stopped submitting.
func (g *Game) stopOwner() {