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") h.seedAccount("bob") h.seedCharacter("alice", "alice") h.seedCharacter("bob", "bob") aSess, _ := h.connect("alice") bSess, _ := h.connect("bob") aLines := h.goInput(aSess) bLines := h.goInput(bSess) 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") h.seedCharacter("carol", "carol") sess, _ := h.connect("carol") lines := h.goInput(sess) 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") h.seedAccount("bob") 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) 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") h.seedCharacter("dave", "dave") sess, conn := h.connect("dave") lines := h.goInput(sess) 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 }