paper-dynasty-discord/command_logic/play_context.py
Cal Corum 1a9efa8f7e fix: add locked_play context manager to prevent stuck play locks
Early returns in log_chaos, log_sac_bunt, and log_stealing left play
locks permanently stuck because the lock was acquired but never released.
The new locked_play async context manager wraps checks_log_interaction()
and guarantees lock release on exception, early return, or normal exit.

Migrated all 18 locking commands in gameplay.py and removed redundant
double-locking in end_game_command.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 21:54:44 -06:00

65 lines
2.7 KiB
Python

"""
Async context manager for play locking. Wraps checks_log_interaction()
and guarantees lock release on exception, early return, or normal exit.
This module sits above play_lock and logic_gameplay to avoid circular imports.
"""
import logging
from contextlib import asynccontextmanager
from sqlmodel import Session
from play_lock import release_play_lock
from command_logic.logic_gameplay import checks_log_interaction
logger = logging.getLogger("discord_app")
@asynccontextmanager
async def locked_play(session: Session, interaction, command_name: str):
"""
Async context manager that acquires a play lock via checks_log_interaction
and guarantees release when the block exits.
Usage:
with Session(engine) as session:
async with locked_play(session, interaction, 'log flyball') as (game, team, play):
play = await flyballs(session, interaction, play, flyball_type)
await self.complete_and_post_play(session, interaction, play, ...)
Behavior:
- If checks_log_interaction raises (PlayLockedException, etc.), no lock
was acquired and the exception propagates naturally.
- If complete_play() was called, play.complete=True, so finally is a no-op.
- If the block raises an exception, the lock is released and the exception re-raised.
- If the block returns early (e.g. validation failure), the lock is released.
"""
# If this raises, no lock was acquired - exception propagates naturally
this_game, owner_team, this_play = await checks_log_interaction(
session, interaction, command_name=command_name, lock_play=True
)
try:
yield this_game, owner_team, this_play
except Exception as e:
if this_play.locked and not this_play.complete:
logger.error(
f"Exception in '{command_name}' on play {this_play.id}, releasing lock: {e}"
)
try:
release_play_lock(session, this_play)
except Exception as release_err:
logger.error(f"Failed to release lock on play {this_play.id}: {release_err}")
raise
finally:
# Catches early returns (the primary bug fix)
# No-op if complete_play() already set complete=True and locked=False
if this_play.locked and not this_play.complete:
logger.warning(
f"Play {this_play.id} still locked after '{command_name}', "
f"releasing (likely early return)"
)
try:
release_play_lock(session, this_play)
except Exception as release_err:
logger.error(f"Failed to force release lock on play {this_play.id}: {release_err}")