diff options
| author | historia <[not public]> | 2026-09-15 20:14:02 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-15 20:14:02 -0400 |
| commit | 69235cac5b3da5ff0dc20310e7474cabd8e63ba3 (patch) | |
| tree | ce96c9506531e219c157f29ac6a6dc53de1df15b /internal/game | |
| parent | 6f3c43aca62a7d33a19f14f6ad7c16891952673a (diff) | |
| download | thehouseoficarus-69235cac5b3da5ff0dc20310e7474cabd8e63ba3.tar.gz | |
feat: route all html editor API mutations through single owner task
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/admin_owner_test.go | 27 | ||||
| -rw-r--r-- | internal/game/exit_migrate.go | 14 | ||||
| -rw-r--r-- | internal/game/owner.go | 31 |
3 files changed, 67 insertions, 5 deletions
diff --git a/internal/game/admin_owner_test.go b/internal/game/admin_owner_test.go new file mode 100644 index 0000000..e592660 --- /dev/null +++ b/internal/game/admin_owner_test.go @@ -0,0 +1,27 @@ +package game + +import "testing" + +func TestRunAdminOnOwnerRejectsShutdown(t *testing.T) { + g := &Game{owner: newOwner()} + defer g.stopOwner() + if !g.RunAdminOnOwner(func() {}) { + t.Fatal("running owner rejected task") + } + g.setShuttingDown() + if g.RunAdminOnOwner(func() { t.Error("task ran during shutdown") }) { + t.Error("shutdown accepted task") + } + g.stopOwner() + if g.RunAdminOnOwner(func() { t.Error("task ran after owner stopped") }) { + t.Error("stopped owner accepted task") + } +} + +func TestRunAdminOnOwnerNeverFallsBackInline(t *testing.T) { + g := &Game{owner: newOwner()} + g.stopOwner() + if g.RunAdminOnOwner(func() { t.Error("task ran inline after stop") }) { + t.Error("stopped owner accepted task") + } +} diff --git a/internal/game/exit_migrate.go b/internal/game/exit_migrate.go index db70c8d..c678591 100644 --- a/internal/game/exit_migrate.go +++ b/internal/game/exit_migrate.go @@ -152,7 +152,7 @@ func (g *Game) RewriteRoomIDs(idMap map[int]int) { } // MigrateRoomIDs is the complete room-ID migration entry point for other -// goroutines (the admin API and swapid command): player state, Hub room +// goroutines: player state, Hub room // buckets, casino room keys, world room-keyed runtime state, and live mob // instances all move together under one owner task. This is what makes a // live rename leave NO gameplay or broadcast attached to the old location. @@ -161,13 +161,17 @@ func (g *Game) MigrateRoomIDs(idMap map[int]int) { return } g.RunOnOwner(func() { - g.migrateRoomIDsLocked(idMap) + g.MigrateRoomIDsOnOwner(idMap) }) } -// migrateRoomIDsLocked performs the full runtime migration; caller must -// already be on the owner goroutine. -func (g *Game) migrateRoomIDsLocked(idMap map[int]int) { +// MigrateRoomIDsOnOwner performs the full runtime migration within an existing +// owner task (e.g. the admin editor's file commit + migration + reload task). +// It must not dispatch again: a nested RunOnOwner would deadlock. +func (g *Game) MigrateRoomIDsOnOwner(idMap map[int]int) { + if len(idMap) == 0 { + return + } if g.Hub != nil { g.Hub.RekeyRooms(idMap) } diff --git a/internal/game/owner.go b/internal/game/owner.go index 2fb4777..4a3646e 100644 --- a/internal/game/owner.go +++ b/internal/game/owner.go @@ -122,6 +122,37 @@ func (g *Game) run(fn func()) { <-task } +// RunAdminOnOwner synchronously executes one complete editor request. Unlike +// RunOnOwner it rejects work during shutdown and NEVER falls back to executing +// on the HTTP goroutine when the owner has stopped. Call only from outside the +// owner; runtime callbacks within fn must call owner-only methods directly. +func (g *Game) RunAdminOnOwner(fn func()) bool { + if g.isShuttingDown() { + return false + } + accepted := false + task := func() { + if !g.isShuttingDown() { + fn() + accepted = true + } + } + if g.owner == nil { + // Synthetic Games only; production always has an owner. + task() + return accepted + } + done := make(chan struct{}) + if !g.owner.enqueue(func() { + task() + close(done) + }) { + return false + } + <-done + return accepted +} + // tryRun executes fn on the owner if it is currently idle-draining; it is // not currently used for correctness paths but exists for future non-blocking // administrative reads. It returns false when the queue is full. |
