Demand, async, and defer

Koskari bytecode runs on an async-native evaluator: suspension and resumption are normal, and many Python calls that return coroutines or awaitables are auto-awaited at call boundaries. How you host that evaluator (with or without an asyncio event loop) is a separate choice.

Ambient async execution

Python treats asynchronous execution as opt-in: functions are marked async, and calling an async function returns a coroutine that must be awaited.

Koskari treats asynchronous execution as the default. Every function can suspend and resume; the runtime tries to produce a concrete value at each step and only suspends when it cannot continue. A stackless evaluator (frames on the heap, not the C stack) makes that model practical for tail calls, continuations, generators, and async work in one pipeline.

Three consequences:

  • Every Koskari function is capable of suspension.

  • Most code reads synchronously – explicit await is uncommon.

  • Suspension propagates through the evaluator rather than through coroutine objects at every call site.

Shorthand:

Python often defaults to returning awaitables; Koskari defaults to returning values when it can.

Compared with Python

async def fetch_user():
    return await db.get_user()

result = fetch_user()
print(result)  # <coroutine object ...>

The caller must await (or schedule) the coroutine. Async tends to spread through callers – sometimes called async contagion.

In Koskari:

def fetch-user []:
  (db.get-user)

var user (fetch-user)
(print user)

If db.get-user finishes immediately, user is a normal value and print runs. If it must wait on Python, the evaluator suspends the current context and resumes when the result is ready – without requiring async on fetch-user or an explicit await at the call site.

Conceptually, (fetch-user) at a use site behaves like Python’s await fetch_user() when suspension is needed, except the await is implicit and managed by the runtime.

Behaviour

Python

Koskari

Functions async by default

No

Yes (capable of suspend)

Explicit async declaration

Required for async defs

Not required

Explicit await at most call sites

Usually yes

Usually no

Call returns coroutine object by default

Yes (for async def)

No – value or suspend

Runtime tries immediate value resolution

No

Yes

Suspension handled by evaluator

No (caller awaits)

Yes

Intentionally hold work for later

Tasks, coroutines

defer

Python dunder / slot may suspend

No

No – see demand

Demand (sync-forbidden) execution path

N/A

Yes

Auto-await at call boundaries

Coroutines, async generators, and async context managers (with / dyn-with) integrate with the same evaluator. Ordinary calls into Python that return awaitables are typically driven to completion at the boundary, so the stack holds the result rather than a bare coroutine. See compile/await and the VM opcode reference for opcode-level detail.

Two kinds of values are excluded and arrive as plain values: a defer handle, and any asyncio future (anything passing asyncio.isfuture, including Task). Futures are handles to work owned by the event loop; hold them, cancel them, or resolve them with an explicit (await fut) when the value is wanted.

Suspension in sequential code

def fetch-user [id]:
  (db.fetch-user id)

var user (fetch-user id)
(print user)

If db.fetch-user cannot complete immediately, the evaluator suspends, other work may run on the host loop, the result arrives, execution resumes, and print runs. The source stays sequential even though suspension occurred.

defer

Sometimes a program must not resolve a value immediately. A defer is a single-shot await handle (Python builtin defer, also exposed in bootstrap). The wrapped call runs when the defer is constructed; the VM does not auto-await the defer value at the next use site.

var task (defer fetch-user)
;; ... pass task around ...
(await task)

That mirrors holding a coroutine in Python, and like a coroutine the handle is consumed by its first await – awaiting it again raises RuntimeError. When you need a handle that can be awaited more than once, cancelled, or given callbacks, schedule it as a Task with create-task. Defer is explicit – immediate resolution remains the default for ordinary calls.

Use explicit (await expr) when expr is a defer or any other awaitable you must suspend on at that program point. See koskari.lib for the C/Python type surface.

Bare (await) with no argument performs a cooperative suspend on the async-hosted path; on the demand path it triggers KoskariSyncViolation (see on-demand? below).

Demand evaluation

Demand evaluation runs Koskari to completion without hosting an asyncio loop for that run. Each top-level form is driven synchronously from the host (for example demand_call on a compiled expression).

Typical uses:

  • Default module import path (exec_module_demand on the meta path finder)

  • Loading bootstrap without an event loop (demand builtins path via koskari.bootstrap)

  • load_module_demand – same read/compile loop as async load, but each expression is executed on the demand path

On the demand path, suspension that requires waiting on a Python awaitable raises KoskariSyncViolation. Koskari code meant to run under demand must not rely on await at points where the host cannot resume.

Why demand exists

Many Python integration points must not suspend: numeric operators, attribute access, sequence protocols, iterator hooks, and most CPython slot implementations expect a synchronous result or an exception – not a suspended computation.

x + y  # cannot yield to an event loop mid-operation

When Python invokes a Koskari dunder (for example __add__), the runtime routes that work through demand execution: produce a value now, or raise; never suspend. That preserves predictable behaviour for obj + other and similar calls while still allowing suspension inside ordinary Koskari-to-Koskari evaluation.

Demand establishes a boundary:

  • Inside Koskari evaluation (async-hosted), suspension is allowed.

  • At synchronous Python-facing boundaries, suspension is forbidden.

Async-hosted evaluation

Async-hosted evaluation loads or runs code under an asyncio-driven host: each compiled top-level expression is awaited. The CLI and load_module_async use this path when an event loop is available.

Same bytecode and namespaces as demand; different host and allowed suspensions.

Aspect

Demand path

Async-hosted path

Host loop

None required for the Koskari run

asyncio (or compatible)

Top-level module run

load_module_demand

load_module_async

await in Koskari

Forbidden where sync completion is required

Allowed when the awaitable can run

Python dunder / slot entry

Demand only

Demand only

Importer details: koskari.importer, koskari.module.

Authoring for both paths

  • @demand above a def – binds the function wrapped with demand, so a Python caller with no event loop gets its value (compile/decorate)

  • on-demand? – runtime predicate: tries bare (await) and returns true if that would violate sync completion (demand path) (on-demand?)

Keep modules that must import without a loop on the demand path; use async-hosted load when the embedding app already runs asyncio.

Choosing a construct

Koskari resolves ordinary calls to values automatically, so most code needs none of these. Reach for one only when you want to override that default – run without a loop, hold a result instead of resolving it, or hand Koskari async work to a raw asyncio API.

Decision table

Your situation

Use

Kind

The code must run to completion now, with no event loop – a module imported without a host, or a body reached through a Python dunder

demand, or @demand above def

function wrapper

You want to hold an async result and await it later, not resolve it at the next use site

defer

builtin type

You must hand a Koskari function to a Python asyncio API that requires a coroutine function (a task factory, framework hook)

coroutine-fn, or @coroutine-fn above def

builtin

You need to schedule or gather held async work on the asyncio loop

create-task, or defer + asyncio.gather

asyncio bridge

The first two shape how your own code runs; the last two are bridges to raw asyncio. If you are not crossing into an asyncio API by hand, you almost certainly want demand/defer, not the coroutine bridges.

Auto-await caveat. Koskari auto-awaits an awaitable at the call boundary, so calling a coroutine function from Koskari resolves it to a value. To hand a real coroutine to asyncio, pass the function (coroutine-fn) or a defer, never a call result.

demand – run without a loop

Runs on the demand path: produce a value now, or raise – never suspend. A body that tries to await a Python awaitable here raises KoskariSyncViolation.

@demand
def checksum [a b]:
  (+ a b)

(checksum 2 3)   ; -> 5, computed with no event loop hosting the run

The decorator lines read as (decorate [demand] (def checksum [a b] ...)), which binds checksum to (demand (function checksum [a b] ...)). See compile/decorate.

defer – hold work instead of resolving it

A defer holds an async result instead of resolving it at the next use site, and an explicit (await ...) consumes it. defer above has the rules.

coroutine-fn – a genuine coroutine function

Some asyncio entry points require a real coroutine function – one that asyncio.iscoroutinefunction accepts. coroutine-fn wraps a function so it qualifies; you hand the function to the API, and when Python calls it, it gets a coroutine.

(import asyncio)
(import-from asyncio sleep)

@coroutine-fn
def work [x]:
  (await (sleep 0.1))
  x

(asyncio.iscoroutinefunction work)   ; -> True

Pass work itself to the asyncio API. Do not call (work 1) from Koskari first – that auto-awaits to the value (see the caveat above). @coroutine-fn above def work binds work to (coroutine-fn (function work [x] ...)). See compile/decorate.

Scheduling and gathering – create-task, defer + gather

To run held async work on the loop, schedule a defer (or coroutine) with create-task. It returns the real asyncio.Task – a handle. Tasks (like all asyncio futures) are excluded from implicit auto-await, so the handle can be stored, cancelled, or given done-callbacks, and is resolved by explicit await:

var t (create-task (defer fetch-user))
;; ... other work runs meanwhile ...
;; (t.cancel) would stop it; (await t) resolves it
(await t)

Keep a reference to the task while it runs – the event loop holds only a weak one. Passing a defer to create-task consumes it (a defer is single-shot); the Task owns the work from then on.

For concurrency, hold each call as a defer and await them together – defers are awaitable, so asyncio.gather accepts them directly:

(import asyncio)
var a (defer (function fetch [] (fetch-user 1)))
var b (defer (function fetch [] (fetch-user 2)))
(await (asyncio.gather a b))          ; both run concurrently

A defer satisfies asyncio.iscoroutine (it carries the full coroutine protocol), so the strict surfaces – asyncio.create_task, asyncio.run, run_coroutine_threadsafe – accept it directly. The lower-level as_coroutine helper (in koskari.asyncio, reached by import) remains for the rare non-coroutine awaitable that needs to satisfy those checks.

See also