test(wsapi): cover ghost seats, drain refusals and a silent resume

Direct room-level tests for the torn-down-connection race on create,
join, quick match's auto-start, start-bot and resume, plus the
non-resumable-token answer and freezeBoard's new signature after
UsedWords replaced its history reconstruction.
This commit is contained in:
tiennm99 committed 2026-09-21 16:14:12 +07:00
1 parent 8b3e8f7e67
commit b1b6e4ba92
1 file changed
+155 -1
+155 -1
View File
@@ -440,6 +440,122 @@ func TestABotGameOpensWithTheHuman(t *testing.T) {
}
}
// TestCreateOpensGraceWindowForATornDownConnection covers the race between a
// connection dying and the room draining the message that seats it: nothing
// else would ever tell this room the creator is gone, since leaveRoom only
// notifies a room the session had already attached to.
func TestCreateOpensGraceWindowForATornDownConnection(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
dead := &session{id: "dead", ctx: ctx, out: make(chan []byte, 1)}
r := &room{hub: &hub{}, dict: chainDict(), turnLimit: time.Second, graceFor: time.Minute}
r.handleCreate(createInput{sess: dead})
s := r.seats[0]
if s == nil {
t.Fatal("handleCreate did not seat the creator")
}
if s.sess != nil {
t.Error("a torn-down connection was left looking connected")
}
if s.graceUntil.IsZero() {
t.Error("no grace window was opened for the torn-down connection")
}
}
// TestJoinOpensGraceWindowForATornDownConnection is the same race on the
// other seating path: without this, allConnected() would report true for a
// seat nobody is behind.
func TestJoinOpensGraceWindowForATornDownConnection(t *testing.T) {
live := &session{id: "live", ctx: context.Background(), out: make(chan []byte, 8)}
ctx, cancel := context.WithCancel(context.Background())
cancel()
dead := &session{id: "dead", ctx: ctx, out: make(chan []byte, 1)}
r := &room{hub: &hub{}, dict: chainDict(), turnLimit: time.Second, graceFor: time.Minute}
r.handleCreate(createInput{sess: live})
r.handleJoin(joinInput{sess: dead})
s := r.seats[1]
if s == nil {
t.Fatal("handleJoin did not seat the second player")
}
if s.sess != nil {
t.Error("a torn-down connection was left looking connected")
}
if r.allConnected() {
t.Error("allConnected must not report true with a ghost seat behind it")
}
}
// TestQuickMatchAutoStartSkipsAGhostSeat is the consequence C1 warns about: a
// real player auto-started into a game against a dead socket burns the whole
// turn clock before anyone notices. allConnected() reporting the ghost seat
// honestly is what keeps this from ever reaching beginGame.
func TestQuickMatchAutoStartSkipsAGhostSeat(t *testing.T) {
live := &session{id: "live", ctx: context.Background(), out: make(chan []byte, 8)}
ctx, cancel := context.WithCancel(context.Background())
cancel()
dead := &session{id: "dead", ctx: ctx, out: make(chan []byte, 1)}
r := &room{hub: &hub{}, dict: chainDict(), turnLimit: time.Second, graceFor: time.Minute}
r.handleCreate(createInput{sess: live, autoStart: true})
r.handleJoin(joinInput{sess: dead})
if r.engine != nil {
t.Error("a game was auto-started against a connection that had already torn down")
}
}
// TestStartBotCancelsTheRoomForATornDownConnection: a bot room has no lobby to
// wait in and no idle timer while it has no engine yet, so a ghost seat here
// must end the room outright rather than being left to a grace window that
// nothing would ever clear.
func TestStartBotCancelsTheRoomForATornDownConnection(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
dead := &session{id: "dead", ctx: ctx, out: make(chan []byte, 1)}
hctx, hcancel := context.WithCancel(context.Background())
defer hcancel()
r := newRoom(&hub{ctx: hctx}, "AAAAAA", time.Second, time.Second, time.Minute, roomModeBot)
r.dict = chainDict()
r.handleStartBot(startBotInput{sess: dead, difficulty: bot.Easy})
if r.ctx.Err() == nil {
t.Error("a room seated only by a torn-down connection must be cancelled")
}
if r.engine != nil {
t.Error("a bot game was started against a connection that had already torn down")
}
}
// TestResumeOpensGraceWindowForATornDownConnection covers the same race on
// the resume path: the new connection presenting the token can die before the
// room drains the resumeInput it produced.
func TestResumeOpensGraceWindowForATornDownConnection(t *testing.T) {
live := &session{id: "live", ctx: context.Background(), out: make(chan []byte, 8)}
ctx, cancel := context.WithCancel(context.Background())
cancel()
dead := &session{id: "dead", ctx: ctx, out: make(chan []byte, 1)}
r := &room{hub: &hub{}, dict: chainDict(), turnLimit: time.Second, graceFor: time.Minute}
r.handleCreate(createInput{sess: live})
r.seats[0].sess = nil
r.seats[0].graceUntil = time.Now().Add(time.Minute)
r.handleResume(resumeInput{player: "p1", sess: dead})
s := r.seats[0]
if s.sess != nil {
t.Error("a torn-down resuming connection was left looking connected")
}
if s.graceUntil.IsZero() {
t.Error("no grace window was reopened for the torn-down resuming connection")
}
}
// TestPvPGameAlternatesTurns runs two clients through a full game and checks
// that each sees the other's move rendered from its own side.
func TestPvPGameAlternatesTurns(t *testing.T) {
@@ -706,6 +822,44 @@ func TestProtocolVersionMismatchIsRefused(t *testing.T) {
}
}
// TestUnknownResumeTokenIsAnsweredNotSilent: a token the server never
// registered, or has already forgotten past its grace window, used to get
// silence. The client's own resume latch then waited forever for a reply that
// was never coming — this is the fix, and the connection must still be usable
// afterward as the fresh session it is.
func TestUnknownResumeTokenIsAnsweredNotSilent(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
c := dial(t, url)
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{
ProtocolVersion: ProtocolVersion,
Nickname: "Người chơi",
ResumeToken: "no-such-token",
}}})
c.await("welcome")
if code := c.await("error").GetError().GetCode(); code != "session_not_resumable" {
t.Errorf("error code = %q, want session_not_resumable", code)
}
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
if code := c.await("room_state").GetRoomState().GetRoomCode(); code == "" {
t.Error("a connection answered session_not_resumable must still be usable as a fresh session")
}
}
// TestFreshHelloIsNotToldItCannotResume: a Hello with no resume token at all
// is not a resume attempt, and must not be answered as a failed one.
func TestFreshHelloIsNotToldItCannotResume(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
c := dial(t, url)
c.hello("Người chơi")
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
if m := c.recv(); payloadCase(m) == "error" {
t.Fatalf("a fresh Hello with no resume token got %q, want none", m.GetError().GetCode())
}
}
// TestHandshakeIsRequiredFirst rejects a client that skips Hello.
func TestHandshakeIsRequiredFirst(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
@@ -901,7 +1055,7 @@ func TestFreezeBoardIncludesTheOpeningWord(t *testing.T) {
t.Fatalf("engine: %v", err)
}
board := freezeBoard(e, "a b")
board := freezeBoard(e)
if !board.Used("a b") {
t.Error("frozen board does not consider the opening word played")
}