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:
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:
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:
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)
A
@superfunctioncall cannot be used directly in anawaitexpression,although this invocation form is explicitly documented. The temporary
UsageTraceris finalized before its internal coroutine starts running, sothe coroutine is closed and the await fails.
For example:
Expected:
Actual:
The failed call also reports a second exception through
sys.unraisablehook: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 somethingretains the tracer:
The natural and documented form does not work:
Regression tests
The following tests currently fail. They should all pass after the bug is
fixed:
Current result:
The first and third tests raise:
They also produce
PytestUnraisableExceptionWarningfor theNotImplementedErrorraised by the finalizer. The second test captures botheffects 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 registerssync_option()withweakref.finalize. The finalizer treats the call as anunused synchronous call unless the shared
usedflag has already been set.UsageTracer.__await__()currently returnsself.coroutine.__await__()directly. That iterator retains the internalcoroutine, but it does not retain the surrounding
UsageTracer. In anexpression such as:
there is therefore no strong reference to the temporary
UsageTracerafter__await__()returns. On CPython it can be finalized immediately, beforeasync_option()starts executing and setsflags["used"].The finalizer consequently sees the call as unused, closes the internal
coroutine, and raises the tilde-syntax
NotImplementedError. The event loopthen 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