From b1b6e4ba9227ad6be41fb09ed183b129beed9951 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 21 Sep 2026 16:14:12 +0700 Subject: [PATCH] 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. --- server/internal/wsapi/wsapi_test.go | 156 +++++++++++++++++++++++++++- 1 file changed, 155 insertions(+), 1 deletion(-) diff --git a/server/internal/wsapi/wsapi_test.go b/server/internal/wsapi/wsapi_test.go index 59e8b25..045fd65 100644 --- a/server/internal/wsapi/wsapi_test.go +++ b/server/internal/wsapi/wsapi_test.go @@ -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") }