Synoema

Async/Await in Synoema

Stackless concurrency — from a single timer to a TCP echo server

Phases D, E, G, H1, H2, H3 deliver a complete async stack: stackless state machine compilation in JIT, a three-component IO reactor (timer wheel + file-IO pool + mio TCP), race/gather task combinators, an async TCP server API, scope_result/try_await error propagation, and cancellation tokens. All backed by 58+ tests.

Start with basics IO Reactor

1. Async/Await basics

An async fn returns a Task a — an asynchronous computation that will eventually produce a value of type a. Inside an async fn, await suspends execution until the inner task completes and unwraps its result.

-- async fn: declares a function that returns Task a
async fn fetch_hostname =
  content = await (async_read "/etc/hostname")
  content ++ " (fetched)"

-- await unwraps Task a → a inside async fn
async fn pipeline src dst =
  raw     = await (async_read src)
  n       = await (async_write dst (upper raw))
  n

-- main uses await at the top level
async fn main =
  result = await (pipeline "/tmp/input.txt" "/tmp/output.txt")
  print "Wrote ${show result} bytes"

Type Task a

Task a is an asynchronous task that eventually returns a value of type a. It is distinct from Chan a: a task models a single result, a channel models a stream.

StateMeaning
Task:PendingTask is still running in the IO pool or reactor
Task:Done vTask completed; result is v : a

Stackless state machine (JIT)

In JIT mode (sno jit), an async fn with 1–4 await points is compiled into a stackless state machine: a polling function __poll_name(task, frame) with states S0..SN dispatched over a br_table. Each state is the code between two suspension points.

-- JIT compilation of a 2-await async fn:
S0 → launch async_read  → save frame → return Pending
S1 → receive content    → launch async_write → save frame → return Pending
S2 → receive n          → return Done(n)

An async fn with 5 or more await points automatically falls back to a blocking stub that inlines the body and parks the calling OS thread — correct, but without the state-machine efficiency.

2. IO Reactor

All async primitives dispatch through a three-component reactor backend. The reactor maintains a waker map that reschedules parent tasks when a child completes.

┌─────────────────────────────────────────────────────────────┐
│                    Reactor (G1)                              │
│  Mutex<HashMap<task_id, PendingTask>>  — waker map           │
│  Reschedules parent on wake_parent(child_task)               │
└───────────────┬──────────────────┬──────────────────────────┘
                │                  │
       ┌────────▼──────┐  ┌────────▼────────┐  ┌─────────────┐
       │  Timer Wheel  │  │  File-IO Pool   │  │  mio TCP    │
       │  (G2)         │  │  (G3)           │  │  Loop (G4)  │
       │  1 OS thread  │  │  4 workers      │  │  1 OS thread│
       │  BinaryHeap   │  │  VecDeque-256   │  │  mio::Poll  │
       │  + Condvar    │  │  + Condvar      │  │  + Waker    │
       └───────────────┘  └─────────────────┘  └─────────────┘
  • Timer Wheel (G2) — a single background OS thread handles all async_sleep calls. 1000 concurrent 5 ms timers fire in approximately 5 ms.
  • File-IO Pool (G3) — bounded queue of 256 tasks. SNO_FILE_IO_THREADS controls worker count (default 4, range 1–16). When the queue is full new tasks block until a slot is free.
  • mio TCP Loop (G4) — non-blocking TCP via mio::Poll; READABLE/WRITABLE events wake the reactor. Requires the event-loop feature (default on).

Async builtins

BuiltinTypeDescription
async_sleep msInt -> Task IntAsync delay; returns 0 when done
async_read pathString -> Task StringAsync file read
async_write path dataString -> String -> Task IntAsync file write; returns bytes written
async_tcp_connect host portString -> Int -> Task IntTCP connect; returns fd
async_tcp_read fdInt -> Task StringRead from TCP fd
async_tcp_write fd dataInt -> String -> Task IntWrite to TCP fd; returns bytes written
async_tcp_close fdInt -> Task IntClose TCP fd; returns 0
async_tcp_listen portInt -> Task IntBind TCP server; returns listener fd
async_tcp_accept listenerInt -> Task IntAccept connection; returns client fd

3. Task Combinators

Two combinators run tasks in parallel and collect results.

CombinatorTypeSemantics
race tasks[Task a] -> Task aRun all tasks in parallel; return the first to finish (others are dropped)
gather tasks[Task a] -> Task [a]Run all tasks in parallel; return a list of results in original order
-- async_race.sno: race and gather in action

async fn fast_task = 1
async fn slow_task = _ = await (async_sleep 200); 2

async fn main =
  -- race: first to complete wins; slow_task result is discarded
  winner = await (race [slow_task fast_task])

  -- gather: both finish, results in order
  both = await (gather [fast_task fast_task])

  winner   -- → 1

race uses an AtomicBool winner flag so only the first completion is propagated. gather uses an AtomicUsize completion counter and collects results in original index order. Each sub-task gets its own OS thread.

4. Async TCP Server

The async TCP server API uses the same fd handle pattern as the synchronous tls_* builtins. async_tcp_listen binds a port and returns a listener fd; async_tcp_accept waits for an incoming connection and returns a client fd.

-- async_tcp_server.sno — async echo server
-- Run:  sno jit examples/async_tcp_server.sno
-- Test: echo "hello" | nc 127.0.0.1 9877

async fn handle_client client data =
  await (async_tcp_write client ("echo: " ++ data))

async fn server_once listener =
  client = await (async_tcp_accept listener)
  data   = await (async_tcp_read client)
  await (handle_client client data)

async fn main =
  listener = await (async_tcp_listen 9877)
  await (server_once listener)

Client fds returned by async_tcp_accept are compatible with async_tcp_read, async_tcp_write, and async_tcp_close. The mio::Poll background loop delivers READABLE and WRITABLE events to wake the reactor without blocking the calling thread.

Pass 0 as the port to let the OS choose a free port (useful in tests).

5. Error handling

Two builtins convert runtime errors inside async code into typed values rather than panics.

BuiltinTypeSemantics
scope_result thunk(Unit -> a) -> Result a ErrorRun thunk (); if the body calls error, return Err e instead of aborting
try_await taskTask a -> Result a ErrorAwait a task; if the task panicked, return Err e
-- scope_result: wrap a block that may fail
async fn safe_read path =
  result = scope_result (\_ -> await (async_read path))
  ? is_ok result -> unwrap_ok result
  : "read failed: " ++ show result

-- try_await: await a task that might have panicked
async fn run_with_fallback task fallback =
  result = try_await task
  ? is_ok result -> unwrap_ok result : fallback

Internally scope_result/try_await use a thread-local soft-error channel (SCOPE_RESULT_ERROR / TASK_ERROR). When synoema_error is called inside a scope, it stores the message in TLS instead of panicking, then returns a sentinel 0. The enclosing scope_result or G1 reactor intercepts the sentinel and constructs an Err value. This means synoema_error never unwinds through Cranelift-compiled frames.

6. Cancellation

Cancel tokens let you stop a long-running task from outside.

BuiltinTypeDescription
new_cancel_token ()Unit -> CancelTokenCreate a new cancel token (starts uncancelled)
cancel tokenCancelToken -> UnitMark the token as cancelled
is_cancelled tokenCancelToken -> BoolCheck whether the token has been cancelled
with_cancel token taskCancelToken -> Task a -> Maybe aRun task; if token is cancelled before completion, return Nothing
await_with_timeout ms taskInt -> Task a -> Maybe aAwait task for up to ms milliseconds; return Nothing on timeout
-- Create a token, pass it to a long-running task, cancel externally
async fn guarded_fetch path token =
  result = with_cancel token (async_read path)
  ? result == Nothing -> "cancelled"
  : "got: " ++ (from_just result)

-- Timeout pattern: give up after 500 ms
async fn fetch_with_timeout path =
  result = await_with_timeout 500 (async_read path)
  ? result == Nothing -> "timed out"
  : from_just result

async fn main =
  token = new_cancel_token ()
  cancel token        -- cancel before the task even starts
  r = await (guarded_fetch "/etc/hostname" token)
  print r             -- → "cancelled"

7. When to use what

SituationRecommendation
Parallel file or network I/Oasync fn / await
First result wins, rest discardedrace [task1 task2 ...]
All results needed in ordergather [task1 task2 ...]
CPU-bound parallelism (list transform)pmap f xs
Independent long-running background workscope { spawn ... }
Producer/consumer with backpressurebounded_chan n
Fan-in from multiple channelsselect [chan1 chan2 ...]
Async error propagationscope_result / try_await
Deadline or timeoutawait_with_timeout ms task
External cancellationnew_cancel_token / with_cancel

8. Limitations (honest)

The async system ships and works. These are the current known constraints:

  • JIT state machine covers 1–4 await points. An async fn with 5 or more await points compiles correctly but uses a blocking stub (D3b). The 4-await stackless path covers the vast majority of real use cases.
  • File-IO pool defaults to 4 workers. Set SNO_FILE_IO_THREADS=1..16 to adjust. The queue depth is 256; when full, new tasks block.
  • await outside async fn is a type error. You get an AWAIT_OUTSIDE_ASYNC diagnostic. Wrap the call in an async fn.
  • sno run evaluates async primitives eagerly. The interpreter mode is correct but does not use the reactor; use sno jit for the full async stack and performance.
  • Async TCP requires the event-loop feature (on by default). Building with --no-default-features disables async_tcp_*. Use the synchronous tcp_connect / tls_connect builtins instead.
  • No epoll integration for file descriptors. File I/O goes through the thread pool (G3), not the mio event loop. Deferred.