Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Exploring async/await without exposed pinning

Status: Not implemented; exploratory proposal with a standalone native spike. Async/await is not accepted or scheduled for Plenty. The user requested this investigation while the language can still change freely, despite its original deliberate exclusion of async/await. Keeping it out remains a valid outcome. The backlog records that boundary; concurrency and generators describe what works today. Nothing here changes the tutorial or production compiler.

The question and provisional answer

Could Python-shaped async syntax coexist with Plenty’s explicit, fallible allocation and deterministic ownership, without exposing Pin or requiring asynchronous destructors? A restricted design looks possible: owned, concrete movable frames; no surviving borrow across suspension; explicit task admission; and synchronous cancellation only for operations whose pending work can be safely abandoned. The native spike demonstrates those mechanics for a tiny language. It does not demonstrate that the restrictions are ergonomic enough for HTTP/TLS or that a production scheduler is affordable.

This could improve simplicity for Plenty’s narrower contract, not improve on Python or Rust in general. In particular, avoiding arbitrary borrows across await gives up a major capability of Rust’s design. Refusing implicit async cleanup gives up automatic completion of some protocol obligations. Moving those costs into adapters or a runtime does not make them disappear.

What to borrow from Python and Rust

Python separates a coroutine from a scheduled task: calling an async function creates a coroutine, while awaiting or scheduling drives it. async def and prefix await fit Plenty’s surface syntax. Python also specifies separate asynchronous context-manager operations; these are more than alternate spelling for ordinary deterministic drop. PEP 492 describes that syntax and protocol separation.

Python task groups provide an attractive execution-ownership model: their exit waits for child tasks, and failures initiate sibling cancellation. Cancellation is delivered through CancelledError, can be suppressed, and needs careful handling in cleanup. Task creation can also opt into eager execution, so Python is not a mandate for one immutable scheduling rule. Plenty should specify lazy construction and task admission itself. Python task documentation is the comparison source, consulted 2026-10-22.

Rust’s await machinery polls a child and suspends its parent on Pending; its completion output can itself be a Result. That separation of suspension from failure suits Plenty. Rust’s await reference and Future contract also make readiness notification and the poll interface explicit.

Rust permits address-sensitive generated state, including a reference to another field in the same future. Pinning protects the resulting address invariant; it is not arbitrary syntax that can simply be deleted. The pinning documentation explains that motivation. Rust’s AsyncDrop remains an experimental nightly API in the documentation consulted. This proposal avoids relying on it and makes no claim that Rust’s cleanup design is settled or inherently impossible to simplify.

Candidate surface and evaluation order

The following is proposed Plenty syntax, not accepted source:

async def fetch(request: Request) -> Result[Response, TransportError | AllocError]:
    response = (await exchange(request))?
    Ok(response)

fetch(request) evaluates arguments immediately and moves/copies them into a concrete Coroutine[Result[Response, ...]]. It does not execute the body or schedule anything. Frame construction is inline and adds no allocation; argument expressions retain their own failures. await consumes a coroutine and produces its declared result. It may finish immediately or suspend; it does not imply another task, a thread, a failure, or a fairness yield. Parentheses deliberately leave await/? precedence undecided. ? propagates only the result error and destroys active owned locals through the ordinary cleanup path.

Like today’s Generator[T], Coroutine[T] would retain a concrete producer identity rather than secretly box different producers into one type. Returning coroutines from factories, generic specialization, frame-size limits, recursive async calls and containers need explicit contracts. Initially reject recursive inline layouts and erased/dynamic coroutine storage. A coroutine value is affine: it cannot be copied or awaited twice. Dropping an unstarted one destroys captures without executing the body; this is a deliberate difference from Python’s unawaited-coroutine warning behavior.

async would be legal only on functions in the initial scope. await would be legal only inside them, plus a library root runner that consumes a coroutine. No implicit async main, async iteration, async closures, arbitrary awaitable protocol, async generators or async with follows from adopting this core. These restrictions keep a possible first implementation independently assessable.

Scheduling is a separate choice

Direct await is sequential composition. To overlap work, an explicit bounded scope admits multiple roots. A candidate API is scope.start(coroutine) -> Result[Task[T], StartError[Coroutine[T]]]: capacity or allocation failure returns the entire unstarted owner. The scope owns execution; dropping a public handle must not orphan tasks. Results need their own owned, allocation-audited storage and a policy when unclaimed. Prefer one result consumer over implicit reference-counted shared results.

The minimal affine scope can synchronously cancel its remaining abort-safe children at exit. A stronger scope promising asynchronous graceful completion needs explicit awaited closure or retained responsibility elsewhere. It cannot silently borrow an enclosing stack and let that stack return. The distinction is developed in ownership and cleanup.

Existing Future[T] represents an executor job’s result and has different drop/cancellation rules. Do not reinterpret it as a coroutine or make .result() secretly await. Blocking code called from async remains blocking. A future adapter must submit work fallibly, wake the async consumer safely, preserve result/error ownership, and define cancellation when the worker continues. No such adapter exists here.

Alternatives and the decision still needed

DesignBenefitCost or unresolved boundary
Keep current threads, bounded executors and readiness APIsNo second calling convention or async ecosystem; existing fallible admissionMany mostly idle connections may need manual state machines or many native stacks; measure a real workload
Owned movable async frames (spike)Inline composition, no pin API or frame allocation per callNo stored loan across suspension; ownership-return I/O can be cumbersome
Runtime-owned stable framesPotentially allow broader borrows without user-facing pin typesFallible per-frame/arena reservation, lifetime proof, no relocation, fragmentation; address stability is internalized, not removed
Stackful tasks / goroutine-style syntaxSynchronous-looking call chains can suspend deeplyFixed stacks consume reserved memory and can exhaust; growing/moving stacks must preserve pointers, FFI and allocation failures; blocking calls and task admission still need policies
General Rust-like borrowed futuresBroad zero-copy composition and explicit low-level controlAddress-sensitive frame projection and poll contracts recreate the complexity we want to avoid

The independent stackful-task exploration investigates bounded fallible stack creation, guard pages and overflow behavior, cross-thread movement, foreign calls and cancellation. Stackful tasks are an alternative to syntax-directed stackless transformation, not a consequence of it. Current OS workers already provide one stackful baseline. Neither syntax alone solves protocol cleanup.

Before considering adoption, compare at least one bounded connection workload and one file/worker workflow using existing APIs and each plausible prototype. Measure allocations, reserved/live memory, throughput, latency, cancellation latency, compile time and API friction. The spike establishes neither a performance win nor a requirement for async. The main open decision is whether owned-operation ergonomics justify a second execution model; a full borrowing redesign should not be smuggled in as a small syntax convenience.