Files
gomoku/server/network/dispatch.go
T
tiennm99 03758b0e90 fix(exit): route ClientExit through state machine, keep WS alive
Bug: after clicking "Leave" on the game-over modal, the client landed
on the nickname entry screen and got stuck — typing a nickname produced
no response for up to 90 seconds.

Root cause chain:
1. Server handleClientExit (stateless) closed SendCh and nilled it but
   did not close the WS connection. Writer goroutine drained + exited
   without closing the conn; reader stayed blocked on ReadMessage; state
   machine stayed blocked on CmdCh. Zombie session.
2. Any subsequent player.Send() returned nil (SendCh was nil), silently
   dropping responses. The setNickname reply never reached the client.
3. Client only unstuck when reader's 90 s readDeadline fired, finally
   closing the WS and triggering the auto-reconnect path.
4. Client MenuScene.create() always called showNicknameScreen() without
   checking gameState.nickname, so even after the reconnect the user
   had to re-type their name.

Fix: "exit" now means "leave current room, return to home lobby" —
not "terminate WS session".

Server:
- dispatch.go: move Request_ClientExit from stateless to stateful path.
- handlers_stateless.go: delete handleClientExit entirely.
- waiting.go leaveRoom: broadcast ClientExitResponse via broadcastResponse
  BEFORE removing the player, so the exiting player also receives their
  own exit confirmation. Removes duplicated self-notification logic.
- waiting/gamePvp/gamePve/gameover/watching: on ClientExitRequest, call
  leaveRoom + return consts.StateHome (was return 0, ErrClientExit).
- home.go: on ClientExitRequest, send bare ClientExitResponse and stay
  in home (no room to leave).
- Update 4 state tests that asserted ErrClientExit → now assert StateHome.
- Rename TestWatching_ClientExitReturnsErrClientExit → ReturnsHome.

Client:
- menu-scene.js create(): if gameState.nickname is set, skip nickname
  screen and call showLobby() directly. Otherwise showNicknameScreen().
- game-scene.js _onClientExit: drop the explicit showLobby() call; let
  MenuScene.create() decide the panel based on nickname state. Remove
  now-unused menu-ui showLobby import.

Result: click Leave → server sends ClientExitResponse, state machine
transitions to StateHome, WS stays open, client transitions to
MenuScene → sees gameState.nickname is set → goes straight to lobby.
No reconnect, no re-typing, no 90-second stall.

go vet + go test ./... green. npm run build green.
2026-04-11 16:51:13 +07:00

68 lines
2.2 KiB
Go

package network
import (
"github.com/tiennm99/gomoku/server/lobby"
"github.com/tiennm99/gomoku/server/pkg/log"
"github.com/tiennm99/gomoku/server/protocol"
)
// Dispatch routes an incoming Request to the appropriate handler.
//
// Stateless requests (heartbeat, get_rooms, set_nickname, set_client_info)
// are handled inline on the reader goroutine — they must return fast.
//
// Stateful requests (create_room, join_room, game_move, client_exit, etc.)
// are pushed onto player.CmdCh for the state machine goroutine to consume
// in order.
//
// Overflow policy: if CmdCh is full, the request is logged and dropped rather
// than blocking the reader goroutine (prevents backpressure deadlocks).
func Dispatch(player *lobby.Player, req *protocol.Request) {
switch req.Payload.(type) {
// --- Stateless handlers (inline) ---
case *protocol.Request_Heartbeat:
handleHeartbeat(player, req)
case *protocol.Request_SetNickname:
handleSetNickname(player, req)
case *protocol.Request_GetRooms:
handleGetRooms(player, req)
case *protocol.Request_SetClientInfo:
handleSetClientInfo(player, req)
// --- Stateful handlers (pushed to cmdCh for state machine) ---
// ClientExit is stateful: it must leave the current room and transition
// the state machine back to home, keeping the WS alive.
case *protocol.Request_CreateRoom,
*protocol.Request_CreatePveRoom,
*protocol.Request_JoinRoom,
*protocol.Request_GameStarting,
*protocol.Request_GameMove,
*protocol.Request_GameReset,
*protocol.Request_WatchGame,
*protocol.Request_WatchGameExit,
*protocol.Request_ClientExit:
pushToCmdCh(player, req)
default:
log.Errorf("[dispatch] player %d: unhandled request type %T, dropping\n", player.ID, req.Payload)
}
}
// pushToCmdCh enqueues req on player.CmdCh without blocking.
// If the channel is full or nil, the request is dropped with a warning.
func pushToCmdCh(player *lobby.Player, req *protocol.Request) {
if player.CmdCh == nil {
log.Errorf("[dispatch] player %d: CmdCh is nil, dropping %T\n", player.ID, req.Payload)
return
}
select {
case player.CmdCh <- req:
default:
log.Errorf("[dispatch] player %d: CmdCh full (cap %d), dropping %T\n",
player.ID, cap(player.CmdCh), req.Payload)
}
}