Cancel CPU turns immediately when host ends game

Convert CPU turn chain to a cancellable asyncio.Task tracked on Room,
so ending the game or leaving no longer blocks waiting for CPU sleeps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken 2026-02-22 17:21:22 -05:00
parent 4c23f2b4a9
commit de3495635b
4 changed files with 48 additions and 3 deletions

View File

@ -1934,7 +1934,11 @@ def _log_cpu_action(logger, game_id: Optional[str], cpu_player: Player, game: Ga
async def process_cpu_turn(
game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None
) -> None:
"""Process a complete turn for a CPU player."""
"""Process a complete turn for a CPU player.
May raise asyncio.CancelledError if the game is ended mid-turn.
The caller (check_and_run_cpu_turn) handles cancellation.
"""
import asyncio
from services.game_logger import get_logger

View File

@ -473,6 +473,15 @@ async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, c
await ctx.websocket.send_json({"type": "error", "message": "Only the host can end the game"})
return
# Cancel any running CPU turn task so the game ends immediately
if ctx.current_room.cpu_turn_task:
ctx.current_room.cpu_turn_task.cancel()
try:
await ctx.current_room.cpu_turn_task
except (asyncio.CancelledError, Exception):
pass
ctx.current_room.cpu_turn_task = None
await ctx.current_room.broadcast({
"type": "game_ended",
"reason": "Host ended the game",

View File

@ -706,7 +706,29 @@ async def broadcast_game_state(room: Room):
async def check_and_run_cpu_turn(room: Room):
"""Check if current player is CPU and run their turn."""
"""Check if current player is CPU and run their turn.
If this is the outermost call (no CPU task running), creates a single
asyncio.Task that runs the entire chain of consecutive CPU turns.
This allows the task to be cancelled cleanly when the game ends.
"""
# If already inside a CPU turn task, run directly (no nested tasks)
if room.cpu_turn_task is not None:
await _run_cpu_chain(room)
return
# Outermost call: wrap the chain in a cancellable task
room.cpu_turn_task = asyncio.create_task(_run_cpu_chain(room))
try:
await room.cpu_turn_task
except asyncio.CancelledError:
pass
finally:
room.cpu_turn_task = None
async def _run_cpu_chain(room: Room):
"""Run consecutive CPU turns until a human player's turn or game ends."""
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
return
@ -728,11 +750,20 @@ async def check_and_run_cpu_turn(room: Room):
await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id)
# Check if next player is also CPU (chain CPU turns)
await check_and_run_cpu_turn(room)
await _run_cpu_chain(room)
async def handle_player_leave(room: Room, player_id: str):
"""Handle a player leaving a room."""
# Cancel any running CPU turn task before cleanup
if room.cpu_turn_task:
room.cpu_turn_task.cancel()
try:
await room.cpu_turn_task
except (asyncio.CancelledError, Exception):
pass
room.cpu_turn_task = None
room_code = room.code
room_player = room.remove_player(player_id)

View File

@ -69,6 +69,7 @@ class Room:
settings: dict = field(default_factory=lambda: {"decks": 1, "rounds": 1})
game_log_id: Optional[str] = None
game_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
cpu_turn_task: Optional[asyncio.Task] = None
def add_player(
self,