aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-15 20:14:02 -0400
committerhistoria <[not public]>2026-09-15 20:14:02 -0400
commit69235cac5b3da5ff0dc20310e7474cabd8e63ba3 (patch)
treece96c9506531e219c157f29ac6a6dc53de1df15b /internal/game
parent6f3c43aca62a7d33a19f14f6ad7c16891952673a (diff)
downloadthehouseoficarus-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.go27
-rw-r--r--internal/game/exit_migrate.go14
-rw-r--r--internal/game/owner.go31
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.