Repository navigation
async: an Async value is cold and runs again on every await - #177
Merged
Merged
Conversation
An `Async a` was a Python coroutine, so it was single-shot: awaiting the same
value twice raised `RuntimeError: cannot reuse already awaited coroutine`,
and `Async.parallel [job, job]` ran `job` once, because `gather` deduplicates
identical arguments. Pyfun's types already described F#'s cold `Async` (the
effect is attributed at the await), and now the runtime matches: building one
runs nothing, and every await is a fresh run.
An `Async` is an emitted `_pf_Async(fn, *args)` whose `__await__` calls
`fn(*args)` afresh. `async { }` lowers to `_pf_Async(_pf_fn0)`; a saturated
call of an extern returning `Async` to `_pf_Async(f, a, b)`, with function and
arguments evaluated at once; every `Async`/`Task` helper is a plain def
returning one. The emitter prints `await _pf_Async(f, *args)` as
`await f(*args)`, so an immediate `let!` still reads `await inbox.get()`, and
a fully applied `Async.sleep` reads `await asyncio.sleep(secs)`.
Where Python insists on a real coroutine, the value passes through
`async def _pf_run(a): return await a`: an extern parameter typed `Async a`
(`asyncio.run(_pf_run(main))`), a callback parameter returning `Async`, a
`unit -> Async a` thunk, and each element `Async.parallel`/`race` start. The
callback case matters: `asyncio.start_server` schedules a handler's result
only if it is a coroutine and would silently drop a `_pf_Async`. In a
project, the exporting module's binding for such an extern adapts, since a
dependent knows only its arity.
The async self tail call now gives the coroutine the loop variables as its
own parameters (`async def g(n, acc): while True: ...`, returned as
`_pf_Async(g, n, acc)`), so each await starts from the arguments the value
was built with. No `nonlocal` is left in that shape, so #174's pass, which
un-annotated a parameter rebound through `nonlocal` (python/mypy#22221), has
nothing to do and is removed.
Breaking: Python code handed a Pyfun `Async` receives an awaitable rather
than a coroutine. Awaiting it works, any number of times; an asyncio API that
wants a coroutine needs it declared as an `Async` extern parameter.
…sync externs stay cold CI's Python 3.12 rejected `asyncio.run(twice)` in two tests that hand Python a Pyfun value, where 3.14 accepts any awaitable. `_pf_Async` now has `async def __call__(self): return await self`, so Python code runs a Pyfun value the way it would a zero-argument `async def`, `asyncio.run(job())`, on every supported version. A bare or partially applied extern returning or taking `Async` (`urls |> List.map fetch`) was the raw Python callable, so its results were single-shot coroutines. A bare reference now lowers to the extern's adapting lambda (`lambda p0: _pf_Async(asyncio.sleep, p0)`) and a partial application to a `functools.partial` over it, so those values re-run on every await too. Every e2e program also ran under Python 3.12 in Docker: 285 of 287 match 3.14, and the two others differ only in glibc's `math.cbrt(27.0)` (3.0000000000000004), which predates this change.
simontreanor
added a commit
that referenced
this pull request
Oct 17, 2026
* ci: run the tests on Python 3.14 as well as 3.12 The test job ran only on 3.12, the oldest supported Python, while local runs use 3.14, so a behaviour that differs between the two ends of the range was caught by neither alone: 3.12's asyncio.run takes only a coroutine, where 3.14's takes any awaitable (#177). The matrix keeps 3.12 on all three systems and adds 3.14 on Linux. CONTRIBUTING says so. * examples: hello takes the cube root of 64, which is exact on every platform hello.pyfun printed cbrt 27.0 and its comments promised 3.0, but glibc's cbrt is not correctly rounded, so Linux and most cloud notebooks print 3.0000000000000004. 64.0 gives 4.0 on Linux and Windows alike, and the example now prints the same on both. DESIGN notes that, as in Python, a float maths function's last digit follows the platform's C library.
simontreanor
added a commit
that referenced
this pull request
Oct 17, 2026
….parallel (#179) Since #177 an extern whose Python call starts work at once (an asyncio Task, a JS promise) starts it at the await instead. Two such calls bound with let and awaited afterwards used to overlap and now run one after the other, and awaiting one twice makes the call twice. Checked against the 0.9.0 wheel: the same program started both at 0.0s there and the second at 0.5s now, while Async.parallel overlapped under both. DESIGN §8 and the Pyodide passage say so, and lesson 18 tells the reader to use Async.parallel for overlap.
This was referenced Oct 17, 2026
Merged
simontreanor
added a commit
that referenced
this pull request
Oct 18, 2026
Bumps the compiler, the VS Code and JetBrains clients and the Emacs mode to 0.10.0. Minor rather than patch: an Async value is cold (#177), so Python code that ran a Pyfun async value with asyncio.run(job) calls it, asyncio.run(job()).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes Pyfun's
async { }behave like F#'sasync: cold and composable. Building a value runs nothing, and every await is a fresh run.Before: an
Async awas a Python coroutine, so it was single-shot. Awaiting the same value twice raisedRuntimeError: cannot reuse already awaited coroutine, andAsync.parallel [job, job]ranjobonce, becausegatherdeduplicates identical arguments. The type system already described cold semantics (DESIGN §8 attributes the effect at the await); now the runtime matches.How it lowers:
Asyncis an emitted_pf_Async(fn, *args)whose__await__callsfn(*args)afresh.let job = async { … }→job = _pf_Async(_pf_fn0). A saturated call of an extern returningAsync→_pf_Async(f, a, b), with the function and arguments evaluated at once. EveryAsync/Taskhelper is a plaindefreturning one.await _pf_Async(f, *args)asawait f(*args), so the output still readsawait inbox.get()andawait asyncio.sleep(0.01).async def _pf_run(a): return await a. That covers an extern parameter typedAsync a(asyncio.run(_pf_run(main))), a callback parameter returningAsync, aunit -> Async athunk, and each elementAsync.parallel/racestart. In a project, the exporting module's binding adapts, since a dependent knows only the arity.async def g(n, acc): while True: …→_pf_Async(g, n, acc)), so each await starts from the original arguments. With nononlocalleft in that shape, native: a parameter rebound through nonlocal stays unannotated #174's un-annotating pass has nothing to do and is removed.The callback case (raised during review of the plan):
asyncio.start_serverschedules a handler's result only if it is a coroutine, so a handler returning_pf_Asyncwould silently never run.e2e_an_async_callback_handed_to_python_runsruns a real server with a Pyfun handler. I confirmed it tells the two apart: with the raw handler, the client readsSome('')with no error; adapted, it readsSome('HELLO').Breaking: Python code handed a Pyfun
Asyncnow receives an awaitable rather than a coroutine. It can await it any number of times, or call it for a fresh coroutine, as with a zero-argumentasync def:asyncio.run(job()). Python 3.14'sasyncio.run(job)accepts the value directly; 3.12 and 3.13 need the call. CI caught this, since two tests hand Python a Pyfun value. A bare or partially applied async extern (urls |> List.map fetch) uses the same adapting lambda as the project binding, so its results are cold too.Tests: e2e for cold re-runs (the same value awaited twice and passed to
Async.paralleltwice runs four times), thestart_servercallback, and a two-module project (an importedAsyncextern re-runs, and its argument reachesasyncio.runas a coroutine). Existing tests updated for the new shapes.Docs: DESIGN §5.4/§5.6/§8, INTERNALS, ROADMAP, and lesson 18, which gains an "Awaiting a value twice" section with a verified example; its notebook is regenerated.
Python 3.12: every e2e program also ran under 3.12 in Docker. 285 of 287 match 3.14; the two others differ only in glibc's
math.cbrt(27.0)(3.0000000000000004), which predates this change.Checked locally:
cargo fmt,cargo clippy --all-targets(clean),cargo test(every binary),docs/verify_lessons.py,build_notebooks.py --check,tests/kernel_e2e.py(all pass, including the async cell), andtests/native_parity.py: 84 of 84 match under mypyc.