|
1 | 1 | import asyncio |
| 2 | +import contextvars |
2 | 3 | import logging |
3 | 4 | from itertools import chain |
4 | 5 | from time import time |
|
21 | 22 | logger = logging.getLogger(__name__) |
22 | 23 |
|
23 | 24 |
|
| 25 | +# ContextVar to distinguish reentrant calls (from within callbacks) from |
| 26 | +# concurrent external calls. asyncio propagates context to child tasks |
| 27 | +# (e.g., those created by asyncio.gather in the callback system), so a |
| 28 | +# ContextVar set in the processing loop is visible in all callbacks. |
| 29 | +# Independent external coroutines have their own context where this is False. |
| 30 | +_in_processing_loop: contextvars.ContextVar[bool] = contextvars.ContextVar( |
| 31 | + "_in_processing_loop", default=False |
| 32 | +) |
| 33 | + |
| 34 | + |
24 | 35 | class AsyncEngine(BaseEngine): |
25 | 36 | """Async engine with full StateChart support. |
26 | 37 |
|
27 | 38 | Mirrors :class:`SyncEngine` algorithm but uses ``async``/``await`` for callback dispatch. |
28 | 39 | All pure-computation helpers are inherited from :class:`BaseEngine`. |
29 | 40 | """ |
30 | 41 |
|
| 42 | + def put(self, trigger_data: TriggerData, internal: bool = False, _delayed: bool = False): |
| 43 | + """Override to attach an asyncio.Future for external events. |
| 44 | +
|
| 45 | + Futures are only created when: |
| 46 | + - The event is external (not internal) |
| 47 | + - No future is already attached |
| 48 | + - There is a running asyncio loop |
| 49 | + - The call is NOT from within the processing loop (reentrant calls |
| 50 | + from callbacks must not get futures, as that would deadlock) |
| 51 | + """ |
| 52 | + if not internal and trigger_data.future is None and not _in_processing_loop.get(): |
| 53 | + try: |
| 54 | + loop = asyncio.get_running_loop() |
| 55 | + trigger_data.future = loop.create_future() |
| 56 | + except RuntimeError: |
| 57 | + pass # No running loop — sync caller |
| 58 | + super().put(trigger_data, internal=internal, _delayed=_delayed) |
| 59 | + |
| 60 | + @staticmethod |
| 61 | + def _resolve_future(future: "asyncio.Future[object] | None", result): |
| 62 | + """Resolve a future with the given result, if present and not yet done.""" |
| 63 | + if future is not None and not future.done(): |
| 64 | + future.set_result(result) |
| 65 | + |
| 66 | + @staticmethod |
| 67 | + def _reject_future(future: "asyncio.Future[object] | None", exc: Exception): |
| 68 | + """Reject a future with the given exception, if present and not yet done.""" |
| 69 | + if future is not None and not future.done(): |
| 70 | + future.set_exception(exc) |
| 71 | + |
| 72 | + def _reject_pending_futures(self, exc: Exception): |
| 73 | + """Reject all unresolved futures in the external queue.""" |
| 74 | + self.external_queue.reject_futures(exc) |
| 75 | + |
31 | 76 | # --- Callback dispatch overrides (async versions of BaseEngine methods) --- |
32 | 77 |
|
33 | 78 | async def _get_args_kwargs( |
@@ -265,16 +310,27 @@ async def activate_initial_state(self): |
265 | 310 | """ |
266 | 311 | return await self.processing_loop() |
267 | 312 |
|
268 | | - async def processing_loop(self): # noqa: C901 |
| 313 | + async def processing_loop( # noqa: C901 |
| 314 | + self, caller_future: "asyncio.Future[object] | None" = None |
| 315 | + ): |
269 | 316 | """Process event triggers with the 3-phase macrostep architecture. |
270 | 317 |
|
271 | 318 | Phase 1: Eventless transitions + internal queue until quiescence. |
272 | 319 | Phase 2: Remaining internal events (safety net for invoke-generated events). |
273 | 320 | Phase 3: External events. |
| 321 | +
|
| 322 | + When ``caller_future`` is provided, the caller can ``await`` it to |
| 323 | + receive its own event's result — even if another coroutine holds the |
| 324 | + processing lock. |
274 | 325 | """ |
275 | 326 | if not self._processing.acquire(blocking=False): |
| 327 | + # Another coroutine holds the lock and will process our event. |
| 328 | + # Await the caller's future so we get our own result back. |
| 329 | + if caller_future is not None: |
| 330 | + return await caller_future |
276 | 331 | return None |
277 | 332 |
|
| 333 | + _ctx_token = _in_processing_loop.set(True) |
278 | 334 | logger.debug("Processing loop started: %s", self.sm.current_state_value) |
279 | 335 | first_result = self._sentinel |
280 | 336 | try: |
@@ -336,26 +392,53 @@ async def processing_loop(self): # noqa: C901 |
336 | 392 | ) |
337 | 393 | break |
338 | 394 |
|
339 | | - enabled_transitions = await self.select_transitions(external_event) |
340 | | - logger.debug("Enabled transitions: %s", enabled_transitions) |
341 | | - if enabled_transitions: |
342 | | - try: |
| 395 | + event_future = external_event.future |
| 396 | + try: |
| 397 | + enabled_transitions = await self.select_transitions(external_event) |
| 398 | + logger.debug("Enabled transitions: %s", enabled_transitions) |
| 399 | + if enabled_transitions: |
343 | 400 | result = await self.microstep( |
344 | 401 | list(enabled_transitions), external_event |
345 | 402 | ) |
| 403 | + self._resolve_future(event_future, result) |
346 | 404 | if first_result is self._sentinel: |
347 | 405 | first_result = result |
348 | | - except Exception: |
349 | | - self.clear() |
350 | | - raise |
351 | | - |
352 | | - else: |
353 | | - if not self.sm.allow_event_without_transition: |
354 | | - raise TransitionNotAllowed(external_event.event, self.sm.configuration) |
355 | | - |
| 406 | + else: |
| 407 | + if not self.sm.allow_event_without_transition: |
| 408 | + tna = TransitionNotAllowed( |
| 409 | + external_event.event, self.sm.configuration |
| 410 | + ) |
| 411 | + self._reject_future(event_future, tna) |
| 412 | + self._reject_pending_futures(tna) |
| 413 | + raise tna |
| 414 | + # Event allowed but no transition — resolve with None |
| 415 | + self._resolve_future(event_future, None) |
| 416 | + except Exception as exc: |
| 417 | + self._reject_future(event_future, exc) |
| 418 | + self._reject_pending_futures(exc) |
| 419 | + self.clear() |
| 420 | + raise |
| 421 | + |
| 422 | + except Exception as exc: |
| 423 | + if caller_future is not None: |
| 424 | + # Route the exception to the caller's future if still pending. |
| 425 | + # If already resolved (caller's own event succeeded before a |
| 426 | + # later event failed), suppress the exception — the caller will |
| 427 | + # get their successful result via ``await future`` below, and |
| 428 | + # the failing event's exception was already routed to *its* |
| 429 | + # caller's future by ``_reject_future(event_future, ...)``. |
| 430 | + self._reject_future(caller_future, exc) |
| 431 | + else: |
| 432 | + raise |
356 | 433 | finally: |
| 434 | + _in_processing_loop.reset(_ctx_token) |
357 | 435 | self._processing.release() |
358 | | - return first_result if first_result is not self._sentinel else None |
| 436 | + |
| 437 | + result = first_result if first_result is not self._sentinel else None |
| 438 | + # If the caller has a future, await it (already resolved by now). |
| 439 | + if caller_future is not None: |
| 440 | + return await caller_future |
| 441 | + return result |
359 | 442 |
|
360 | 443 | async def enabled_events(self, *args, **kwargs): |
361 | 444 | sm = self.sm |
|
0 commit comments