fix: condición de carrera al unirse múltiples jugadores

- Agrega sistema de locks en Redis para operaciones de sala
- add_player, remove_player y change_team ahora son atómicos
- Previene sobrescritura de estado cuando jugadores se unen simultáneamente
- Nuevo método change_player_team con lock integrado

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-27 02:21:41 +00:00
parent e0106502b1
commit e017c5804c
2 changed files with 173 additions and 86 deletions

View File

@@ -1,12 +1,17 @@
import json
import random
import string
import asyncio
from typing import Optional
import redis.asyncio as redis
from app.config import get_settings
settings = get_settings()
# Lock timeout in seconds
LOCK_TIMEOUT = 5
LOCK_RETRY_DELAY = 0.05 # 50ms
class RoomManager:
def __init__(self):
@@ -20,6 +25,31 @@ class RoomManager:
if self.redis:
await self.redis.close()
async def _acquire_lock(self, room_code: str, timeout: float = LOCK_TIMEOUT) -> bool:
"""Acquire a lock for room operations."""
await self.connect()
lock_key = f"lock:room:{room_code}"
# Try to acquire lock with NX (only if not exists) and EX (expire)
acquired = await self.redis.set(lock_key, "1", nx=True, ex=int(timeout))
return acquired is not None
async def _release_lock(self, room_code: str):
"""Release a room lock."""
await self.connect()
lock_key = f"lock:room:{room_code}"
await self.redis.delete(lock_key)
async def _with_lock(self, room_code: str, operation, max_retries: int = 20):
"""Execute an operation with a room lock."""
for attempt in range(max_retries):
if await self._acquire_lock(room_code):
try:
return await operation()
finally:
await self._release_lock(room_code)
await asyncio.sleep(LOCK_RETRY_DELAY)
raise Exception(f"Could not acquire lock for room {room_code}")
def _generate_room_code(self) -> str:
"""Generate a 6-character room code."""
return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
@@ -88,7 +118,10 @@ class RoomManager:
team: str,
socket_id: str
) -> Optional[dict]:
"""Add a player to a room."""
"""Add a player to a room (with lock to prevent race conditions)."""
await self.connect()
async def _do_add_player():
room = await self.get_room(room_code)
if not room:
return None
@@ -122,31 +155,37 @@ class RoomManager:
await self.update_room(room_code, room)
return room
try:
return await self._with_lock(room_code, _do_add_player)
except Exception as e:
print(f"Error adding player: {e}")
return None
async def remove_player(self, socket_id: str) -> Optional[dict]:
"""Remove a player from their room."""
"""Remove a player from their room (with lock)."""
await self.connect()
# Get player info
# Get player info first (outside lock)
player_data = await self.redis.get(f"player:{socket_id}")
if not player_data:
return None
player_info = json.loads(player_data)
room_code = player_info["room"]
team = player_info["team"]
async def _do_remove_player():
# Get room
room = await self.get_room(room_code)
if not room:
return None
# Remove player from team
room["teams"][team] = [
p for p in room["teams"][team] if p["socket_id"] != socket_id
# Remove player from both teams (in case of inconsistency)
for t in ["A", "B"]:
room["teams"][t] = [
p for p in room["teams"][t] if p["socket_id"] != socket_id
]
# Update positions
for i, p in enumerate(room["teams"][team]):
for i, p in enumerate(room["teams"][t]):
p["position"] = i
# Delete player mapping
@@ -160,6 +199,79 @@ class RoomManager:
await self.update_room(room_code, room)
return room
try:
return await self._with_lock(room_code, _do_remove_player)
except Exception as e:
print(f"Error removing player: {e}")
return None
async def change_player_team(
self,
room_code: str,
player_name: str,
socket_id: str,
new_team: str
) -> Optional[dict]:
"""Change a player's team (with lock)."""
await self.connect()
async def _do_change_team():
room = await self.get_room(room_code)
if not room:
return None
# Find current team
current_team = None
for t in ["A", "B"]:
for p in room["teams"][t]:
if p["name"] == player_name:
current_team = t
break
if current_team:
break
# If already on target team, just return room
if current_team == new_team:
return room
# Check if target team is full
if len(room["teams"][new_team]) >= 4:
return None
# Remove from both teams (safety)
for t in ["A", "B"]:
room["teams"][t] = [p for p in room["teams"][t] if p["name"] != player_name]
# Add to new team
player = {
"name": player_name,
"team": new_team,
"position": len(room["teams"][new_team]),
"socket_id": socket_id
}
room["teams"][new_team].append(player)
# Update positions in both teams
for t in ["A", "B"]:
for i, p in enumerate(room["teams"][t]):
p["position"] = i
# Update player record
await self.redis.setex(
f"player:{socket_id}",
3600 * 3,
json.dumps({"name": player_name, "room": room_code, "team": new_team})
)
await self.update_room(room_code, room)
return room
try:
return await self._with_lock(room_code, _do_change_team)
except Exception as e:
print(f"Error changing team: {e}")
return None
async def get_player(self, socket_id: str) -> Optional[dict]:
"""Get player info by socket ID."""
await self.connect()

View File

@@ -189,14 +189,13 @@ def register_socket_events(sio: socketio.AsyncServer):
room_code = player["room"]
new_team = data.get("team")
current_team = player["team"]
# Don't do anything if already on that team
if current_team == new_team:
return
# Use room_manager method with lock to prevent race conditions
room = await room_manager.change_player_team(
room_code, player["name"], sid, new_team
)
room = await room_manager.get_room(room_code)
if not room or len(room["teams"][new_team]) >= 4:
if not room:
await sio.emit(
"error",
{"message": "Cannot change team. It may be full."},
@@ -204,30 +203,6 @@ def register_socket_events(sio: socketio.AsyncServer):
)
return
# Remove from current team (by socket_id to be safe)
room["teams"][current_team] = [
p for p in room["teams"][current_team] if p["socket_id"] != sid
]
# Also remove from new team if somehow already there (prevent duplicates)
room["teams"][new_team] = [
p for p in room["teams"][new_team] if p["socket_id"] != sid
]
# Add to new team
room["teams"][new_team].append({
"name": player["name"],
"team": new_team,
"position": len(room["teams"][new_team]),
"socket_id": sid
})
# Update room state
await room_manager.update_room(room_code, room)
# Update player record with new team
await room_manager.update_player(sid, {"team": new_team})
await sio.emit("team_changed", {"room": room}, room=room_code)
@sio.event