From 153acb7f644d755c45533b7c5311e3ffa47e8973 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 05:31:09 -0400 Subject: fix: harden engine across casino, economy, concurrency, security, and content --- .../game/correct_owner_panic_subscription_test.go | 85 ++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 internal/game/correct_owner_panic_subscription_test.go (limited to 'internal/game/correct_owner_panic_subscription_test.go') diff --git a/internal/game/correct_owner_panic_subscription_test.go b/internal/game/correct_owner_panic_subscription_test.go new file mode 100644 index 0000000..f535dcc --- /dev/null +++ b/internal/game/correct_owner_panic_subscription_test.go @@ -0,0 +1,85 @@ +package game + +// Regression: the owner loop contains a panic in a tick callback, so the tick +// engine no longer sees it. SubscribeTicks must therefore treat "the callback +// did not run to completion" as KEEP - a false return unsubscribes the system, +// and the master subscriber main.go registers (RunTickSystems) is what drains +// queued commands, settles casino rounds, and cleans up disconnects. Likewise +// RunAdminOnOwner must not report a contained panic as "game is shutting down". + +import ( + "testing" + + "thehouseoficarus/internal/engine" +) + +func TestSubscribeTicksKeepsSubscriptionWhenCallbackPanics(t *testing.T) { + g := &Game{owner: newOwner()} + g.Ticks = engine.New() + t.Cleanup(g.stopOwner) + + calls := 0 + g.SubscribeTicks(1, func() bool { + calls++ + if calls == 1 { + panic("tick system failure") + } + return false + }) + + g.Ticks.Tick() + // The panicking task's runTask (recover, log, counter) has not finished + // when the blocked caller is released, so drain the queue before reading + // the counter: the owner processes tasks FIFO. + g.run(func() {}) + if got := g.owner.panics.Load(); got != 1 { + t.Fatalf("contained panics = %d, want 1", got) + } + if calls != 1 { + t.Fatalf("callback calls = %d, want 1", calls) + } + + // The subscription must still be live: a deleted one never fires again, + // which would leave the world frozen while the process keeps running. + g.Ticks.Tick() + if calls != 2 { + t.Fatalf("a contained panic unsubscribed the system: calls = %d, want 2", calls) + } + + // An explicit false must still unsubscribe. + g.Ticks.Tick() + if calls != 2 { + t.Fatalf("explicit false did not unsubscribe: calls = %d, want 2", calls) + } +} + +func TestRunAdminOnOwnerReportsContainedPanicToCaller(t *testing.T) { + g := &Game{owner: newOwner()} + t.Cleanup(g.stopOwner) + + func() { + defer func() { + if r := recover(); r != adminTaskPanic { + t.Errorf("recovered %v, want the contained-task-panic value", r) + } + }() + if g.RunAdminOnOwner(func() { panic("editor task failure") }) { + t.Error("a panicking editor task was reported as accepted") + } + t.Fatal("a contained panic was reported as a normal result") + }() + + g.run(func() {}) + if got := g.owner.panics.Load(); got != 1 { + t.Errorf("contained panics = %d, want 1", got) + } + // The owner loop must still be draining, and the game must not have been + // flagged as shutting down by the panic. + ran := false + if !g.RunAdminOnOwner(func() { ran = true }) { + t.Fatal("the owner stopped accepting editor tasks after a contained panic") + } + if !ran { + t.Fatal("the owner loop did not execute later editor tasks") + } +} -- cgit v1.2.3