Skip to content

Commit 8f651d9

Browse files
committed
fix(lint): resolve asyncio and contextlib linting issues
- Store references to asyncio.create_task() calls to prevent garbage collection - Use contextlib.suppress() instead of try-except-pass for CancelledError - Replace polling loop with asyncio.Event for shutdown monitoring - Fix RUF006, SIM105, and ASYNC110 linting violations
1 parent 646da15 commit 8f651d9

File tree

3 files changed

+340
-42
lines changed

3 files changed

+340
-42
lines changed

src/tux/core/app.py

Lines changed: 100 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -19,45 +19,34 @@
1919
from loguru import logger
2020

2121
from tux.core.bot import Tux
22-
from tux.database.utils import get_db_controller_from
2322
from tux.help import TuxHelp
2423
from tux.services.sentry_manager import SentryManager
2524
from tux.shared.config import CONFIG
2625

2726

2827
async def get_prefix(bot: Tux, message: discord.Message) -> list[str]:
29-
"""Get the command prefix for a guild.
28+
"""Get the command prefix for a guild using the prefix manager.
3029
31-
This function retrieves the guild-specific prefix from the database,
32-
falling back to `CONFIG.get_prefix()` when the guild is unavailable or the database
33-
cannot be resolved.
30+
This function uses the in-memory prefix cache for optimal performance,
31+
falling back to the default prefix when the guild is unavailable.
32+
33+
If BOT_INFO__PREFIX is set in environment variables, all guilds will use
34+
that prefix, ignoring database settings.
3435
"""
35-
if not message.guild:
36+
# Check if prefix override is enabled by environment variable
37+
if CONFIG.is_prefix_override_enabled():
3638
return [CONFIG.get_prefix()]
3739

38-
prefix: str | None = None
39-
40-
try:
41-
controller = get_db_controller_from(bot, fallback_to_direct=False)
42-
if controller is None:
43-
logger.warning("Database unavailable; using default prefix")
44-
else:
45-
# Ensure the guild exists in the database first
46-
await controller.guild.get_or_create_guild(message.guild.id)
47-
48-
# Get or create guild config with default prefix
49-
guild_config = await controller.guild_config.get_or_create_config(
50-
message.guild.id,
51-
prefix=CONFIG.get_prefix(), # Use the default prefix as the default value
52-
)
53-
if guild_config and hasattr(guild_config, "prefix"):
54-
prefix = guild_config.prefix
40+
if not message.guild:
41+
return [CONFIG.get_prefix()]
5542

56-
except Exception as e:
57-
logger.error(f"❌ Error getting guild prefix: {type(e).__name__}")
58-
logger.info("💡 Using default prefix due to database or configuration error")
43+
# Use the prefix manager for efficient prefix resolution
44+
if hasattr(bot, "prefix_manager") and bot.prefix_manager:
45+
prefix = await bot.prefix_manager.get_prefix(message.guild.id)
46+
return [prefix]
5947

60-
return [prefix or CONFIG.get_prefix()]
48+
# Fallback to default prefix if prefix manager is not available
49+
return [CONFIG.get_prefix()]
6150

6251

6352
class TuxApp:
@@ -82,7 +71,29 @@ def run(self) -> None:
8271
8372
This is the synchronous entrypoint typically invoked by the CLI.
8473
"""
85-
asyncio.run(self.start())
74+
try:
75+
# Use a more direct approach to handle signals
76+
loop = asyncio.new_event_loop()
77+
asyncio.set_event_loop(loop)
78+
79+
try:
80+
# Run the bot with the event loop
81+
loop.run_until_complete(self.start())
82+
finally:
83+
loop.close()
84+
85+
except KeyboardInterrupt:
86+
logger.info("Application interrupted by user")
87+
except RuntimeError as e:
88+
# Handle event loop stopped errors gracefully (these are expected during shutdown)
89+
if "Event loop stopped" in str(e):
90+
logger.debug("Event loop stopped during shutdown")
91+
else:
92+
logger.error(f"Application error: {e}")
93+
raise
94+
except Exception as e:
95+
logger.error(f"Application error: {e}")
96+
raise
8697

8798
def setup_signals(self, loop: asyncio.AbstractEventLoop) -> None:
8899
"""Register signal handlers for graceful shutdown.
@@ -100,21 +111,39 @@ def setup_signals(self, loop: asyncio.AbstractEventLoop) -> None:
100111

101112
def _sigterm() -> None:
102113
SentryManager.report_signal(signal.SIGTERM, None)
103-
# Trigger graceful shutdown by closing the bot
114+
logger.info("SIGTERM received, forcing shutdown...")
115+
# Set shutdown event for the monitor
116+
if hasattr(self, "_shutdown_event"):
117+
self._shutdown_event.set()
118+
# Cancel ALL tasks in the event loop
119+
for task in asyncio.all_tasks(loop):
120+
if not task.done():
121+
task.cancel()
122+
# Force close the bot connection if it exists
104123
if hasattr(self, "bot") and self.bot and not self.bot.is_closed():
105-
# Schedule the close operation in the event loop
106-
bot = self.bot # Type narrowing
107-
with contextlib.suppress(Exception):
108-
loop.call_soon_threadsafe(lambda: asyncio.create_task(bot.close()))
124+
close_task = asyncio.create_task(self.bot.close())
125+
# Store reference to prevent garbage collection
126+
_ = close_task
127+
# Stop the event loop
128+
loop.call_soon_threadsafe(loop.stop)
109129

110130
def _sigint() -> None:
111131
SentryManager.report_signal(signal.SIGINT, None)
112-
# Trigger graceful shutdown by closing the bot
132+
logger.info("SIGINT received, forcing shutdown...")
133+
# Set shutdown event for the monitor
134+
if hasattr(self, "_shutdown_event"):
135+
self._shutdown_event.set()
136+
# Cancel ALL tasks in the event loop
137+
for task in asyncio.all_tasks(loop):
138+
if not task.done():
139+
task.cancel()
140+
# Force close the bot connection if it exists
113141
if hasattr(self, "bot") and self.bot and not self.bot.is_closed():
114-
# Schedule the close operation in the event loop
115-
bot = self.bot # Type narrowing
116-
with contextlib.suppress(Exception):
117-
loop.call_soon_threadsafe(lambda: asyncio.create_task(bot.close()))
142+
close_task = asyncio.create_task(self.bot.close())
143+
# Store reference to prevent garbage collection
144+
_ = close_task
145+
# Stop the event loop
146+
loop.call_soon_threadsafe(loop.stop)
118147

119148
try:
120149
loop.add_signal_handler(signal.SIGTERM, _sigterm)
@@ -124,7 +153,9 @@ def _sigint() -> None:
124153
# Fallback for platforms that do not support add_signal_handler (e.g., Windows)
125154
def _signal_handler(signum: int, frame: FrameType | None) -> None:
126155
SentryManager.report_signal(signum, frame)
127-
# For Windows fallback, just log the signal
156+
logger.info(f"Signal {signum} received, shutting down...")
157+
# For Windows fallback, raise KeyboardInterrupt to stop the event loop
158+
raise KeyboardInterrupt
128159

129160
signal.signal(signal.SIGTERM, _signal_handler)
130161
signal.signal(signal.SIGINT, _signal_handler)
@@ -175,8 +206,26 @@ async def start(self) -> None:
175206
)
176207

177208
try:
178-
# Start the bot normally - this handles login() + connect() properly
179-
await self.bot.start(CONFIG.BOT_TOKEN, reconnect=True)
209+
# Use login() + connect() separately to avoid blocking
210+
logger.info("🔐 Logging in to Discord...")
211+
await self.bot.login(CONFIG.BOT_TOKEN)
212+
213+
logger.info("🌐 Connecting to Discord...")
214+
# Create a task for the connection
215+
self._connect_task = asyncio.create_task(self.bot.connect(reconnect=True), name="bot_connect")
216+
217+
# Create a task to monitor for shutdown signals
218+
shutdown_task = asyncio.create_task(self._monitor_shutdown(), name="shutdown_monitor")
219+
220+
# Wait for either the connection to complete or shutdown to be requested
221+
done, pending = await asyncio.wait([self._connect_task, shutdown_task], return_when=asyncio.FIRST_COMPLETED)
222+
223+
# Cancel any pending tasks
224+
for task in pending:
225+
task.cancel()
226+
with contextlib.suppress(asyncio.CancelledError):
227+
await task
228+
180229
except asyncio.CancelledError:
181230
# Handle cancellation gracefully
182231
logger.info("Bot startup was cancelled")
@@ -188,6 +237,16 @@ async def start(self) -> None:
188237
finally:
189238
await self.shutdown()
190239

240+
async def _monitor_shutdown(self) -> None:
241+
"""Monitor for shutdown signals while the bot is running."""
242+
# Create an event to track shutdown requests
243+
self._shutdown_event = asyncio.Event()
244+
245+
# Wait for shutdown event
246+
await self._shutdown_event.wait()
247+
248+
logger.info("Shutdown requested via monitor")
249+
191250
async def shutdown(self) -> None:
192251
"""Gracefully shut down the bot and flush telemetry.
193252

src/tux/core/bot.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717

1818
from tux.core.cog_loader import CogLoader
1919
from tux.core.container import ServiceContainer
20-
from tux.core.prefix_manager import PrefixManager
2120
from tux.core.service_registry import ServiceRegistry
2221
from tux.core.task_monitor import TaskMonitor
2322
from tux.database.migrations.runner import upgrade_head_if_needed
@@ -304,6 +303,9 @@ async def _setup_prefix_manager(self) -> None:
304303
logger.info("🔧 Initializing prefix manager...")
305304

306305
try:
306+
# Import here to avoid circular imports
307+
from tux.core.prefix_manager import PrefixManager # noqa: PLC0415
308+
307309
# Initialize the prefix manager
308310
self.prefix_manager = PrefixManager(self)
309311

0 commit comments

Comments
 (0)