Browse docs

Start here

examplesGetting started with Flowdocumentation

Design

FoundationsLanguage architecturePhilosophy

Language specification

Program checkingConcurrencyDataEffectsResults, Tool problems, and faultsGrammarHistoryModules and importsLanguage specificationEvaluationStandard libraryToolsTypes

Runtime

Runtime architectureThe host boundaryDiagnosticsRunning a programThe history format

Guides

Writing programs that reach checkpointsImplementing Tools with a toolkitLoops that never returnRecursive delegationSharing types between Tool modulesHarnesses over tool registries

Loops that never return

This guide is non-normative. It shows practice; it creates no semantics and states no guarantee. Concurrency owns task ownership, stopping and faults in tasks, history owns checkpoints, and execution owns pausing, resuming and halting a run.

The shape

A program that watches, serves or converses for as long as something outside it lasts is a tail-recursive loop. Each round waits on one or more Tool calls, reacts, and tail-calls the next round. It returns only when the outside says so: a consumer leaves, a conversation ends, a person types "stop". Halting it from outside is the host's act, not a branch in the program (execution).

The loop shape that reaches checkpoints is described in checkpoints.md. This guide covers what is different when the loop is meant never to finish.

Keeping a pending receive alive across rounds

A tail call continues the same call, so tasks spawned in one round are still owned, and still running, in the next (concurrency). Use that to keep one receive outstanding while other work races against it, and pass the receive along as an argument:

pub flow main(target: feed.Target) -> Ending !tool =
    watch(target, task.spawn(flow() = Gone(consumer.gone())), listen(target))

flow watch(target: feed.Target, gone: task.Task<Event>, pushed: task.Task<Event>) -> Ending !tool = {
    let tick = task.spawn(flow() = Ticked(clock.sleep(poll_interval)))
    match task.first([gone, pushed, tick]) {
        Gone(_) => ConsumerGone,
        Pushed(Ok(item)) => {
            let _ = task.stop(tick)
            deliver(target, item, gone, listen(target))
        },
        Pushed(Err(problem)) => {
            let _ = task.stop(tick)
            FeedLost(problem)
        },
        Ticked(_) => watch(target, gone, pushed),
    }
}

flow listen(target: feed.Target) -> task.Task<Event> !tool =
    task.spawn(flow() = Pushed(feed.next(target)))
Open in playground →

gone stays outstanding for the whole run, and pushed until it answers. Both are parked on their only call, so every tail call above can be a checkpoint. No message is lost: the receive that did not win a race is passed on, never stopped.

A stream is this pattern, not a mechanism. Each item is one ordinary call such as feed.next(target), recorded like any other (concurrency). There is no queue, no named recipient and no way for one task to send another anything; a message is protocol data a Tool returns.

Stopping finished races by hand

The owning call of a never-returning loop never returns, so nothing stops its tasks for it. In a function that returns, the losers of a race are stopped when the call ends. In a loop that never returns, every loser that is not passed on keeps running for the life of the run.

In watch above, each branch that does not tail-call with tick stops it first. Forgetting that costs more than a leaked timer:

  • every forgotten task stays live, and a live task that is not parked in the arguments prevents every later checkpoint;
  • forgotten tasks accumulate one per round;
  • a forgotten task can still fault, and its fault ends the run (below).

The rule of thumb: at each tail call, every task spawned in this round is either passed on in the arguments, already finished, or stopped. task.race does this for you when no loser needs to survive (concurrency).

Waiting on a task you stopped faults, so a task stopped in one round must not be passed into the next round's task.first.

Daemons

A task may run its own loop for as long as its owner lives, for example a heartbeat that reports status every minute:

flow heartbeat(n: Int) -> Unit !tool = {
    let _ = clock.sleep(duration.millis(60_000))
    let _ = ui.status("alive, round ${n}")
    heartbeat(n + 1)
}

flow serve(state: State) -> Ending !tool = {
    task.detach(task.spawn(flow() = heartbeat(1)))
    rounds(state)
}
Open in playground →

task.detach says the heartbeat is not waited for: it belongs to serve's call and runs until that call returns. let _ cannot drop a task, so a task that should outlive its statement is detached on purpose, never by accident. It also prevents every checkpoint for as long as it runs, because it is a live task in the middle of its own work, not one parked on its only call (history).

Where checkpoints matter, fold the daemon's work into the main loop instead: keep its next Tool call parked as an argument, the way watch keeps tick, and do its work in the round that finds it finished.

Faults reach the owner at once

A fault in a task that nobody is awaiting reaches its owning call immediately, and the owning call stops its other tasks and faults, even when it is a loop that never returns (concurrency). In a long-running program one careless background task can therefore end the whole run.

Faults come from only four places (errors), and each is easy to avoid in task work:

  • divide with int.checked_div where a divisor can be zero;
  • never wait on a task that was stopped;
  • never call task.first on a list that can be empty; match on it first;
  • keep values the loop accumulates bounded, for example by keeping only the last n turns of a transcript.

A Tool call that fails is not a fault. Its Err is a value the task returns, and the round that receives it decides what to do.

Reading a long-lived run

flow history shows a run's facts and, by re-running the program, intermediate values. In a never-returning loop, look for tasks that stay alive across many rounds: they are either receives kept alive on purpose or losers someone forgot to stop. Viewers are encouraged to point such tasks out.

To move a long-lived run onto a new program version, fork it from a checkpoint (checkpoints.md).