Skip to content

async: self tail calls loop inside the coroutine, and Async.race [] gets a named error - #119

Merged
simontreanor merged 1 commit into
mainfrom
async-tail-calls
Aug 30, 2026
Merged

async: self tail calls loop inside the coroutine, and Async.race [] gets a named error#119
simontreanor merged 1 commit into
mainfrom
async-tail-calls

Conversation

@simontreanor

@simontreanor simontreanor commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Closes #118.

The game agent's feedback-round finding: the cookbook's recursive agent loop (return! agentLoop inbox (count + 1)) nested an await per message, so ~1,000 messages hit RecursionError — a clock posting Tick every second died in under twenty minutes, and an agent is precisely the thing written to live indefinitely. Also from the same note: Async.race [] raised asyncio's error about an empty set.

The §5.4 rewrite gets an async form

let f a = async { … } lowers to def f(a): async def g(): …; return g(), and its self tail call is return await f(…) inside g — a fresh coroutine awaited on the same stack per iteration. When a body ends in exactly that wrapper shape, the awaited tail calls now rebind the outer parameters (they are g's closure, so a nonlocal makes them writable) and go round a while True: inside the one coroutine:

def agentLoop(inbox, count):
    async def _pf_fn1():
        nonlocal inbox, count
        while True:
            msg = await inbox.get()
            match msg:
                case Said(s):
                    inbox, count = (inbox, heard(count, s))
                    continue
                case Tick():
                    inbox, count = (inbox, count)
                    continue
                case Quit():
                    return await finished(count)

The sync pass's preconditions apply to g's body unchanged (name not rebound, no generator, no closure capturing a rebound name), with the same rejection notes, so a refused rewrite still tells the author why it kept recursing.

Making the tail call visible. return! (match …) in an async { } block used to lower the match in value position (_pf_t0 = agentLoop(...)return await _pf_t0), which hid the call from any tail-position analysis. It now returns per arm — the same descent a sync function's tail match already had — which is also simply better output. return (match …) gets the sync lower_return descent for the same reason.

The cookbook agent is reworked to the loop-safe shape: a sync heard count s helper performs the effect and returns the next count, so the recursive call stays a direct tail call (case Said s: agentLoop inbox (heard count s)); a helper returning the next Async would be mutual recursion, which stays out of scope as in the sync pass, and DESIGN.md §5.4 now says so. Output unchanged.

Async.race []

Racing nothing would wait forever, so the empty list raises ValueError("Async.race needs at least one value") at the await — the module's one partial member, documented on hover and in DESIGN.md §6 — instead of asyncio.wait's message about an empty set. Async.catch reports it like any await-time failure.

Tests

Shape: the loop with nonlocal, per-arm return await, no recursive call left; the cookbook example asserted to loop. Behaviour: 100,000 awaited tail calls complete e2e (past the ~1,000 limit by two orders of magnitude); the closure-capture rejection keeps the recursive form and emits the note; the empty race prints the named error through Async.catch. ROADMAP records both as feedback-round items 21–22. cargo test, cargo clippy --all-targets and cargo fmt --check are clean.

…ets a named error

Found by the game agent: the cookbook's recursive agent loop awaited a
fresh coroutine per message on the same stack, so ~1,000 messages was a
RecursionError - fatal for the one thing an agent is written to be,
long-lived. The 5.4 rewrite now has an async form: when a body is the
def f(a): async def g(): ...; return g() wrapper, the awaited tail
calls in g rebind the outer parameters through a nonlocal and loop a
while True: inside the one coroutine, under the sync pass's
preconditions and with the same rejection notes. To make the tail call
visible, return! of a match/if in an async block now returns per arm
(the descent a sync tail match already had) instead of assigning a temp
and awaiting it once. The cookbook agent is reworked to the loop-safe
shape (a sync heard helper computes the next state, keeping the
recursive call direct); 100,000 messages complete in the e2e test.

Async.race [] raised asyncio's ValueError about an empty set; racing
nothing would wait forever, so it now raises ValueError('Async.race
needs at least one value') at the await, the module's one partial
member, documented on hover and in DESIGN 6. DESIGN 5.4 and ROADMAP
(feedback-round items 21-22) updated.
@simontreanor
simontreanor merged commit 03f11b3 into main Aug 30, 2026
16 checks passed
@simontreanor
simontreanor deleted the async-tail-calls branch August 30, 2026 19:26
@simontreanor simontreanor mentioned this pull request Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The recursive agent loop hits the recursion limit near 1,000 messages: async self-tail-calls nest instead of iterating

1 participant