5959 TaskHandoffContext ,
6060 TaskRegistry ,
6161)
62+ from adcp .decisioning .time_budget import (
63+ RoutedSyncExecution ,
64+ SyncExecutorAdmission ,
65+ _bind_routed_sync_execution ,
66+ submit_supervised ,
67+ )
6268from adcp .decisioning .types import (
6369 AdcpError ,
6470 TaskHandoff ,
8591
8692logger = logging .getLogger (__name__ )
8793
94+ # Strong references for synchronous adopter lifecycles that outlive a
95+ # cancelled request. A Python thread cannot be cancelled; its completion hooks
96+ # must still settle durable proposal/idempotency state.
97+ _SUPERVISED_SYNC_LIFECYCLES : set [asyncio .Task [Any ]] = set ()
98+
8899# ---------------------------------------------------------------------------
89100# Specialism enum — spec slugs known to the framework
90101# ---------------------------------------------------------------------------
@@ -583,6 +594,11 @@ def _internal_error_message(method_name: str, exc: BaseException) -> str:
583594 return f"Platform method { method_name !r} raised { cls_name } ; see details for cause"
584595
585596
597+ def _exception_cause_details (exc : BaseException ) -> dict [str , Any ]:
598+ """Return the shared sanitized exception-type breadcrumb."""
599+ return {"caused_by" : {"type" : type (exc ).__name__ }}
600+
601+
586602def _internal_error_details (exc : BaseException ) -> dict [str , Any ]:
587603 """Build the wire-side ``details`` payload for an INTERNAL_ERROR
588604 wrap.
@@ -624,11 +640,7 @@ def _internal_error_details(exc: BaseException) -> dict[str, Any]:
624640 where a structured field list is meaningful, so we don't
625641 generalize this to other exception types.
626642 """
627- details : dict [str , Any ] = {
628- "caused_by" : {
629- "type" : type (exc ).__name__ ,
630- }
631- }
643+ details = _exception_cause_details (exc )
632644 # Try to import lazily so a future refactor that splits the
633645 # validation tooling can't ripple through the dispatch layer.
634646 try :
@@ -647,8 +659,7 @@ def _internal_error_details(exc: BaseException) -> dict[str, Any]:
647659 details ["validation_errors" ] = list (narrow_union_errors (errors_list ))
648660 except Exception :
649661 # Defensive — never let a narrowing bug 500 the wire.
650- # The caused_by.message already carries the truncated
651- # repr; adopters can still triage via server logs.
662+ # The exception type still lets adopters triage via server logs.
652663 pass
653664 return details
654665
@@ -1313,6 +1324,7 @@ async def _invoke_platform_method(
13131324 webhook_target : WebhookDeliveryTarget | None = None ,
13141325 webhook_auto_emit : bool = True ,
13151326 pre_handoff_reject : Callable [[], None ] | None = None ,
1327+ sync_admission : SyncExecutorAdmission | None = None ,
13161328) -> Any :
13171329 """Invoke a platform method, projecting hybrid returns.
13181330
@@ -1383,12 +1395,17 @@ async def _invoke_platform_method(
13831395 off on a ``wholesale`` request is rejected cleanly instead of
13841396 leaking a task the buyer was told was rejected. Runs only on the
13851397 ``TaskHandoff`` arm; sync / workflow-handoff returns ignore it.
1398+ :param sync_admission: Optional bounded admission controller for a sync
1399+ method. Its permit remains held until the underlying thread future
1400+ actually completes, including after caller cancellation.
13861401 """
13871402 # pydantic is a required dep; import here (not at module level) to mirror
13881403 # the lazy-import discipline used throughout this module.
13891404 from pydantic import ValidationError as _ValidationError # noqa: PLC0415
13901405
13911406 method = getattr (platform , method_name )
1407+ sync_lifecycle_continues = False
1408+ routed_sync_execution : RoutedSyncExecution | None = None
13921409 # Re-validate through the platform method's own annotation when it's a
13931410 # stricter subclass of the shim's already-deserialized type. Skipped
13941411 # when arg_projector is set — that path replaces positional args entirely.
@@ -1407,31 +1424,50 @@ async def _invoke_platform_method(
14071424
14081425 try :
14091426 if asyncio .iscoroutinefunction (method ):
1410- if arg_projector is not None :
1411- result = await method (** arg_projector , ctx = ctx )
1412- elif extra_kwargs :
1413- result = await method (params , ctx , ** extra_kwargs )
1414- else :
1415- result = await method (params , ctx )
1427+ # Async router delegates may resolve to synchronous tenant
1428+ # children only after account routing. Propagate the same bounded
1429+ # admission controller and configured executor through ContextVars
1430+ # so that path cannot bypass the timed-sync limit.
1431+ with _bind_routed_sync_execution (sync_admission , executor ) as routed_sync_execution :
1432+ if arg_projector is not None :
1433+ result = await method (** arg_projector , ctx = ctx )
1434+ elif extra_kwargs :
1435+ result = await method (params , ctx , ** extra_kwargs )
1436+ else :
1437+ result = await method (params , ctx )
14161438 else :
1417- ctx_snapshot = contextvars .copy_context ()
1418- loop = asyncio .get_running_loop ()
14191439 if arg_projector is not None :
14201440 projected_kwargs = {** arg_projector , "ctx" : ctx }
1421- result = await loop .run_in_executor (
1422- executor ,
1423- functools .partial (ctx_snapshot .run , method , ** projected_kwargs ),
1424- )
1441+ worker_call = functools .partial (method , ** projected_kwargs )
14251442 elif extra_kwargs :
1426- result = await loop .run_in_executor (
1427- executor ,
1428- functools .partial (ctx_snapshot .run , method , params , ctx , ** extra_kwargs ),
1429- )
1443+ worker_call = functools .partial (method , params , ctx , ** extra_kwargs )
14301444 else :
1431- result = await loop .run_in_executor (
1432- executor ,
1433- functools .partial (ctx_snapshot .run , method , params , ctx ),
1434- )
1445+ worker_call = functools .partial (method , params , ctx )
1446+
1447+ worker_async_future = await submit_supervised (
1448+ executor ,
1449+ sync_admission ,
1450+ worker_call ,
1451+ )
1452+ try :
1453+ result = await asyncio .shield (worker_async_future )
1454+ except asyncio .CancelledError :
1455+ if on_complete is not None or on_failure is not None :
1456+ sync_lifecycle_continues = True
1457+ _supervise_sync_lifecycle (
1458+ worker_async_future ,
1459+ ctx = ctx ,
1460+ method_name = method_name ,
1461+ registry = registry ,
1462+ executor = executor ,
1463+ on_complete = on_complete ,
1464+ on_failure = on_failure ,
1465+ pre_handoff_reject = pre_handoff_reject ,
1466+ request_params = params ,
1467+ webhook_target = webhook_target ,
1468+ webhook_auto_emit = webhook_auto_emit ,
1469+ )
1470+ raise
14351471 except AdcpError as exc :
14361472 # Adopter raised structured error — propagate verbatim. The
14371473 # outer middleware projects to the wire envelope. Fire
@@ -1526,8 +1562,8 @@ async def _invoke_platform_method(
15261562 # The ``details.caused_by`` shape (Emma AudioStack P2) gives
15271563 # adopters a breadcrumb on the wire — without it, "An internal
15281564 # error occurred" is a dead end and adopters have to grep
1529- # server logs. We expose only the exception class name + str
1530- # (not the traceback) so a misconfigured platform that throws
1565+ # server logs. We expose only the exception class name (not the
1566+ # message or traceback) so a misconfigured platform that throws
15311567 # on secret material doesn't leak the secret value through
15321568 # the wire response.
15331569 logger .exception (
@@ -1543,7 +1579,64 @@ async def _invoke_platform_method(
15431579 if on_failure is not None :
15441580 await _safe_on_failure_call (on_failure , wrapped , method_name )
15451581 raise wrapped from exc
1582+ except BaseException as exc :
1583+ # ``asyncio.CancelledError`` (and shutdown BaseExceptions) bypass the
1584+ # wire-error wrapping above, but must still release framework state
1585+ # reserved before adapter dispatch. Preserve the exact exception.
1586+ nested_sync_future = (
1587+ routed_sync_execution .worker if routed_sync_execution is not None else None
1588+ )
1589+ if isinstance (nested_sync_future , asyncio .Future ) and (
1590+ on_complete is not None or on_failure is not None
1591+ ):
1592+ sync_lifecycle_continues = True
1593+ _supervise_sync_lifecycle (
1594+ nested_sync_future ,
1595+ ctx = ctx ,
1596+ method_name = method_name ,
1597+ registry = registry ,
1598+ executor = executor ,
1599+ on_complete = on_complete ,
1600+ on_failure = on_failure ,
1601+ pre_handoff_reject = pre_handoff_reject ,
1602+ request_params = params ,
1603+ webhook_target = webhook_target ,
1604+ webhook_auto_emit = webhook_auto_emit ,
1605+ )
1606+ if on_failure is not None and not sync_lifecycle_continues :
1607+ await _safe_on_failure_call (on_failure , exc , method_name )
1608+ raise
1609+
1610+ return await _project_invocation_result (
1611+ result ,
1612+ ctx = ctx ,
1613+ method_name = method_name ,
1614+ registry = registry ,
1615+ executor = executor ,
1616+ on_complete = on_complete ,
1617+ on_failure = on_failure ,
1618+ pre_handoff_reject = pre_handoff_reject ,
1619+ request_params = params ,
1620+ webhook_target = webhook_target ,
1621+ webhook_auto_emit = webhook_auto_emit ,
1622+ )
15461623
1624+
1625+ async def _project_invocation_result (
1626+ result : Any ,
1627+ * ,
1628+ ctx : RequestContext [Any ],
1629+ method_name : str ,
1630+ registry : TaskRegistry ,
1631+ executor : ThreadPoolExecutor ,
1632+ on_complete : Callable [[Any ], Awaitable [None ]] | None ,
1633+ on_failure : Callable [[BaseException ], Awaitable [None ]] | None ,
1634+ pre_handoff_reject : Callable [[], None ] | None ,
1635+ request_params : BaseModel ,
1636+ webhook_target : WebhookDeliveryTarget | None ,
1637+ webhook_auto_emit : bool ,
1638+ ) -> Any :
1639+ """Project a raw adopter result and settle its framework lifecycle hooks."""
15471640 if is_task_handoff (result ):
15481641 # Reject before any side effect (registry row, background task,
15491642 # completion webhook) is created. The wholesale discovery guard
@@ -1560,7 +1653,7 @@ async def _invoke_platform_method(
15601653 executor = executor ,
15611654 on_complete = on_complete ,
15621655 on_failure = on_failure ,
1563- request_params = params ,
1656+ request_params = request_params ,
15641657 webhook_target = webhook_target ,
15651658 webhook_auto_emit = webhook_auto_emit ,
15661659 )
@@ -1571,7 +1664,7 @@ async def _invoke_platform_method(
15711664 method_name = method_name ,
15721665 registry = registry ,
15731666 executor = executor ,
1574- request_params = params ,
1667+ request_params = request_params ,
15751668 )
15761669
15771670 # Sync return path. Fire on_complete with the typed result before
@@ -1596,6 +1689,99 @@ async def _invoke_platform_method(
15961689 return strip_credentials_from_wire_result (method_name , result )
15971690
15981691
1692+ async def _settle_cancelled_sync_lifecycle (
1693+ worker_future : asyncio .Future [Any ],
1694+ * ,
1695+ ctx : RequestContext [Any ],
1696+ method_name : str ,
1697+ registry : TaskRegistry ,
1698+ executor : ThreadPoolExecutor ,
1699+ on_complete : Callable [[Any ], Awaitable [None ]] | None ,
1700+ on_failure : Callable [[BaseException ], Awaitable [None ]] | None ,
1701+ pre_handoff_reject : Callable [[], None ] | None ,
1702+ request_params : BaseModel ,
1703+ webhook_target : WebhookDeliveryTarget | None ,
1704+ webhook_auto_emit : bool ,
1705+ ) -> None :
1706+ """Settle a sync worker after its request task has been cancelled."""
1707+ try :
1708+ result = await asyncio .shield (worker_future )
1709+ except asyncio .CancelledError :
1710+ # Cancelling this supervisor must not cancel or roll back the
1711+ # non-cancellable thread it observes. Its reservation remains held.
1712+ raise
1713+ except Exception as exc :
1714+ if on_failure is not None :
1715+ await _safe_on_failure_call (on_failure , exc , method_name )
1716+ return
1717+ if is_task_handoff (result ) or is_workflow_handoff (result ):
1718+ # The cancelled caller never received a task id. Do not promote an
1719+ # unreachable handoff; returning a handoff has not executed its work.
1720+ if on_failure is not None :
1721+ await _safe_on_failure_call (on_failure , asyncio .CancelledError (), method_name )
1722+ logger .warning (
1723+ "Discarded %s handoff returned after request cancellation; no task id was issued" ,
1724+ method_name ,
1725+ )
1726+ return
1727+ try :
1728+ await _project_invocation_result (
1729+ result ,
1730+ ctx = ctx ,
1731+ method_name = method_name ,
1732+ registry = registry ,
1733+ executor = executor ,
1734+ on_complete = on_complete ,
1735+ on_failure = on_failure ,
1736+ pre_handoff_reject = pre_handoff_reject ,
1737+ request_params = request_params ,
1738+ webhook_target = webhook_target ,
1739+ webhook_auto_emit = webhook_auto_emit ,
1740+ )
1741+ except Exception :
1742+ # Lifecycle hooks already apply their own rollback semantics. There is
1743+ # no request waiter left to receive this exception, so retain it in
1744+ # server logs rather than producing an unhandled-task warning.
1745+ logger .exception (
1746+ "Cancelled request's synchronous %s lifecycle failed while settling" ,
1747+ method_name ,
1748+ )
1749+
1750+
1751+ def _supervise_sync_lifecycle (
1752+ worker_future : asyncio .Future [Any ],
1753+ * ,
1754+ ctx : RequestContext [Any ],
1755+ method_name : str ,
1756+ registry : TaskRegistry ,
1757+ executor : ThreadPoolExecutor ,
1758+ on_complete : Callable [[Any ], Awaitable [None ]] | None ,
1759+ on_failure : Callable [[BaseException ], Awaitable [None ]] | None ,
1760+ pre_handoff_reject : Callable [[], None ] | None ,
1761+ request_params : BaseModel ,
1762+ webhook_target : WebhookDeliveryTarget | None ,
1763+ webhook_auto_emit : bool ,
1764+ ) -> None :
1765+ """Own a cancelled request's worker until its lifecycle settles."""
1766+ lifecycle = asyncio .create_task (
1767+ _settle_cancelled_sync_lifecycle (
1768+ worker_future ,
1769+ ctx = ctx ,
1770+ method_name = method_name ,
1771+ registry = registry ,
1772+ executor = executor ,
1773+ on_complete = on_complete ,
1774+ on_failure = on_failure ,
1775+ pre_handoff_reject = pre_handoff_reject ,
1776+ request_params = request_params ,
1777+ webhook_target = webhook_target ,
1778+ webhook_auto_emit = webhook_auto_emit ,
1779+ )
1780+ )
1781+ _SUPERVISED_SYNC_LIFECYCLES .add (lifecycle )
1782+ lifecycle .add_done_callback (_SUPERVISED_SYNC_LIFECYCLES .discard )
1783+
1784+
15991785async def _safe_on_failure_call (
16001786 on_failure : Callable [[BaseException ], Awaitable [None ]],
16011787 exc : BaseException ,
@@ -1808,6 +1994,11 @@ async def _run() -> None:
18081994 )
18091995 await _fail (wrapped )
18101996 return
1997+ except BaseException :
1998+ # Cancellation does not prove adopter work stopped. Leave any
1999+ # reservation fail-closed for expiry/reconciliation rather than
2000+ # release it while side effects may still be outstanding.
2001+ raise
18112002
18122003 # Framework completion hook (e.g., proposal_store.commit for
18132004 # finalize, mark_proposal_consumed for create_media_buy). Runs
0 commit comments