From 9cd0de3883c6ce68eae32c9d559b7278bebcb7f6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 24 Mar 2026 02:18:30 -0400 Subject: [PATCH] fix(briefing): deadlock in scheduler startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start_briefing_scheduler was called from before_serving (event loop thread) and used run_coroutine_threadsafe(...).result() which blocks the calling thread waiting for the coroutine to complete — but since the calling thread IS the event loop, the coroutine could never run, causing a 10s timeout and zero jobs scheduled. Fix: make start_briefing_scheduler async and await _get_briefing_enabled_users() directly. Also use asyncio.create_task for the catch-up rather than run_coroutine_threadsafe. The background thread jobs (_run_user_slot_sync) continue to use run_coroutine_threadsafe correctly since they run on the APScheduler thread, not the event loop thread. Co-Authored-By: Claude Sonnet 4.6 --- src/fabledassistant/app.py | 2 +- src/fabledassistant/services/briefing_scheduler.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index afee0c2..18013e5 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -246,7 +246,7 @@ def create_app() -> Quart: # Start briefing scheduler from fabledassistant.services.briefing_scheduler import start_briefing_scheduler - start_briefing_scheduler(asyncio.get_running_loop()) + await start_briefing_scheduler(asyncio.get_running_loop()) @app.after_serving async def shutdown(): diff --git a/src/fabledassistant/services/briefing_scheduler.py b/src/fabledassistant/services/briefing_scheduler.py index 285f2a1..47e2b58 100644 --- a/src/fabledassistant/services/briefing_scheduler.py +++ b/src/fabledassistant/services/briefing_scheduler.py @@ -296,10 +296,10 @@ async def _catchup_missed_slots(loop: asyncio.AbstractEventLoop) -> None: ) -def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None: +async def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None: """ Start the APScheduler background scheduler with per-user timezone-aware jobs. - Must be called from the app's before_serving hook with the running event loop. + Must be awaited from the app's before_serving hook (async context). """ global _scheduler, _loop if _scheduler is not None: @@ -308,10 +308,10 @@ def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None: _loop = loop _scheduler = BackgroundScheduler(timezone="UTC") - # Schedule jobs synchronously: run the async query in the provided loop - future = asyncio.run_coroutine_threadsafe(_get_briefing_enabled_users(), loop) + # Await directly — we're already on the event loop, so run_coroutine_threadsafe + # would deadlock (it blocks the calling thread, which IS the event loop thread). try: - users = future.result(timeout=10) + users = await _get_briefing_enabled_users() except Exception: logger.exception("Failed to load briefing users at startup") users = [] @@ -325,7 +325,7 @@ def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None: len(users), len(users) * len(SLOTS), ) - asyncio.run_coroutine_threadsafe(_catchup_missed_slots(loop), loop) + asyncio.create_task(_catchup_missed_slots(loop)) def stop_briefing_scheduler() -> None: