Suspension#

A suspension-aware function waits without changing its call syntax or return type. The same call blocks in a command-line program and parks its coroutine where a host installed a suspension handler:

local process = nupp.io.process

local child = new process.Process({args = {"cc", "--version"}} as process.Options)
local result = assert(child:communicate())
print(result.output)
child:close()

Waits block or park#

communicate follows one of three paths:

  • A ready child returns without polling, parking, or switching coroutines.
  • A pending child with no handler makes the current thread drive registered readiness sources until the child finishes.
  • A pending child under a handler makes the handler park this coroutine while the host runs other work.

The library describes the wait. The handler owns scheduling policy, and the caller's result remains process.Result on every path. See Child processes for the operation itself.

Hosts supply scheduling policy#

A command-line program needs no handler. A host supplies one when blocking the thread would stop unrelated work:

  • A game engine parks a loading coroutine and renders the next frame.
  • A server parks one request and serves other connections.
  • A UI runtime parks a task and continues processing input.
  • A test scheduler controls exactly when a suspended operation resumes.

A root task installs the host's handler, then ordinary functions beneath it can park without accepting a scheduler parameter:

main.nupp
local frame = require("scheduler")
local process = nupp.io.process

local function printCompilerVersion(): nil
    local child = new process.Process({args = {"cc", "--version"}} as process.Options)
    print(assert(child:communicate()).output)
    child:close()
end

local function application(): nil
    handle suspension with frame.handler do
        printCompilerVersion()
    end
end

frame.run(application)

frame.handler is an ordinary value. It is not a keyword, a global scheduler, or a handler built into Nupp. The application function defines its dynamic scope. Most application code only consumes a handler this way. Framework authors and scheduler integrations implement one, as Writing a frame handler shows.

Dive deeper

Suspension is one effect with handlers rather than general algebraic effects, which would be a much larger language than anything here needs. One effect buys the property a host wants, which is that a wait deep inside a library reaches the handler installed around the task, and it costs one construct in the grammar and one fact in the checker rather than an effect system every signature has to carry.

See NEP 5 for more information.

Waits park one coroutine#

When child:communicate() cannot finish immediately, control makes a round trip:

  1. The process library registers its readiness source and cancellation function.
  2. The suspension runtime calls frame.handler.park with the pending wait.
  3. The handler records the current coroutine and yields it to the event loop, which runs another coroutine, request, or frame.
  4. suspension.poll() discovers that the child has completed, the library resumes the wait, and the handler queues its coroutine again.
  5. The coroutine runs, and communicate() returns its process.Result.

The handler decides when the coroutine runs again. It never supplies the result; the library's guarded resume function does that.

Function signatures stay synchronous#

Waiting does not introduce async function, await, or a future return type. An ordinary wrapper returns the value produced after the wait:

local process = nupp.io.process

local function compilerVersion(): string
    local child = new process.Process({args = {"cc", "--version"}} as process.Options)
    local result = assert(child:communicate())
    child:close()
    return result.output
end

local function printVersion(): nil
    print(compilerVersion())
end

printVersion()

The compiler infers that compilerVersion and printVersion may suspend, and that fact travels separately from their parameter and result types.

Suspension propagates through calls#

A direct coroutine.yield is what marks a function as suspending. The fact propagates through resolved calls and across module boundaries, so a caller that needs uninterrupted control gets it without annotating every function on the path.

Non-suspending regions#

nosuspend do requires every call inside the region to prove that it cannot suspend:

local function commit(write: nosuspend function(): nil): nil
    nosuspend do
        write()
    end
end

print(commit)

The region is lexical, static, and erased. It adds no runtime lock. An unresolved call is refused too, because the checker cannot prove the guarantee for a callee it cannot follow.

This call path reaches coroutine.yield, so the region is reported, and the report names the path from the call to the suspension:

pause.nupp
local function pause(): nil
    coroutine.yield()
end

nosuspend do
    pause()
end
nupp check pause.nupp
error: NUPP2701: `pause` may suspend, and this region forbids suspending

A cleanup running at a scope boundary is held to the same rule, because an obligation is being discharged there and the discharge cannot be left half done.

Function types carry the guarantee#

nosuspend function(...) describes a callback or host declaration whose body is not visible:

local type Reporter = nosuspend function(message: string): nil

local function publish(report: Reporter): nil
    nosuspend do
        report("committed")
    end
end

print(publish)

A non-suspending function fits an ordinary function slot. An ordinary function does not fit a nosuspend slot. The qualifier survives aliases, generics, imports, and exports.

The guarantee covers suspension only. The function may allocate, mutate, perform external I/O, or raise.

Effect contracts publish the complete boundary#

An effect contract includes suspension in its yields member:

transport.d.nupp
@effects(yields = true, raises = true)
const receive: function(): string

Use @effects where an API needs a reviewed complete effect summary. @effects() says that every modeled effect is absent, so it promises much more than a nosuspend function type does.

Handler scope follows the coroutine#

A handler is dynamically scoped per coroutine, not process-wide. A host often wraps its root application task, which makes that handler application-wide in practice:

local frame = require("scheduler")
local suspension = nupp.suspension

local function childWork(): nil
    assert(suspension.handled())
end

local function application(): nil
    handle suspension with frame.handler do
        local task = suspension.create(childWork)
        local ok, problem = coroutine.resume(task)
        if not ok then
            error(problem)
        end
    end
end

frame.run(application)

suspension.create creates an ordinary coroutine and makes it inherit the handler installed where it was created. Inheritance is fixed at creation. Continue to use coroutine.resume; no resume wrapper is required. A coroutine made with coroutine.create inherits no handler.

A nested handle suspension temporarily replaces the current handler and restores the outer one when its region ends, so different coroutines may use different handlers at the same time.

Raw coroutine yields keep explicit control#

coroutine.yield yields directly to the code that resumes the coroutine. It does not register cancellation or give a handler responsibility for the suspended stack.

Dive deeper

The two forms are judged differently where an affine obligation is live. A raw yield with an obligation outstanding is rejected, because nobody is responsible for the abandoned continuation. A handled suspension with one outstanding is allowed, because responsibility transfers to a handler that owns the continuation and its cancellation until the park returns or unwinds.

The yield is recognized by what it is rather than how it is spelled: through the library table, through a local bound to that table, or through a name bound to the function itself, as local pause = coroutine.yield is. A helper this file can see is judged by its body, so a call that reaches a raw yield through one or several visible functions is refused at the call while the obligation is live. A callee reached through a type is answered by that type, as nosuspend is. A yield written inside unsafe do is the author's to answer for -- the shape a driver takes when it forwards a nested coroutine's park to whoever resumes it -- and is not counted.

A borrows or exclusive parameter counts as an outstanding obligation for this purpose even though the owner is the caller's. The check sees one frame, and the caller's owner would be stranded through a callee that raw-yields while holding the view; the callee cannot know whether its caller may. An observer that parks while holding a borrowed event therefore does so through a handled suspension, which every waiting library call already is.

That permission rests on a trusted handler contract rather than on a proof. The checker cannot prove anything about an arbitrary scheduler's cancellation behavior, and the invariant being trusted is not that a wait completes, which it may legitimately never do, but that the continuation is never abandoned without being woken far enough to run its cleanup.

See NEP 5 for more information.

Running several waits together#

Waiting on more than one thing at a time is a task scope, which owns the coroutines it starts and settles them before it returns. This module supplies the effect those coroutines wait through, and nothing that starts one.

Libraries register readiness#

A suspension-aware library calls suspension.suspend with a subscription. This example completes after its readiness source has been polled twice:

local suspension = nupp.suspension

local function after(polls: integer, value: string): string
    return suspension.suspend("counter", function(resume: function(string), context: suspension.Context): function()
        local left = polls
        context:source("counter", 10, function(): integer
            left = left - 1
            if left > 0 then
                return 0
            end
            resume(value)

            return 1
        end)

        return function(): nil
            left = 0
        end
    end)
end

print(after(2, "ready"))

The protocol has three rules:

  1. resume(value) supplies the result exactly once.
  2. A subscription that does not resume during the call returns a cancellation function, so every real park can be abandoned.
  3. A source registered through the context belongs to that wait. The runtime drops it when the wait returns, raises, or is cancelled.

A poll function returns how many operations it settled, and zero means that nothing completed during that pass. Lower priorities run first, with names breaking ties. A wait with no handler and no source reports that it cannot make progress instead of hanging.

Writing a frame handler#

This scheduler keeps a queue of runnable coroutines. Its event loop calls tick once per frame to poll readiness sources and resume the tasks they woke.

scheduler.nupp
local suspension = nupp.suspension

local runnable: {thread} = {}

local function enqueue(task: thread): nil
    runnable[#runnable + 1] = task
end

local function runReady(): nil
    local pass = runnable
    runnable = {}
    for _, task in ipairs(pass) do
        if coroutine.status(task) == "suspended" then
            local ok, problem = coroutine.resume(task)
            if not ok then
                error(problem)
            end
        end
    end
end

A waker may run before the park that registered it has yielded, when the wait completes during the registration itself, so a task can be queued and have moved on by the time its turn comes; the status check is what makes that harmless.

The handler itself is three members. park registers a waker that enqueues the current coroutine, then yields until the wait is ready. canPark returns false inside a host barrier where yielding would violate a runtime invariant. shutdown drains work queued while the handled extent is ending.

scheduler.nupp
local scheduler = {park = function(_: suspension.Handler, waiting: suspension.Waiting, _: function(): nil): nil
    local task = assert(coroutine.running())
    local function wake(): nil
        enqueue(task)
    end

    while not waiting:ready() do
        waiting:onResume(wake)
        if not waiting:ready() then
            coroutine.yield()
        end
    end
end, canPark = function(_: suspension.Handler): boolean
    return true
end, shutdown = function(_: suspension.Handler): nil
    while #runnable > 0 do
        runReady()
    end
end,} as suspension.Handler

waiting:onResume(wake) is a notification, not value delivery. The readiness source supplies the value through resume, and the waker makes the coroutine runnable after that value exists. The as suspension.Handler cast accepts a trusted runtime contract: the checker verifies the function bodies and their annotations, and only the scheduler author can guarantee that park eventually resumes or cancels every wait.

scheduler.nupp
local function tick(): nil
    suspension.poll()
    runReady()
end

local function run(body: function(): nil): nil
    local task = coroutine.create(body)
    local ok, problem = coroutine.resume(task)
    if not ok then
        error(problem)
    end
    while coroutine.status(task) ~= "dead" do
        tick()
    end
end

return {handler = scheduler, tick = tick, run = run}

run creates the root task used in the opening application, and its first resume reaches park and yields. Each tick polls completion sources and resumes tasks placed on runnable. A game host calls the same tick function once per frame instead of using this standalone loop.

Cancellation unwinds the parked stack#

handle suspension lowers to an owned handler installation. When its extent ends, the runtime restores the previous handler, cancels outstanding subscriptions, wakes their coroutines, and invokes shutdown. A cancelled suspend raises inside its parked coroutine, so lexical resource drops run as the stack unwinds.

Structured exits leave the region only after its installation has been released. return preserves all values, break and continue reach the loop that owns them, and goto may reach a label outside:

local frame = require("scheduler")

local function choose(): integer
    handle suspension with frame.handler do
        return 1
    end
end

print(choose())

The lowering uses the same completion protocol as automatic resource cleanup, which preserves a body failure as the primary error when releasing the handler fails too, while still reporting the release failure.

Control cannot jump into a handled region, because such a jump would bypass handler installation and the lexical state before the label:

wrong.nupp
local frame = require("scheduler")

goto inside
handle suspension with frame.handler do
    ::inside::
end
nupp check wrong.nupp
error: NUPP2706: control cannot enter a `handle suspension` region

C-call boundaries#

LuaJIT cannot yield through every C frame. A comparator called by table.sort, a replacement called by string.gsub, and an FFI callback are non-yieldable positions. The checker follows those callback bodies and reports a call inside one that reaches a suspension:

compare.nupp
local function pause(): nil
    coroutine.yield()
end

table.sort({2, 1}, function(a: integer, b: integer): boolean
    pause()

    return a < b
end)
nupp check compare.nupp
error: NUPP2702: `pause` may suspend, and `table.sort` cannot yield across the C call that reaches it

The same function is free to suspend anywhere else; the boundary belongs to the invocation. Where an unknown C API hides the boundary from static analysis, the runtime names the operation instead.

FAQ#

Does a suspending call return a future?#

No. A suspension-aware call returns its declared result after the wait, so callers do not unwrap a future or acquire a second function type. The compiler tracks the possibility of suspension separately through call propagation.

Should a callback be a nosuspend type or an @effects contract?#

Use nosuspend function(...) when suspension is the only thing that matters, which is the common case for a callback invoked inside a region or across a C boundary. Use @effects when the API owes a reviewed summary of allocation, raising, and yielding together.

Does cancellation run affine cleanup?#

Yes. A handler cancels a parked operation by unwinding its coroutine stack, and that unwind performs automatic lexical destruction, so affine files, locks, and native allocations do not become stranded.