Skip to content

Directly awaiting a @superfunction call closes its coroutine before execution #19

Description

@pomponchik

A @superfunction call cannot be used directly in an await expression,
although this invocation form is explicitly documented. The temporary
UsageTracer is finalized before its internal coroutine starts running, so
the coroutine is closed and the await fails.

For example:

from asyncio import run

from transfunctions import async_context, superfunction


@superfunction
def function():
    with async_context:
        return "async result"


async def main():
    return await function()


print(run(main()))

Expected:

async result

Actual:

RuntimeError: cannot reuse already awaited coroutine

The failed call also reports a second exception through
sys.unraisablehook:

NotImplementedError:
The tilde-syntax is enabled for the "function" function.
Call it like this: ~function().

This message is misleading: the call is being used as an awaitable, not as an
unused synchronous call.

The failure specifically depends on the lifetime of the temporary
UsageTracer. Both of the following forms currently work because something
retains the tracer:

# asyncio.run retains the object passed to it.
result = run(function())


async def workaround():
    # A local variable keeps the UsageTracer alive across __await__().
    call = function()
    return await call

The natural and documented form does not work:

async def documented_form():
    return await function()

Regression tests

The following tests currently fail. They should all pass after the bug is
fixed:

import sys
from asyncio import run, sleep

from transfunctions import async_context, await_it, superfunction


def test_superfunction_can_be_awaited_directly():
    @superfunction
    def function(value):
        with async_context:
            return f"async:{value}"

    async def caller():
        return await function("value")

    assert run(caller()) == "async:value"


def test_direct_await_does_not_finalize_the_active_call_as_unused():
    unraisable = []
    original_hook = sys.unraisablehook

    def capture_unraisable(error):
        unraisable.append(
            (type(error.exc_value).__name__, str(error.exc_value)),
        )

    @superfunction
    def function():
        with async_context:
            return "result"

    async def caller():
        return await function()

    sys.unraisablehook = capture_unraisable
    try:
        try:
            outcome = run(caller())
        except Exception as error:
            outcome = (type(error).__name__, str(error))
    finally:
        sys.unraisablehook = original_hook

    assert (outcome, unraisable) == ("result", [])


def test_direct_await_supports_await_it_and_arguments():
    async def inner(value):
        await sleep(0)
        return value * 2

    @superfunction
    def function(value):
        with async_context:
            return await_it(inner(value))

    async def caller():
        return await function(21)

    assert run(caller()) == 42

Current result:

3 failed

The first and third tests raise:

RuntimeError: cannot reuse already awaited coroutine

They also produce PytestUnraisableExceptionWarning for the
NotImplementedError raised by the finalizer. The second test captures both
effects explicitly and currently observes:

(
    ("RuntimeError", "cannot reuse already awaited coroutine"),
    [
        (
            "NotImplementedError",
            'The tilde-syntax is enabled for the "function" function. '
            "Call it like this: ~function().",
        ),
    ],
)

Possible cause

UsageTracer.__init__() creates an internal coroutine and registers
sync_option() with weakref.finalize. The finalizer treats the call as an
unused synchronous call unless the shared used flag has already been set.

UsageTracer.__await__() currently returns
self.coroutine.__await__() directly. That iterator retains the internal
coroutine, but it does not retain the surrounding UsageTracer. In an
expression such as:

await function()

there is therefore no strong reference to the temporary UsageTracer after
__await__() returns. On CPython it can be finalized immediately, before
async_option() starts executing and sets flags["used"].

The finalizer consequently sees the call as unused, closes the internal
coroutine, and raises the tilde-syntax NotImplementedError. The event loop
then attempts to drive the iterator of that already-closed coroutine, which
results in RuntimeError: cannot reuse already awaited coroutine.

The await path needs to mark the call as used and keep the required state alive
for the entire await operation. An active await must not be handled by the
unused-call finalizer.

Environment

  • transfunctions: 0.0.13
  • Python: 3.10.8
  • OS: macOS 15.3.1 (build 24D70)

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions