99import contextvars
1010import math
1111import logging
12+ import sys
1213from collections import deque
1314from collections .abc import Callable , Coroutine , Generator
1415from datetime import datetime , timedelta
1516from typing import Any , TypeVar , cast
1617
1718from durabletask import task
19+ from durabletask .internal .exceptions import is_control_flow_exception
1820
1921T = TypeVar ("T" )
2022
@@ -113,9 +115,11 @@ def __init__(
113115 self ._observers : dict [int , _CompletionObserver ] = {}
114116 self ._completions : deque [Callable [[], None ]] = deque ()
115117 self ._timers : dict [_DurableTimerHandle , _CompletionObserver ] = {}
118+ self ._asyncgens : dict [int , Any ] = {}
116119 self ._closed = False
117120 self ._closing = False
118121 self ._fatal : BaseException | None = None
122+ self ._cleanup_failure : BaseException | None = None
119123 self ._root : asyncio .Task [Any ] | None = None
120124
121125 def time (self ) -> float :
@@ -217,6 +221,8 @@ def _complete_timer(self, handle: _DurableTimerHandle) -> None:
217221 self ._ready .append (handle )
218222
219223 async def wait_durable (self , durable : task .Task [T ]) -> T :
224+ if self ._closing :
225+ raise RuntimeError ("Dispatch cleanup cannot await durable work." )
220226 if durable .is_complete :
221227 return durable .get_result ()
222228 future = self .create_future ()
@@ -263,7 +269,8 @@ def resume(self) -> None:
263269 raise task .TaskCancelledError (
264270 "The orchestrator coroutine was cancelled. Catch local cancellation "
265271 "inside the orchestrator; terminate the durable instance through the client." )
266- # result() propagates the orchestrator's failure to the executor.
272+ # result() deliberately propagates control-flow BaseExceptions to the
273+ # existing executor recovery boundary, not a success-shaped fallback.
267274 result = self ._root .result ()
268275 raise StopIteration (result )
269276 if not self ._has_durable_work ():
@@ -277,33 +284,57 @@ def _has_durable_work(self) -> bool:
277284 def _drain (self ) -> None :
278285 previous_loop = asyncio ._get_running_loop ()
279286 previous_task = asyncio .current_task (previous_loop ) if previous_loop is not None else None
287+ previous_hooks = sys .get_asyncgen_hooks ()
280288 if previous_task is not None and previous_loop is not None :
281289 asyncio .tasks ._leave_task (previous_loop , previous_task )
282290 asyncio ._set_running_loop (self )
291+ sys .set_asyncgen_hooks (firstiter = self ._asyncgen_firstiter , finalizer = self ._asyncgen_finalizer )
283292 try :
293+ steps = 0
294+ cleanup_limit = max (10000 , 4 * (len (self ._tasks ) + len (self ._asyncgens )))
284295 while self ._ready or self ._cancel_ready :
285296 self ._ready .extend (handle for _ , _ , handle in sorted (self ._cancel_ready ))
286297 self ._cancel_ready .clear ()
287298 handle = self ._ready .popleft ()
288299 if not handle .cancelled ():
289- handle ._run ()
300+ try :
301+ handle ._run ()
302+ except BaseException as error :
303+ if not self ._closing :
304+ raise
305+ # Defer all exits until other cleanup tasks have drained.
306+ self ._record_cleanup_error (error )
290307 if self ._fatal is not None :
291308 error , self ._fatal = self ._fatal , None
292309 if not self ._closing :
293310 raise error
294- self ._logger .warning (
295- "%s: Async cleanup callback failed." , self .ctx .instance_id , exc_info = error )
311+ self ._record_cleanup_error (error )
312+ if not self ._closing and self ._is_complete ():
313+ break
314+ if not self ._closing and self ._root is not None and self ._root .done ():
315+ break
296316 if not self ._closing :
297- if self ._is_complete ():
298- break
299- if self ._root is not None and self ._root .done ():
300- break
301317 self ._deliver_completions ()
318+ steps += 1
319+ if self ._closing and steps >= cleanup_limit :
320+ self ._record_cleanup_error (RuntimeError (
321+ "Async cleanup did not quiesce; cleanup must cooperate with cancellation." ))
322+ self ._ready .clear ()
323+ self ._cancel_ready .clear ()
324+ break
302325 finally :
326+ sys .set_asyncgen_hooks (* previous_hooks )
303327 asyncio ._set_running_loop (previous_loop )
304328 if previous_task is not None and previous_loop is not None :
305329 asyncio .tasks ._enter_task (previous_loop , previous_task )
306330
331+ def _asyncgen_firstiter (self , generator : Any ) -> None :
332+ self ._asyncgens [id (generator )] = generator
333+
334+ def _asyncgen_finalizer (self , generator : Any ) -> None :
335+ if not self ._closed :
336+ self .create_task (generator .aclose ())
337+
307338 def call_exception_handler (self , context : dict [str , Any ]) -> None :
308339 error = context .get ("exception" )
309340 if isinstance (error , StopIteration ):
@@ -312,26 +343,47 @@ def call_exception_handler(self, context: dict[str, Any]) -> None:
312343 error = failure
313344 self ._fatal = error if isinstance (error , BaseException ) else RuntimeError (context ["message" ])
314345
346+ def _record_cleanup_error (self , error : BaseException , source : str = "cleanup callback" ) -> None :
347+ self ._logger .warning ("%s: Async %s failed." , self .ctx .instance_id , source , exc_info = error )
348+ if not isinstance (error , (Exception , asyncio .CancelledError )):
349+ if self ._cleanup_failure is None or is_control_flow_exception (error ):
350+ self ._cleanup_failure = error
351+
315352 def close (self ) -> None :
316- """Cancel unfinished coroutines so their cleanup runs before the dispatch ends."""
317- if self ._closed :
318- return
319353 self ._closing = True
320354 try :
355+ completed_at_close = {future for future in self ._tasks if future .done ()}
356+ unobserved_at_close = {
357+ future for future in completed_at_close if getattr (future , "_log_traceback" , False )}
321358 for future in tuple (self ._tasks ):
322359 if not future .done ():
323360 future .cancel ()
324361 self ._drain ()
325- failure : BaseException | None = None
362+ for generator in tuple (self ._asyncgens .values ()):
363+ self .create_task (generator .aclose ())
364+ self ._drain ()
365+ for future in tuple (self ._tasks ):
366+ if not future .done ():
367+ future .cancel ()
368+ self ._drain ()
369+ failure : BaseException | None = self ._cleanup_failure
326370 for future in tuple (self ._tasks ):
327371 if not future .done ():
328- future .get_coro ().close ()
372+ try :
373+ future .get_coro ().close ()
374+ except BaseException as error :
375+ # Close siblings before propagating process-control exits.
376+ self ._record_cleanup_error (error , "coroutine cleanup" )
377+ failure = failure or error
329378 failure = failure or RuntimeError (
330379 "Async cleanup must finish without waiting for durable or external work." )
331- elif not future .cancelled () and getattr (future , "_log_traceback" , False ):
332- self ._logger .warning (
333- "%s: Async background task failed." , self .ctx .instance_id ,
334- exc_info = future .exception ())
380+ elif not future .cancelled ():
381+ error = future .exception ()
382+ if error is not None and (future not in completed_at_close or future in unobserved_at_close ):
383+ self ._record_cleanup_error (
384+ error , "background task" if future in completed_at_close else "task cleanup" )
385+ if self ._cleanup_failure is not None :
386+ raise self ._cleanup_failure
335387 if failure is not None :
336388 raise failure
337389 finally :
@@ -345,6 +397,7 @@ def close(self) -> None:
345397 self ._waits .clear ()
346398 self ._timers .clear ()
347399 self ._tasks .clear ()
400+ self ._asyncgens .clear ()
348401
349402 def run_in_executor (self , executor : Any , func : Any , * args : Any ) -> asyncio .Future [Any ]:
350403 raise RuntimeError ("Threads/executors are unsupported in orchestrators; use an activity." )
0 commit comments