diff options
| author | historia <[not public]> | 2026-09-15 15:35:52 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-15 15:35:52 -0400 |
| commit | 0b0b25222b3131ada79ff78c28eb8bf07d977deb (patch) | |
| tree | 2ceaef5203595044f66a8f00064cd8e017f8f48d /internal/game/owner_concurrency_test.go | |
| parent | 396c3facb8287b9a34076cae8f3d7b300d366d99 (diff) | |
| download | thehouseoficarus-0b0b25222b3131ada79ff78c28eb8bf07d977deb.tar.gz | |
feat: single-owner game state, transactional lifecycle, durable money on server stop
Diffstat (limited to 'internal/game/owner_concurrency_test.go')
| -rw-r--r-- | internal/game/owner_concurrency_test.go | 154 |
1 files changed, 154 insertions, 0 deletions
diff --git a/internal/game/owner_concurrency_test.go b/internal/game/owner_concurrency_test.go new file mode 100644 index 0000000..58e426b --- /dev/null +++ b/internal/game/owner_concurrency_test.go @@ -0,0 +1,154 @@ +package game + +// Owner-loop concurrency regressions. These exercise the competing runtime +// entry points (connection input, ticks, disconnects, admin mutations) the +// way production does and must pass under -race now that all live-state +// mutation executes on the owner goroutine. The pre-owner baselines live in +// baseline_race_test.go behind the baselinerace build tag. + +import ( + "sync" + "testing" + "time" +) + +// TestOwnerSerializesEntryPoints runs session input, the ticker goroutine, +// and hub disconnects concurrently. +func TestOwnerSerializesEntryPoints(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + h.seedAccount("bob", "pw", false) + h.seedCharacter("alice", "alice") + h.seedCharacter("bob", "bob") + + aSess, _ := h.connect("alice") + bSess, _ := h.connect("bob") + + aLines := h.goInput(aSess, 1) + bLines := h.goInput(bSess, 1) + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for i := 0; i < 200; i++ { + aLines <- "look" + } + close(aLines) + }() + go func() { + defer wg.Done() + for i := 0; i < 200; i++ { + bLines <- "say hi" + } + close(bLines) + }() + + go func() { + for i := 0; i < 50; i++ { + h.hub.PlayersInRoom(1) + if i == 25 { + h.hub.Remove(bSess) + } + } + }() + + wg.Wait() + h.barrier() +} + +// TestOwnerAdminRewriteWhilePlaying runs Game.RewriteRoomIDs (admin entry) +// concurrently with live movement and ticking. +func TestOwnerAdminRewriteWhilePlaying(t *testing.T) { + h := newHarness(t) + h.seedAccount("carol", "pw", false) + h.seedCharacter("carol", "carol") + sess, _ := h.connect("carol") + lines := h.goInput(sess, 1) + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for i := 0; i < 100; i++ { + lines <- "look" + } + close(lines) + }() + go func() { + defer wg.Done() + // Rewrite 2->4, then back; exercise both directions while the + // player commands run. + for i := 0; i < 10; i++ { + h.g.RunOnOwner(func() { h.g.RewriteRoomIDs(map[int]int{2: 4}) }) + h.g.RunOnOwner(func() { h.g.RewriteRoomIDs(map[int]int{4: 2}) }) + } + }() + wg.Wait() + h.barrier() +} + +// TestSlowClientDoesNotStallTheWorld proves the write-queue boundary: a +// session whose transport blocks forever on Write must not prevent other +// players from receiving output and progressing. +func TestSlowClientDoesNotStallTheWorld(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + h.seedAccount("bob", "pw", false) + h.seedCharacter("alice", "alice") + h.seedCharacter("bob", "bob") + + // Alice's writer goroutine blocks at the transport (full socket buffer + // equivalent); her queue absorbs or disconnects her, but never blocks + // the owner goroutine. + aSess, aConn := h.connect("alice") + bSess, bConn := h.connect("bob") + aConn.writeGate = make(chan struct{}) + + bLines := h.goInput(bSess, 1) + + for i := 0; i < 300; i++ { + aSess.WriteLine("spam for the stalled client") + } + // Bob keeps playing and must still see output while alice is stuck. + bLines <- "say hello" + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if bConn.Contains("hello") { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("bob's output never arrived while alice's client was stalled") +} + +// TestOwnerDisconnectDuringInput removes a session from a separate goroutine +// while its input goroutine is mid-stream, proving the remove callback +// (save + registry cleanup) serializes with session input on the owner. +func TestOwnerDisconnectDuringInput(t *testing.T) { + h := newHarness(t) + h.seedAccount("dave", "pw", false) + h.seedCharacter("dave", "dave") + sess, conn := h.connect("dave") + + lines := h.goInput(sess, 1) + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 50; i++ { + lines <- "look" + } + close(lines) + }() + // Hard-remove while input is streaming; the onRemove callback mutates + // player state and must not race with HandleSession. + for i := 0; i < 50; i++ { + h.hub.PlayersInRoom(1) + if i == 10 { + h.hub.HardRemove(sess) + } + } + <-done + h.barrier() + _ = conn +} |
