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 fractions import Fraction
1617from typing import Any , TypeVar , cast
1718
1819from durabletask import task
20+ from durabletask .internal .exceptions import is_control_flow_exception
1921
2022T = TypeVar ("T" )
2123
@@ -130,9 +132,11 @@ def __init__(
130132 self ._observers : dict [int , _CompletionObserver ] = {}
131133 self ._completions : deque [Callable [[], None ]] = deque ()
132134 self ._timers : dict [_DurableTimerHandle , _CompletionObserver ] = {}
135+ self ._asyncgens : dict [int , Any ] = {}
133136 self ._closed = False
134137 self ._closing = False
135138 self ._fatal : BaseException | None = None
139+ self ._cleanup_failure : BaseException | None = None
136140 self ._root : asyncio .Task [Any ] | None = None
137141
138142 def time (self ) -> float :
@@ -237,6 +241,8 @@ def _complete_timer(self, handle: _DurableTimerHandle) -> None:
237241 self ._ready .append (handle )
238242
239243 async def wait_durable (self , durable : task .Task [T ]) -> T :
244+ if self ._closing :
245+ raise RuntimeError ("Dispatch cleanup cannot await durable work." )
240246 if durable .is_complete :
241247 return durable .get_result ()
242248 future = self .create_future ()
@@ -283,7 +289,8 @@ def resume(self) -> None:
283289 raise task .TaskCancelledError (
284290 "The orchestrator coroutine was cancelled. Catch local cancellation "
285291 "inside the orchestrator; terminate the durable instance through the client." )
286- # result() propagates the orchestrator's failure to the executor.
292+ # result() deliberately propagates control-flow BaseExceptions to the
293+ # existing executor recovery boundary, not a success-shaped fallback.
287294 result = self ._root .result ()
288295 raise StopIteration (result )
289296 if not self ._has_durable_work ():
@@ -297,33 +304,57 @@ def _has_durable_work(self) -> bool:
297304 def _drain (self ) -> None :
298305 previous_loop = asyncio ._get_running_loop ()
299306 previous_task = asyncio .current_task (previous_loop ) if previous_loop is not None else None
307+ previous_hooks = sys .get_asyncgen_hooks ()
300308 if previous_task is not None and previous_loop is not None :
301309 asyncio .tasks ._leave_task (previous_loop , previous_task )
302310 asyncio ._set_running_loop (self )
311+ sys .set_asyncgen_hooks (firstiter = self ._asyncgen_firstiter , finalizer = self ._asyncgen_finalizer )
303312 try :
313+ steps = 0
314+ cleanup_limit = max (10000 , 4 * (len (self ._tasks ) + len (self ._asyncgens )))
304315 while self ._ready or self ._cancel_ready :
305316 self ._ready .extend (handle for _ , _ , handle in sorted (self ._cancel_ready ))
306317 self ._cancel_ready .clear ()
307318 handle = self ._ready .popleft ()
308319 if not handle .cancelled ():
309- handle ._run ()
320+ try :
321+ handle ._run ()
322+ except BaseException as error :
323+ if not self ._closing :
324+ raise
325+ # Defer all exits until other cleanup tasks have drained.
326+ self ._record_cleanup_error (error )
310327 if self ._fatal is not None :
311328 error , self ._fatal = self ._fatal , None
312329 if not self ._closing :
313330 raise error
314- self ._logger .warning (
315- "%s: Async cleanup callback failed." , self .ctx .instance_id , exc_info = error )
331+ self ._record_cleanup_error (error )
332+ if not self ._closing and self ._is_complete ():
333+ break
334+ if not self ._closing and self ._root is not None and self ._root .done ():
335+ break
316336 if not self ._closing :
317- if self ._is_complete ():
318- break
319- if self ._root is not None and self ._root .done ():
320- break
321337 self ._deliver_completions ()
338+ steps += 1
339+ if self ._closing and steps >= cleanup_limit :
340+ self ._record_cleanup_error (RuntimeError (
341+ "Async cleanup did not quiesce; cleanup must cooperate with cancellation." ))
342+ self ._ready .clear ()
343+ self ._cancel_ready .clear ()
344+ break
322345 finally :
346+ sys .set_asyncgen_hooks (* previous_hooks )
323347 asyncio ._set_running_loop (previous_loop )
324348 if previous_task is not None and previous_loop is not None :
325349 asyncio .tasks ._enter_task (previous_loop , previous_task )
326350
351+ def _asyncgen_firstiter (self , generator : Any ) -> None :
352+ self ._asyncgens [id (generator )] = generator
353+
354+ def _asyncgen_finalizer (self , generator : Any ) -> None :
355+ if not self ._closed :
356+ self .create_task (generator .aclose ())
357+
327358 def call_exception_handler (self , context : dict [str , Any ]) -> None :
328359 error = context .get ("exception" )
329360 if isinstance (error , StopIteration ):
@@ -332,26 +363,47 @@ def call_exception_handler(self, context: dict[str, Any]) -> None:
332363 error = failure
333364 self ._fatal = error if isinstance (error , BaseException ) else RuntimeError (context ["message" ])
334365
366+ def _record_cleanup_error (self , error : BaseException , source : str = "cleanup callback" ) -> None :
367+ self ._logger .warning ("%s: Async %s failed." , self .ctx .instance_id , source , exc_info = error )
368+ if not isinstance (error , (Exception , asyncio .CancelledError )):
369+ if self ._cleanup_failure is None or is_control_flow_exception (error ):
370+ self ._cleanup_failure = error
371+
335372 def close (self ) -> None :
336- """Cancel unfinished coroutines so their cleanup runs before the dispatch ends."""
337- if self ._closed :
338- return
339373 self ._closing = True
340374 try :
375+ completed_at_close = {future for future in self ._tasks if future .done ()}
376+ unobserved_at_close = {
377+ future for future in completed_at_close if getattr (future , "_log_traceback" , False )}
341378 for future in tuple (self ._tasks ):
342379 if not future .done ():
343380 future .cancel ()
344381 self ._drain ()
345- failure : BaseException | None = None
382+ for generator in tuple (self ._asyncgens .values ()):
383+ self .create_task (generator .aclose ())
384+ self ._drain ()
385+ for future in tuple (self ._tasks ):
386+ if not future .done ():
387+ future .cancel ()
388+ self ._drain ()
389+ failure : BaseException | None = self ._cleanup_failure
346390 for future in tuple (self ._tasks ):
347391 if not future .done ():
348- future .get_coro ().close ()
392+ try :
393+ future .get_coro ().close ()
394+ except BaseException as error :
395+ # Close siblings before propagating process-control exits.
396+ self ._record_cleanup_error (error , "coroutine cleanup" )
397+ failure = failure or error
349398 failure = failure or RuntimeError (
350399 "Async cleanup must finish without waiting for durable or external work." )
351- elif not future .cancelled () and getattr (future , "_log_traceback" , False ):
352- self ._logger .warning (
353- "%s: Async background task failed." , self .ctx .instance_id ,
354- exc_info = future .exception ())
400+ elif not future .cancelled ():
401+ error = future .exception ()
402+ if error is not None and (future not in completed_at_close or future in unobserved_at_close ):
403+ self ._record_cleanup_error (
404+ error , "background task" if future in completed_at_close else "task cleanup" )
405+ if self ._cleanup_failure is not None :
406+ raise self ._cleanup_failure
355407 if failure is not None :
356408 raise failure
357409 finally :
@@ -365,6 +417,7 @@ def close(self) -> None:
365417 self ._waits .clear ()
366418 self ._timers .clear ()
367419 self ._tasks .clear ()
420+ self ._asyncgens .clear ()
368421
369422 def run_in_executor (self , executor : Any , func : Any , * args : Any ) -> asyncio .Future [Any ]:
370423 raise RuntimeError ("Threads/executors are unsupported in orchestrators; use an activity." )
0 commit comments