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 json
import random import random
import string import string
import asyncio
from typing import Optional from typing import Optional
import redis.asyncio as redis import redis.asyncio as redis
from app.config import get_settings from app.config import get_settings
settings = get_settings() settings = get_settings()
# Lock timeout in seconds
LOCK_TIMEOUT = 5
LOCK_RETRY_DELAY = 0.05 # 50ms
class RoomManager: class RoomManager:
def __init__(self): def __init__(self):
@@ -20,6 +25,31 @@ class RoomManager:
if self.redis: if self.redis:
await self.redis.close() 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: def _generate_room_code(self) -> str:
"""Generate a 6-character room code.""" """Generate a 6-character room code."""
return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
@@ -88,7 +118,10 @@ class RoomManager:
team: str, team: str,
socket_id: str socket_id: str
) -> Optional[dict]: ) -> 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) room = await self.get_room(room_code)
if not room: if not room:
return None return None
@@ -122,31 +155,37 @@ class RoomManager:
await self.update_room(room_code, room) await self.update_room(room_code, room)
return 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]: 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() await self.connect()
# Get player info # Get player info first (outside lock)
player_data = await self.redis.get(f"player:{socket_id}") player_data = await self.redis.get(f"player:{socket_id}")
if not player_data: if not player_data:
return None return None
player_info = json.loads(player_data) player_info = json.loads(player_data)
room_code = player_info["room"] room_code = player_info["room"]
team = player_info["team"]
async def _do_remove_player():
# Get room # Get room
room = await self.get_room(room_code) room = await self.get_room(room_code)
if not room: if not room:
return None return None
# Remove player from team # Remove player from both teams (in case of inconsistency)
room["teams"][team] = [ for t in ["A", "B"]:
p for p in room["teams"][team] if p["socket_id"] != socket_id room["teams"][t] = [
p for p in room["teams"][t] if p["socket_id"] != socket_id
] ]
# Update positions # Update positions
for i, p in enumerate(room["teams"][team]): for i, p in enumerate(room["teams"][t]):
p["position"] = i p["position"] = i
# Delete player mapping # Delete player mapping
@@ -160,6 +199,79 @@ class RoomManager:
await self.update_room(room_code, room) await self.update_room(room_code, room)
return 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]: async def get_player(self, socket_id: str) -> Optional[dict]:
"""Get player info by socket ID.""" """Get player info by socket ID."""
await self.connect() await self.connect()

View File

@@ -189,14 +189,13 @@ def register_socket_events(sio: socketio.AsyncServer):
room_code = player["room"] room_code = player["room"]
new_team = data.get("team") new_team = data.get("team")
current_team = player["team"]
# Don't do anything if already on that team # Use room_manager method with lock to prevent race conditions
if current_team == new_team: room = await room_manager.change_player_team(
return room_code, player["name"], sid, new_team
)
room = await room_manager.get_room(room_code) if not room:
if not room or len(room["teams"][new_team]) >= 4:
await sio.emit( await sio.emit(
"error", "error",
{"message": "Cannot change team. It may be full."}, {"message": "Cannot change team. It may be full."},
@@ -204,30 +203,6 @@ def register_socket_events(sio: socketio.AsyncServer):
) )
return 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) await sio.emit("team_changed", {"room": room}, room=room_code)
@sio.event @sio.event