1515- ``asyncio.Handle._run``: run a ready callback with asyncio's own context and
1616 exception-handler semantics.
1717- ``asyncio.Task._log_traceback``: report unobserved local-task failures during
18- dispatch cleanup without marking them as retrieved.
18+ dispatch cleanup without marking them as retrieved, and keep such tasks
19+ tracked until then.
20+ - ``__self__`` of Task step/wakeup callbacks (including the C implementation's
21+ step wrapper and bound ``task_wakeup``): identify which local task a
22+ callback resumes, so task-group cancellation wakeups run in task-creation
23+ order.
24+ - ``AbstractEventLoop._timer_handle_cancelled``: the hook ``TimerHandle.cancel``
25+ calls on its loop, used to withdraw the durable timer observer.
1926"""
2027
2128from __future__ import annotations
2229
2330import asyncio
2431import contextvars
25- import math
2632import logging
33+ import math
2734import sys
2835from collections import deque
2936from collections .abc import Callable , Coroutine , Generator
@@ -72,8 +79,10 @@ def __init__(
7279 super ().__init__ (coro , loop = loop , name = name , context = context )
7380
7481 def __hash__ (self ) -> int :
75- # TaskGroup traverses a set before cancellation schedules shared-Future
76- # callbacks. Stabilize that traversal, not just the resulting wakeups.
82+ # Load-bearing for replay determinism: TaskGroup keeps its children in a
83+ # set and iterates it to cancel them. Hashing by creation sequence makes
84+ # that traversal, and the cancellation wakeups it schedules, follow task
85+ # creation order instead of memory addresses that differ across workers.
7786 return self ._sequence
7887
7988 def __await__ (self ) -> Generator [Any , None , T ]:
@@ -162,12 +171,15 @@ def __init__(
162171 self ._cleanup_failure : BaseException | None = None
163172 self ._root : asyncio .Task [Any ] | None = None
164173
165- def time (self ) -> float :
166- # A fixed UTC epoch avoids local-time and platform timestamp differences.
174+ def _now (self ) -> datetime :
167175 now = self .ctx .current_utc_datetime
168176 if now .tzinfo is not None :
169177 now = now .astimezone (UTC ).replace (tzinfo = None )
170- return (now - _EPOCH ).total_seconds ()
178+ return now
179+
180+ def time (self ) -> float :
181+ # A fixed UTC epoch avoids local-time and platform timestamp differences.
182+ return (self ._now () - _EPOCH ).total_seconds ()
171183
172184 def get_debug (self ) -> bool :
173185 return False
@@ -216,7 +228,8 @@ def call_soon(
216228 # TaskGroup cancels a set of tasks. Do not let object hashes
217229 # determine the order in which their finally blocks execute.
218230 owner = getattr (callback , "__self__" , None )
219- index = self ._tasks [self .cancelling_task ]
231+ index = self ._tasks .get (
232+ self .cancelling_task , getattr (self .cancelling_task , "_sequence" , self ._task_sequence ))
220233 if isinstance (owner , asyncio .Task ):
221234 index = self ._tasks .get (cast (asyncio .Task [Any ], owner ), index )
222235 self ._cancel_ready .append ((index , len (self ._cancel_ready ), handle ))
@@ -242,7 +255,7 @@ def call_at(
242255 # Exact rational ceiling: float round-trips can emit a deadline that
243256 # precedes the requested one by a fraction of a microsecond.
244257 deadline_us = math .ceil (Fraction (when ) * 1_000_000 )
245- now_us = (self .ctx . current_utc_datetime - _EPOCH ) // _MICROSECOND
258+ now_us = (self ._now () - _EPOCH ) // _MICROSECOND
246259 if when <= self .time () or deadline_us <= now_us :
247260 self ._ready .append (handle )
248261 else :
@@ -284,7 +297,10 @@ async def wait_durable(self, durable: task.Task[T]) -> T:
284297 entry [1 ].remove (future )
285298 if not entry [1 ]:
286299 self ._waits .pop (id (durable ), None )
287- self ._observers .pop (id (durable )).detach ()
300+ # Dispatch close may already have detached and cleared observers.
301+ observer = self ._observers .pop (id (durable ), None )
302+ if observer is not None :
303+ observer .detach ()
288304
289305 def _complete_wait (self , durable : task .Task [Any ]) -> None :
290306 entry = self ._waits .get (id (durable ))
@@ -473,24 +489,43 @@ def close(self) -> None:
473489 self ._tasks .clear ()
474490 self ._asyncgens .clear ()
475491
476- def run_in_executor (self , executor : Any , func : Any , * args : Any ) -> asyncio .Future [Any ]:
477- raise RuntimeError ("Threads/executors are unsupported in orchestrators; use an activity." )
478-
479- def call_soon_threadsafe (self , callback : Any , * args : Any , context : Any = None ) -> asyncio .Handle :
480- raise RuntimeError ("Cross-thread scheduling is unsupported in orchestrators; use an activity." )
481-
482- async def _unsupported_io (self , * args : Any , ** kwargs : Any ) -> Any :
483- raise RuntimeError ("External I/O is unsupported in orchestrators; move it to an ordinary activity." )
484-
485- create_connection = _unsupported_io
486- create_server = _unsupported_io
487- create_datagram_endpoint = _unsupported_io
488- connect_read_pipe = _unsupported_io
489- connect_write_pipe = _unsupported_io
490- getaddrinfo = _unsupported_io
491- getnameinfo = _unsupported_io
492- sock_connect = _unsupported_io
493- sock_recv = _unsupported_io
494- sock_sendall = _unsupported_io
495- subprocess_exec = _unsupported_io
496- subprocess_shell = _unsupported_io
492+
493+ _IO_HINT = "External I/O is unsupported in orchestrators; move it to an ordinary activity."
494+ _THREAD_HINT = "Threads/executors are unsupported in orchestrators; use an activity."
495+ _LOOP_HINT = "The worker owns the durable loop's lifecycle and configuration."
496+
497+ # Every asyncio loop API this loop does not implement fails with one actionable
498+ # error instead of AbstractEventLoop's bare NotImplementedError.
499+ _UNSUPPORTED_LOOP_APIS : dict [str , str ] = {
500+ ** dict .fromkeys ((
501+ "create_connection" , "create_server" , "create_datagram_endpoint" ,
502+ "create_unix_connection" , "create_unix_server" , "connect_accepted_socket" ,
503+ "connect_read_pipe" , "connect_write_pipe" , "start_tls" , "sendfile" ,
504+ "getaddrinfo" , "getnameinfo" , "sock_connect" , "sock_accept" , "sock_recv" ,
505+ "sock_recv_into" , "sock_recvfrom" , "sock_recvfrom_into" , "sock_sendall" ,
506+ "sock_sendto" , "sock_sendfile" , "subprocess_exec" , "subprocess_shell" ,
507+ "add_reader" , "remove_reader" , "add_writer" , "remove_writer" ,
508+ "add_signal_handler" , "remove_signal_handler" ,
509+ ), _IO_HINT ),
510+ ** dict .fromkeys ((
511+ "run_in_executor" , "set_default_executor" , "shutdown_default_executor" ,
512+ "call_soon_threadsafe" ,
513+ ), _THREAD_HINT ),
514+ ** dict .fromkeys ((
515+ "run_forever" , "run_until_complete" , "stop" , "set_task_factory" ,
516+ "set_exception_handler" ,
517+ ), _LOOP_HINT ),
518+ }
519+
520+
521+ def _unsupported_loop_api (name : str , hint : str ) -> Callable [..., Any ]:
522+ def unsupported (self : DurableEventLoop , * args : Any , ** kwargs : Any ) -> Any :
523+ raise RuntimeError (f"loop.{ name } () is not supported in durable orchestrators. { hint } " )
524+
525+ unsupported .__name__ = unsupported .__qualname__ = name
526+ return unsupported
527+
528+
529+ for _name , _hint in _UNSUPPORTED_LOOP_APIS .items ():
530+ if _name not in vars (DurableEventLoop ):
531+ setattr (DurableEventLoop , _name , _unsupported_loop_api (_name , _hint ))
0 commit comments