Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 39 additions & 13 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,12 +314,15 @@ language changes — this is a property of the emitted code, and the recursive d
only way to write it.

The **async form** is the same rewrite one wrapper in. `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` —
`def f(a): async def g(): …; return _pf_Async(g)`, and its self tail call is `return await f(…)`
inside `g` —
each message of an agent loop then awaits a fresh coroutine *on the same stack*, so CPython's limit
is the loop's lifetime cap (measured: ~1,000 messages), which is fatal for exactly the thing an
agent is written to be: long-lived. When a body ends in that wrapper shape, the awaited tail calls
rebind the outer parameters (they are `g`'s closure, so a `nonlocal` declaration makes them
writable) and go round a `while True:` inside the one coroutine. For the tail call to be *visible*,
agent is written to be: long-lived. When a body ends in that wrapper shape, `g` takes the
parameters as its own (`async def g(a): while True: …`, returned as `_pf_Async(g, a)`), and the
awaited tail calls rebind them and go round the loop inside the one coroutine. Because an `Async` is
cold (§8), every await of `f(…)` starts again from the arguments it was built with, and no
`nonlocal` is needed. For the tail call to be *visible*,
`return!` of a `match`/`if` in an `async { }` block lowers with a return per arm (`return await
loop(inbox, n)` in the arm) rather than assigning a temp and awaiting it once — the same descent a
sync function's tail `match` gets. The preconditions below apply to `g`'s body unchanged; the
Expand Down Expand Up @@ -438,11 +441,11 @@ from their inferred types where Python can spell them: `int`, `float`, `bool`, `
record's class, and a union alias per user sum type (`Expr = Num | Add | Mul | Neg`, emitted after
its classes). A type variable, a function type or an async result stays unannotated, so the
annotations never claim more than the checker proved, and so does a `unit` result, since Pyfun
returns a unit call's value (`return f(x)`), which mypy rejects under `-> None`. A parameter
that a nested function rebinds through `nonlocal` (an async self tail call's, §5.4) stays
unannotated too: mypyc copies an annotated `int` parameter into the closure environment without
boxing it, which gcc rejects and an MSVC build turns into a segfault (python/mypy#22221). Data
class fields follow suit: a field declared as a user sum type or record names it (`_0: "Expr"`,
returns a unit call's value (`return f(x)`), which mypy rejects under `-> None`. Pyfun never
rebinds a parameter through `nonlocal` (the async self tail call loops over the coroutine's own
parameters, §5.4), which matters to mypyc: it copies an annotated `int` parameter into a closure
environment without boxing it, which gcc rejects and an MSVC build turns into a segfault
(python/mypy#22221). Data class fields follow suit: a field declared as a user sum type or record names it (`_0: "Expr"`,
a string because the alias comes later), and any other field without a Python class of its own
is `typing.Any`, since mypyc cannot build a dataclass with an `object` field. The output is
written for mypy's newer redefinition rule (`--allow-redefinition-new --local-partial-types`): a
Expand Down Expand Up @@ -840,8 +843,30 @@ lowers to an emitted `_pf_fmt_*` helper wrapping `format(x, spec)` (the spec a n
from the checked `int`) or `str.rjust`/`ljust`. Dates are deferred (no date type; they would need a
Python `datetime` `extern`).

**Async combinators — the `Async` module.** `Async a` is the type of a coroutine and `async { }`
builds one; the module is the rest of what F#'s `Async` offers that `asyncio` can supply, typed once
**Cold async.** An `Async a` is **cold**, as F#'s `Async` is: building one runs nothing, and every
await is a fresh run, so the same value can be awaited twice, retried, or passed to
`Async.parallel` twice and run twice. A Python coroutine is single-shot (a second `await` raises
`RuntimeError: cannot reuse already awaited coroutine`), so the value is not one. It is a small
emitted `_pf_Async(fn, *args)` whose `__await__` calls `fn(*args)` afresh: `let job = async { … }`
lowers to `job = _pf_Async(_pf_fn0)`, and a saturated call of an `extern` whose result is `Async`
to `_pf_Async(f, a, b)` (function and arguments evaluated at once; pinned keywords ride a
`functools.partial`). Awaiting one where it is built prints as the direct call (`await
inbox.get()`). Where Python insists on a real coroutine, a value passes through `_pf_run(x)` (`async
def _pf_run(a): return await a`), a fresh coroutine of any awaitable: an `extern` parameter typed
`Async a`, a callback parameter returning `Async` (wrapped so each call Python makes returns one:
`asyncio.start_server` schedules a handler's result only if it is a coroutine, and would otherwise
drop it silently), a `unit -> Async a` thunk, and every element `Async.parallel`/`race` start. In a
project the exporting module's binding for such an extern does the adapting, since a dependent
knows only its arity, and a bare or partially applied reference is the same adapting lambda
(`lambda p0: _pf_Async(asyncio.sleep, p0)`, under a `functools.partial` for supplied arguments), so
`urls |> List.map fetch` builds cold values too. Python code handed a Pyfun `Async` can `await` it
any number of times, or call it for a fresh coroutine, as it would a zero-argument `async def`
(`asyncio.run(job())`): `_pf_Async.__call__` is `async def __call__(self): return await self`.
Python 3.14's `asyncio.run` accepts any awaitable, but 3.12 and 3.13 want a coroutine, so code that
must run on them calls the value.

**Async combinators — the `Async` module.** `async { }` builds an `Async a`; the module is the rest
of what F#'s `Async` offers that `asyncio` can supply, typed once
(single source of truth `types::ASYNC_PRELUDE` + `seed_async_prelude`): `Async.sleep secs`,
`Async.timeout secs c : Async (Option a)` (`asyncio.wait_for`, `None` on the deadline),
`Async.toThread thunk` (a blocking call off the event loop, `asyncio.to_thread`), `Async.parallel
Expand All @@ -852,9 +877,10 @@ partial member) and `Async.catch c : Async (Result a Exception)`, the await-time
catches at *call* time, and an async extern does not throw when called, it returns a coroutine that
throws when awaited, so `try` would wrap the coroutine and the `TimeoutError` would escape at the
`let!`. `catch` catches `Exception`, not `BaseException`, so `asyncio.CancelledError` passes through
and structured cancellation keeps working. Building any of these is pure (a coroutine is a value);
and structured cancellation keeps working. Building any of these is pure (an `Async` is a value);
the `async` effect is attributed where the value is awaited, except `toThread`, whose arrow carries
the thunk's effect. `sleep` is `asyncio.sleep` itself; the rest are emitted `_pf_async_*` helpers,
the thunk's effect. Each is an emitted helper returning a `_pf_Async` (a fully applied
`Async.sleep secs` builds `_pf_Async(asyncio.sleep, secs)` directly),
because `gather(*xs)` spreads a list the extern syntax cannot, `wait` returns task sets, and two of
them build `Option`/`Result` values. **Deliberately absent:** `Async.start` and `Async.cancel`. A task
should only start inside a scope that will join or cancel it, which is the structured-concurrency
Expand Down
20 changes: 17 additions & 3 deletions INTERNALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,9 +417,7 @@ A `Lowerer.native` flag, set by `lowering::lower_native_typed` (single file, cal
`_X: X = X()`, and the first assignment of a top-level value bound once.
- **Comparisons and closures.** `native_classes` sets `PyStmt::ClassDef::native`, so an ordered
variant emits `__eq__` and all four orderings instead of `@total_ordering` (mypyc compiled `==` as
identity on a class with `__lt__` alone). After `annotate_functions`, `unannotate_nonlocal_params`
clears the annotation of any `TypedFuncDef` parameter a nested function names in a `nonlocal`
(python/mypy#22221).
identity on a class with `__lt__` alone).
- **Discarded matches.** `Lowerer::push_discarded` emits a statement-position expression. In
native mode, when its value is a temp set by an `if` ladder, `discard_temp` turns each assignment
of the temp into a bare evaluation, since mypyc rejects a local whose every assignment is a unit
Expand Down Expand Up @@ -608,6 +606,22 @@ top-level evaluation's performed effect (value bindings, expressions, module val
`Task.scope`/`Task.start` to `_pf_task_scope`/`_pf_task_start` (`async_prelude`) and
`note_task_scope_var` defines or imports `_pf_scope`.

**Cold async (DESIGN §8).** `lower_async` returns `Lowerer::cold_async([g])`, a
`_pf_Async(g)` call, and records `_pf_Async` in `needed_async_helpers`; `async_prelude` emits the
class as a `PyStmt::Class` (the plain-class IR node) and adds `_pf_Async`/`_pf_run` whenever a helper
that needs them is used. Every `Async`/`Task` helper is a plain `def` returning `_pf_Async(run)`
around an inner `async def run()`. At the boundary, `Lowerer::new` records each extern's
`async_param_slots` (`AsyncSlot::Value`/`Callback`/`Thunk`) and whether it `returns_async`;
`wrap_async_args` runs after `wrap_thunk_args` at every extern call site, and `lower_application`
wraps a saturated `Async`-returning extern call with `cold_extern_result` (`_pf_Async(f, *args)`,
or a `functools.partial` for pinned keywords). In a project, `async_extern_binding` gives such an
extern an adapting lambda, since a dependent knows only its arity; `lower_var_value` uses the same
lambda for a bare reference, and `lower_application` a `functools.partial` over it for a partial
application. `_pf_Async.__call__` gives Python code a fresh coroutine (`asyncio.run(job())`). The emitter prints
`await _pf_Async(f, *args)` as `await f(*args)`. The async self-tail-call rewrite
(`rewrite_async_wrapper`) matches `return _pf_Async(g)`, gives `g` the parameters and returns
`_pf_Async(g, *params)`.

## Solving effect equations (`Infer::unify_eff`) — implements DESIGN §4

An `Effect` is a set of concrete labels plus a set of effect variables, so unification is over an
Expand Down
6 changes: 3 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -525,9 +525,9 @@ map_build 1.64× vs hand-written.
`tests/native_parity.py` (the `native` CI job) diffs every runnable lesson, the offline examples
and the programs in `tests/native/` against `pyfun run`, so a native-only miscompile fails the PR.
Two it found are fixed: `==` on a compared sum type (explicit comparison methods, §5.6) and an
async self tail call, whose parameters a coroutine rebinds through `nonlocal`. mypyc copied such
a parameter into the closure unboxed when it was annotated `int` (python/mypy#22221), so those
parameters stay unannotated (#173).
async self tail call, whose parameters a coroutine rebound through `nonlocal`, which mypyc copied
into the closure unboxed when they were annotated `int` (python/mypy#22221, #173). Since `Async`
went cold, that loop runs over the coroutine's own parameters and needs no `nonlocal` at all.
The original reasoning, kept for the record:
the checker knows every binding's inferred type, so the emitter could produce fully annotated
Python whose annotations cannot lie, then compile it with mypyc into a C extension — native speed
Expand Down
64 changes: 51 additions & 13 deletions docs/src/learn/18-async.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,28 +22,63 @@ print (runAsync fetchScore)
```

The key idea carries straight over from Python. An `async { }` block does not run when you write it.
It builds a coroutine, exactly like calling an `async def` in Python hands you an awaitable rather
than a result. Nothing happens until something drives that coroutine. Here `let! x = async { return
20 }` awaits an inner block and binds `20` to `x`, then `return x + 1` produces the final `21`, but
only once the coroutine is run.
It describes work to do later, much as calling an `async def` in Python hands you something to await
rather than a result. Nothing happens until something awaits it. Here `let! x = async { return 20 }`
awaits an inner block and binds `20` to `x`, then `return x + 1` produces the final `21`, but only
once the whole block is run.

The emitted Python shows the mapping directly. Each `async { }` becomes a nested `async def`, each
`let!` becomes an `await`, and the whole value is a coroutine object:
`let!` becomes an `await`, and the value itself is a small `_pf_Async` that remembers which function
to run. (Two short helpers, `_pf_Async` and `_pf_run`, sit at the top of the file and are left out
here.)

```python
import asyncio
async def _pf_fn1():
async def _pf_fn0():
return 20
x = await _pf_fn0()
return x + 1
fetchScore = _pf_fn1()
print(asyncio.run(fetchScore))
fetchScore = _pf_Async(_pf_fn1)
print(asyncio.run(_pf_run(fetchScore)))
```

## Awaiting a value twice

Here is one place where Pyfun goes further than Python. Try awaiting the same value twice:

```pyfun
extern runAsync: Async a -> a = asyncio.run

let roll =
async {
print "rolling"
return 6
}

let twice =
async {
let! a = roll
let! b = roll
return a + b
}

print (runAsync twice)
```

```console
rolling
rolling
12
```

The block runs twice, once for each `let!`. A Python coroutine can only be awaited once, and a second
`await` of it raises an error. A Pyfun `Async` value can be awaited as often as you like, and each
await runs it again from the start. F# calls this a *cold* async. It means you can keep an `Async` in
a variable, pass it to a function that retries it, or put the same one in a list twice.

## Running one at the top level

A coroutine has to be awaited somewhere, and at the top level there is no enclosing `async`
An `Async` value has to be awaited somewhere, and at the top level there is no enclosing `async`
function to await it in. Python solves this with `asyncio.run(main())`, and Pyfun makes the same
move through `extern` (lesson 12). The line

Expand All @@ -52,7 +87,8 @@ extern runAsync: Async a -> a = asyncio.run
```

names Python's `asyncio.run` and gives it the Pyfun type `Async a -> a`: hand it an `Async a` and it
drives the coroutine to completion and returns the `a`. So `fetchScore |> runAsync` turns the
runs it to completion and returns the `a`. `asyncio.run` wants a Python coroutine, so Pyfun hands it
a fresh one made from the value, which is the `_pf_run(fetchScore)` you saw in the output. So `fetchScore |> runAsync` turns the
`Async int` into a plain `int` you can `print`.

## Async is an inferred effect
Expand Down Expand Up @@ -82,7 +118,9 @@ purity cannot be claimed over it.

An async Python function returns a coroutine when you call it, and nothing happens until that
coroutine is awaited. The Pyfun type says exactly that: the result is `Async a`, and `let!` (or
`do!` when there is no value) inside an `async { }` block is where the await happens.
`do!` when there is no value) inside an `async { }` block is where the await happens. Pyfun saves
the call itself for the await, so awaiting the result twice calls the Python function twice, just as
it runs an `async { }` block twice.

```pyfun
extern runAsync: Async a -> a = asyncio.run
Expand Down Expand Up @@ -226,7 +264,7 @@ effect system tracks it for you.

Fill the hole so `combined` awaits two async blocks and returns their sum. The hole has type `int`
(the value the second block returns), so any integer literal works. The runner bridge over
`asyncio.run` is already in place, and `runAsync combined` drives the coroutine to a value.
`asyncio.run` is already in place, and `runAsync combined` runs it to a value.

```pyfun
extern runAsync: Async a -> a = asyncio.run
Expand Down Expand Up @@ -266,5 +304,5 @@ print (runAsync combined)
```

`let!` awaits each inner block and binds its result, so `a` is `10` and `b` is `20`, and `return a +
b` produces `30` once `runAsync` drives the coroutine.
b` produces `30` once `runAsync` runs the block.
</details>
Loading
Loading