diff options
Diffstat (limited to 'internal/game/owner.go')
| -rw-r--r-- | internal/game/owner.go | 54 |
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() { |
