cocoindex-io/cocoindex · error · RuntimeError

Memo state function returned an awaitable from a sync contex

Error message

Memo state function returned an awaitable from a sync context with a running event loop. Use @coco.fn.as_async for the decorated function instead.

What it means

resolve_awaitables_sync is the sync/async bridge that blocks on awaitables returned by memo state functions via asyncio.run. It can only do this when no event loop is already running in the current thread; otherwise asyncio.run would fail. When it detects a running loop, it raises a RuntimeError whose message tells the caller to make the decorated function async (via @coco.fn.as_async) instead of returning an awaitable from a sync function.

Source

Thrown at python/cocoindex/_internal/context_keys.py:60

    the call returned a mix of values and awaitables, and we must block the
    caller until all awaitables resolve — but only if we're not already inside
    a running event loop, in which case we raise with a caller-specific
    message.

    *running_loop_error_msg* is the ``RuntimeError`` message used when we
    detect a running event loop — callers supply a message that points at
    their own remediation (e.g. ``@coco.fn.as_async`` for per-call state fns,
    or "provide the value outside an async context" for ``provide()``).
    """
    awaitable_indices = [i for i, o in enumerate(items) if isinstance(o, Awaitable)]
    if not awaitable_indices:
        return items
    try:
        asyncio.get_running_loop()
    except RuntimeError:
        pass
    else:
        raise RuntimeError(running_loop_error_msg)

    async def _gather() -> list[Any]:
        return list(await asyncio.gather(*(items[i] for i in awaitable_indices)))

    resolved = asyncio.run(_gather())
    out = list(items)
    for idx, val in zip(awaitable_indices, resolved):
        out[idx] = val
    return out


def _compute_initial_context_states(
    state_fns: list[StateFnEntry], key_name: str
) -> list[Any]:
    """Call each state function with ``NON_EXISTENCE`` and return their states.

    This is the one-time initial-state collection at ``provide()`` time. The
    resulting states are cached on the :class:`ContextProvider` and reused on

View on GitHub (pinned to e84aa99b32)

Solutions

  1. Make the decorated function an async function, or wrap the async implementation with the decorator's as_async form, e.g. `@coco.fn.as_async` instead of `@coco.fn`
  2. Run the memoized function from a sync context (blocking entry point like update_blocking) so no loop is running
  3. Change the state function to be synchronous so it does not return an awaitable

Example fix

// before
@coco.fn
def fetch_state(conn):
    return conn.fetch_one("SELECT ...")  # returns coroutine

// after
@coco.fn.as_async
async def fetch_state(conn):
    return await conn.fetch_one("SELECT ...")
Defensive patterns

Strategy: try-catch

Validate before calling

import asyncio

def safe_to_call_state_fn() -> bool:
    try:
        asyncio.get_running_loop()
        return False
    except RuntimeError:
        return True

Type guard

def is_awaitable(v: object) -> bool:
    return isinstance(v, collections.abc.Awaitable)

Try / catch

try:
    result = memoized_fn(args)
except RuntimeError as e:
    if "as_async" in str(e):
        result = await memoized_fn_async(args)  # async variant
    else:
        raise

Prevention

When it happens

Trigger: Calling a memoized (@coco.fn) function (or providing a detect_change context key) from within a running event loop, where a sync state function returns a coroutine/awaitable. resolve_awaitables_sync is invoked from _compute_initial_context_states (provide() time) or _resolve_results_awaitables_sync (cache-hit validation) inside the loop, hits `asyncio.get_running_loop()` succeeding, and raises.

Common situations: Defining a @coco.fn-decorated state/context function whose body calls an async API but whose def is not `async def`, then invoking the memoized function inside an async app (App.update() etc.). Copying sync-style code from a blocking script into an async pipeline.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of cocoindex-io/cocoindex@e84aa99b32 (2026-09-08). Data as JSON: /api/errors/36380efbfcb95a36. Report an issue: GitHub.