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,78 +118,160 @@ 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)."""
room = await self.get_room(room_code)
if not room:
return None
# Check if team is full
if len(room["teams"][team]) >= 4:
return None
# Check if name is taken
for t in ["A", "B"]:
for p in room["teams"][t]:
if p["name"].lower() == player_name.lower():
return None
# Add player
player = {
"name": player_name,
"team": team,
"position": len(room["teams"][team]),
"socket_id": socket_id
}
room["teams"][team].append(player)
# Save player mapping
await self.redis.setex(
f"player:{socket_id}",
3600 * 3,
json.dumps({"name": player_name, "room": room_code, "team": team})
)
await self.update_room(room_code, room)
return room
async def remove_player(self, socket_id: str) -> Optional[dict]:
"""Remove a player from their room."""
await self.connect() await self.connect()
# Get player info async def _do_add_player():
room = await self.get_room(room_code)
if not room:
return None
# Check if team is full
if len(room["teams"][team]) >= 4:
return None
# Check if name is taken
for t in ["A", "B"]:
for p in room["teams"][t]:
if p["name"].lower() == player_name.lower():
return None
# Add player
player = {
"name": player_name,
"team": team,
"position": len(room["teams"][team]),
"socket_id": socket_id
}
room["teams"][team].append(player)
# Save player mapping
await self.redis.setex(
f"player:{socket_id}",
3600 * 3,
json.dumps({"name": player_name, "room": room_code, "team": team})
)
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 (with lock)."""
await self.connect()
# 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"]
# Get room async def _do_remove_player():
room = await self.get_room(room_code) # Get room
if not room: room = await self.get_room(room_code)
if not room:
return None
# 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"][t]):
p["position"] = i
# Delete player mapping
await self.redis.delete(f"player:{socket_id}")
# If room is empty, delete it
if not room["teams"]["A"] and not room["teams"]["B"]:
await self.redis.delete(f"room:{room_code}")
return None
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 return None
# Remove player from team async def change_player_team(
room["teams"][team] = [ self,
p for p in room["teams"][team] if p["socket_id"] != socket_id room_code: str,
] player_name: str,
socket_id: str,
new_team: str
) -> Optional[dict]:
"""Change a player's team (with lock)."""
await self.connect()
# Update positions async def _do_change_team():
for i, p in enumerate(room["teams"][team]): room = await self.get_room(room_code)
p["position"] = i if not room:
return None
# Delete player mapping # Find current team
await self.redis.delete(f"player:{socket_id}") 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 room is empty, delete it # If already on target team, just return room
if not room["teams"]["A"] and not room["teams"]["B"]: if current_team == new_team:
await self.redis.delete(f"room:{room_code}") 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 return None
await self.update_room(room_code, room)
return room
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