LEVIATHAN v0.1 · in development

Standard Library

Concurrency & Async

A plain-object Promise<T>, one privileged operation (await) with no async keyword and no function coloring, and thread-backed Worker<T>s that isolate by copying everything across the boundary. No hidden schedulers — every suspension point is spelled await.

Promise<T>

Promise<T> is a plain library object — there are no special language rules and no implicit wrapping. A function documented as returning Promise<T> must construct and return an actual Promise; the compiler never wraps a bare T for you. It behaves as a one-shot stream: it resolves at most once, and everything downstream observes that single value.

MemberMeaning
Promise()construct pending — no value yet
Promise(T value)construct already resolved with value
resolve(T value)settle a pending promise
isReady()has it settled?
get()read the settled value
then(cb)run cb synchronously, in the resolver's own context, once settled
Promise<int> pending = Promise();
pending.resolve(42);

Promise<int> ready = Promise(42);   // pre-resolved, no waiting needed

Promise<int> fetchValue() {
    Promise<int> p = Promise();
    p.resolve(compute());
    return p;                        // a Promise-returning function IS the async form
}

await is the one privileged operation on a promise — see await & tasks. There is no async keyword marking a function and no function coloring: any function may await, and any function may return a Promise<T>.

Async execution is planned

Promise<T> carries no scheduler of its own — something else drives it toward resolution: a Worker join, a timer callback, or a manual resolve() call. A general-purpose async execution engine underneath Promise itself is planned.

await & tasks

await expr is a suspension point. If expr is not a promise-shaped object, await yields it unchanged. If the promise is already settled, await proceeds without suspending at all — no scheduling point, deterministic and free. This is the C# completed-task rule, not JavaScript's always-defer rule.

Promise<int> p = Promise(7);
int v = await p;   // settles immediately — no suspension, no interleaving

Otherwise the current task suspends: other tasks on the same thread — including new work the event loop dispatches — run while it waits, and the task resumes after the promise settles, in completion order among that thread's runnable tasks. Resumption order is FIFO per thread; it is not the reverse-of-suspension (stack) order an earlier implementation implied, and programs must not depend on either beyond that.

A task is the unit of suspension: the program's top level is a task, and every callback the event loop dispatches — timer, watch, worker-join — runs as its own task. Tasks are pinned to their thread; await never moves work across threads (Channel<T>/Worker<T> remain the only crossings). Across any await, state on the current thread may be observed and mutated by other tasks that ran meanwhile — single-threaded interleaving is real even without data races, and await marks exactly the points where it can happen.

Because await on a settled promise never enters the scheduler, a loop of ready-awaits is compute, not yielding — fairness among other runnable tasks only starts at the first real suspension.

Failure at an await

A Worker that rejected rethrows its failure at the await (catchable). An await whose promise can no longer be settled — the thread's event loop has fully drained with the promise still pending — throws a catchable RuntimeException: await: event loop drained with promise unresolved.

An uncaught throw inside a loop-dispatched callback terminates the program through the standard uncaught path; it is never delivered to an unrelated await. Each callback is its own task with its own failure path:

Promise<int> p = Promise();                  // nothing ever resolves p
std::sysTimerStart(5, 0, (n) => { throw RuntimeException("boom"); });
try {
    int x = await p;
} catch (IException e) {
    console.writeln("caught: " + e.message);  // never receives the timer's "boom"
}

The timer callback's throw is program-uncaught (Uncaught RuntimeException: boom, exit 1) — it does not teleport into the unrelated catch above. await p separately drains with its own catchable RuntimeException once the loop is empty.

await inside hermetic comptime

await inside hermetic comptime is a compile error — comptime evaluation has no event loop to park on.

engines Oracle, IR, and LLVM give await/tasks full, byte-identical coverage (per-task stacks, FIFO resumption). emit-C++ has no async surface; the frozen ELF backend is not a target.

Workers & channels

Workers are Leviathan's concurrency execution layer. The model is pure isolation with copy-always boundaries: a worker captures its inputs by copy, runs, and returns a result; every value that crosses a thread boundary crosses by deep copy (flatten/rebuild), so a counted value lives on exactly one execution unit at a time. Immutable zero-copy sharing is a planned v2 optimization.

Worker<int> w = std::spawn(() => {
    return expensiveCompute();
});
int result = await w;   // the handle IS a Promise<int> — await is the join

Worker<T> std::spawn<T>(() => T body) starts a worker; the handle itself is a Promise<T> (Worker<T> : Promise<T>), so await w is the join — there is no second suspension surface. The body's captures are snapshotted at the spawn call, so mutating a captured original afterward is not observed by the worker.

Channel<T>

Channel<T> is a single-producer / single-consumer conduit between workers.

Channel<int> ch = new Channel(16, std::overflowBlock());

Worker<void> producer = std::spawn(() => {
    for (int i in 1..10) { ch.send(i); }
    ch.close();
});

Promise<int?> next = ch.receive();
int? item = await next;   // None once the channel is closed AND drained
new Channel(int capacity, int policy)
capacity always comes with a policy — grow is rejected. Policy is one of std::overflowBlock(), std::overflowDrop(), or std::overflowError().
send(T value)
copies the value in; send on a closed channel throws.
receive() -> Promise<T?>
resolves with the next item, or None once the channel is closed and drained — the receiver narrows with != None.
close()
a captured Channel handle is a portal: both units share the one endpoint.

std::cpuCount() reports online logical processors (≥ 1) — useful for HttpServer(port, workers: cpuCount())-style sizing; pairing it with sysTcpListen(port, reusePort: true) sets SO_REUSEPORT so N workers can each run a full accept loop on one port.

Engine coverage

engines The surface runs on all three active engines by construction, byte-for-byte identically (one shared flatten/rebuild walk). On the tree-walk oracle and IR interpreter a worker is a cooperative loop task. On the LLVM backend, a worker is a real OS thread: its own per-worker TLS heap/arena/event loop, real pthreads, an eventfd join, and reap-time cleanup — the result rebuilds back on the spawner's thread, so a Worker/Promise continuation only ever runs on the thread that owns it. Channel<T> on LLVM is a process-global lock-free SPSC ring plus two eventfds.

Threads are POSIX-only in v1

A --target *windows* build rejects any spawn/Channel at compile time (threads: unsupported on Windows (v1)); the frozen ELF backend rejects spawn/Channel/sysTcpListen/2 at compile time too.

Output-determinism discipline for worker programs: workers compute and return/send; only the spawning thread prints, at join points. Never print from a worker body — it would race stdout ordering under real threads.

Crossing a thread boundary

Every value that crosses from one worker to another crosses by deep copy. Not everything is flattenable — the copy walk either succeeds completely or fails with a loud, catchable error naming the offending type.

Crosses
May crossprimitives, char, None, ranges, struct values, strings, pure Array/Map of flattenable elements, and statically-shaped class objects (deep, with shared substructure and cycles preserved)
Rejecteda nested closure (only the spawn body itself may cross as a closure); an fd-/loop-bound carrier (TcpStream/TcpListener/Timer/Process, and a disposable InStream whose teardown reaches a live signalfd + loop watch); a Block

A plain in-memory InStream still crosses fine — each worker simply opens its own. Keep loop-bound carriers (open sockets, timers, processes) on their owning thread and pass a Channel<T> instead.

A Worker/Promise handle may never cross

A spawn body may not reference a Worker<T>/Promise<T> handle — whether captured through a local, a container, or a bare top-level global — and sending one through a Channel is likewise a loud, catchable error naming the type. A cross-thread resolve would run the promise's continuation on the wrong thread. Channel<T> is the one sanctioned cross-worker conduit; otherwise, await the Worker<T> handle spawn returned.

One v1 residual, uniform across every carrier: a carrier reached through a bare global rather than a captured local is not caught by this check — only a global Promise is re-scanned at the spawn call.

Cancellation & timeouts

Cancellation is an exception delivered at park points only — a running task is never preempted; the mark takes effect at its next await.

interface ICancelledException : IException { }
class CancelledException : Exception, ICancelledException { }

CancelledException is an ordinary catchable carrier — no second error channel. A task may catch it and refuse cancellation, using the same rethrow-point machinery as a rejected Worker or a drained event loop (above). An uncaught CancelledException in a group-owned task is absorbed at the TaskGroup boundary — cancellation is not a program error — while an uncaught throw of any other type stays program-uncaught, unchanged.

awaitTimeout

Timeout as an outcome, not a failure:

T? awaitTimeout<T>(Promise<T> work, int ms);

Parks on {work, a timer}; returns None on timeout (the T?/union rule — expected absence, no exception) or work's value if it settles first.

v1 does not cancel the work

awaitTimeout stops waiting; it does not cancel work — no Promise → producer-task map exists to reach back into it. A Worker (an OS thread) is never cancelled by timeout either way — threads cannot be wall-clock killed. Compose with the enclosing group's cancelAll() for a kill-switch shape:

int? r = awaitTimeout(p, 5000);
if (r == None) { g.cancelAll(); }

TaskGroup

Structured concurrency via the existing using/IDisposable rule — zero new syntax.

class TaskGroup : IDisposable {
    new TaskGroup();
    void run(() => void body);   // start a child task, owned by this group
    void cancelAll();            // mark every live child cancelled
    void close();                // cancelAll(), then join every child
}
using TaskGroup g = TaskGroup();
g.run(() => { doWork(1); });
g.run(() => { doWork(2); });
// close() runs on every exit edge: fall-off, return, throw, break

using TaskGroup g = TaskGroup(); runs close() on every exit edge of the block (fall-off, return, throw, break): stragglers are cancelled and every child is joined before the block's scope is left, so no task outlives its lexical scope.

g.run vs. spawn

g.run starts a same-thread task — cheap and cancellable. That's deliberately different from std::spawn's thread-backed Worker<T>, which is parallel and uncancellable. The two are never meant to blur.

Cancellation delivery is masked while a task runs its own close() unwind (the shield rule), so a using nested under an already-cancelled task cannot livelock. A child that catches CancelledException and refuses to park again leaves close()'s join waiting — loud, not hung (a [tasks] uncancellable=N report under LANG_TASK_STATS=1) — and the join still completes once the refuser eventually parks or returns.

engines Oracle, IR, and LLVM share full, byte-identical coverage of cancellation and TaskGroup (a task-struct cancel mark, a per-task shield-mask counter, a thread-local id→task registry). emit-C++ has no async surface; the frozen ELF backend is not a target.