Skip to content

Commit 08a8490

Browse files
committed
fix(decisioning): supervise timed and canceled work
1 parent b6c05c5 commit 08a8490

13 files changed

Lines changed: 1163 additions & 89 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
### Bug Fixes
1717

18+
* **decisioning:** supervise timed/cancelled synchronous work and sanitize INTERNAL_ERROR cause details to exception type only; `details.caused_by.message` is removed (non-normative under AdCP 3.1.8, with `recovery` unchanged)
1819
* **security:** harden SDK auth transports ([a2610a5](https://github.com/adcontextprotocol/adcp-client-python/commit/a2610a5b4f8e0d0d1d500fc3908be1d6862b0764))
1920
* **server:** unify divergent host normalizers behind one helper ([#997](https://github.com/adcontextprotocol/adcp-client-python/issues/997)) ([6fb1b72](https://github.com/adcontextprotocol/adcp-client-python/commit/6fb1b72d45ec8adfc2be93c5232464d6ff1c69e3))
2021
* **signing:** block CGNAT and 6to4 relay ranges in SSRF validation ([#974](https://github.com/adcontextprotocol/adcp-client-python/issues/974)) ([0207429](https://github.com/adcontextprotocol/adcp-client-python/commit/020742979cabb5e721ab33c41caddb6b9849074a))

src/adcp/decisioning/dispatch.py

Lines changed: 222 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@
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+
)
6268
from adcp.decisioning.types import (
6369
AdcpError,
6470
TaskHandoff,
@@ -85,6 +91,11 @@
8591

8692
logger = 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+
586602
def _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+
15991785
async 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

Comments
 (0)