@@ -147,6 +147,66 @@ async def _shutdown_async_queue(q: asyncio.Queue, label: str) -> None:
147147 )
148148
149149
150+ def _retrieve_background_task_outcome (task : asyncio .Task ):
151+ """
152+ Return a completed background task's exception, or a sentinel for unexpected success.
153+
154+ Retrieves the exception so asyncio does not emit "never retrieved" warnings.
155+ """
156+ if task is None or not task .done () or task .cancelled ():
157+ return None
158+ exc = task .exception ()
159+ if exc is not None :
160+ return exc
161+ return RuntimeError ('Python agent asyncio background task finished unexpectedly' )
162+
163+
164+ def _log_background_task_outcome (task : asyncio .Task ) -> bool :
165+ """Log and retrieve a completed background task outcome. Returns True if logged."""
166+ exc = _retrieve_background_task_outcome (task )
167+ if exc is None :
168+ return False
169+ logger .error ('Error in Python agent asyncio event loop: %s' , exc , exc_info = exc )
170+ return True
171+
172+
173+ async def _await_shutdown_or_background_failure (
174+ finished : asyncio .Event ,
175+ background_tasks ,
176+ ) -> None :
177+ """
178+ Wait for shutdown or the first unexpected background-task completion.
179+
180+ Background tasks are expected to run until shutdown. If one finishes early,
181+ retrieve/log its outcome and signal shutdown so the root can clean up.
182+ """
183+ shutdown_waiter = asyncio .create_task (finished .wait ())
184+ pending = {task for task in background_tasks if task is not None }
185+ try :
186+ while not finished .is_set ():
187+ if not pending :
188+ await finished .wait ()
189+ return
190+ done , _ = await asyncio .wait (
191+ pending | {shutdown_waiter },
192+ return_when = asyncio .FIRST_COMPLETED ,
193+ )
194+ if shutdown_waiter in done or finished .is_set ():
195+ return
196+ for task in done :
197+ pending .discard (task )
198+ if _log_background_task_outcome (task ):
199+ finished .set ()
200+ return
201+ finally :
202+ if not shutdown_waiter .done ():
203+ shutdown_waiter .cancel ()
204+ try :
205+ await shutdown_waiter
206+ except asyncio .CancelledError :
207+ pass
208+
209+
150210async def _cancel_pending_tasks (tasks ) -> None :
151211 """
152212 Cancel agent-owned reporter / connectivity-watch tasks only.
@@ -155,6 +215,11 @@ async def _cancel_pending_tasks(tasks) -> None:
155215 The current task is excluded so we never await ourselves.
156216 """
157217 current = asyncio .current_task ()
218+ for task in tasks :
219+ if task is None or task is current :
220+ continue
221+ if task .done ():
222+ _log_background_task_outcome (task )
158223 pending = [
159224 task for task in tasks
160225 if task is not None and task is not current and not task .done ()
@@ -174,6 +239,8 @@ async def _cancel_pending_tasks(tasks) -> None:
174239 _SHUTDOWN_JOIN_TIMEOUT_SEC ,
175240 sum (1 for task in pending if not task .done ()),
176241 )
242+ for task in pending :
243+ _log_background_task_outcome (task )
177244
178245
179246def _close_previous_protocol (protocol ) -> None :
@@ -821,9 +888,10 @@ async def __start_event_loop_async(self) -> None:
821888
822889 self .background_tasks = {asyncio .create_task (coro ) for coro in self .background_coroutines }
823890 logger .debug ('All background coroutines started' )
824- # Wait for shutdown inside the asyncio.run root, then clean up here so the
825- # Runner stays alive through protocol aclose() before asyncio.run returns.
826- await self ._finished .wait ()
891+ # Wait for shutdown or unexpected background-task completion inside the
892+ # asyncio.run root, then clean up here so the Runner stays alive through
893+ # protocol aclose() before asyncio.run returns.
894+ await _await_shutdown_or_background_failure (self ._finished , self .background_tasks )
827895 await self .__async_shutdown_cleanup ()
828896
829897 def __start_event_loop (self ) -> None :
0 commit comments