Skip to content

async: an Async value is cold and runs again on every await - #177

Merged
simontreanor merged 2 commits into
mainfrom
cold-async
Oct 17, 2026
Merged

simontreanor merged 2 commits into
mainfrom
cold-async

Conversation

@simontreanor

@simontreanor simontreanor commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Makes Pyfun's async { } behave like F#'s async: cold and composable. Building a value runs nothing, and every await is a fresh run.

Before: 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. The type system already described cold semantics (DESIGN §8 attributes the effect at the await); now the runtime matches.

How it lowers:

  • An Async is an emitted _pf_Async(fn, *args) whose __await__ calls fn(*args) afresh. let job = async { … } → job = _pf_Async(_pf_fn0). A saturated call of an extern returning Async → _pf_Async(f, a, b), with the 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 the output still reads await inbox.get() and await asyncio.sleep(0.01).
  • Where Python insists on a real coroutine, a value goes through async def _pf_run(a): return await a. That covers 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. In a project, the exporting module's binding adapts, since a dependent knows only the arity.
  • The async self tail call now gives the coroutine its loop variables as parameters (async def g(n, acc): while True: … → _pf_Async(g, n, acc)), so each await starts from the original arguments. With no nonlocal left 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_server schedules a handler's result only if it is a coroutine, so a handler returning _pf_Async would silently never run. e2e_an_async_callback_handed_to_python_runs runs a real server with a Pyfun handler. I confirmed it tells the two apart: with the raw handler, the client reads Some('') with no error; adapted, it reads Some('HELLO').

Breaking: Python code handed a Pyfun Async now 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-argument async def: asyncio.run(job()). Python 3.14's asyncio.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.parallel twice runs four times), the start_server callback, and a two-module project (an imported Async extern re-runs, and its argument reaches asyncio.run as 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), and tests/native_parity.py: 84 of 84 match under mypyc.

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
simontreanor merged commit d75fe36 into main Oct 17, 2026
21 checks passed
@simontreanor
simontreanor deleted the cold-async branch October 17, 2026 22:37
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.
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()).
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.

1 participant