SCUA

Manual

Concurrency and time

A message handler runs to completion and then stops. But games and servers need things that take time: a timer that fires in five seconds, an animation that plays over a frame, a task that polls in the background. SCUA handles this with background tasks, a clock you can fast-forward, and a few ways to wait. None of it uses threads or locks. It all rides on the same one-handler-at-a-time model that partitions already give you.

#Background tasks with spawn

spawn do ... end starts a task that runs alongside a partition's message handling. The handler that spawned it returns right away; the spawned task carries on in the background.

partition Greeter
  on Greet(name)
    print(`hi {name}, checking back soon`)
    spawn do
      wait(1s)
      print(`still here, {name}?`)
    end
  end
end

let g = Greeter()
tell g.Greet("Sam")

Run it with --fast so the wait completes instantly (more on that below):

$ scua --fast greeter.scua
hi Sam, checking back soon
still here, Sam?

The Greet handler prints its line, kicks off the background task, and returns. The task waits a second, then prints. While it's parked, the partition is free to handle other messages.

There's a standalone form too, spawn(fn() ... end), for top-level background work that isn't tied to a partition:

spawn(fn()
  let start = now()
  wait(2s)
  print(`slow timer fired at +{now() - start}ms`)
end)

spawn(fn()
  let start = now()
  wait(500ms)
  print(`fast timer fired at +{now() - start}ms`)
end)

print("two timers running")
$ scua --fast timers.scua
two timers running
fast timer fired at +500ms
slow timer fired at +2000ms

Both timers start together. The 500ms one fires first because its wake time comes sooner. The scheduler interleaves tasks by when they're due to wake, not by the order you spawned them.

#When a spawned task faults

A fault in a spawned task stops that task and nothing else. Your other tasks keep running, and so do your partitions and any server the program is running. The fault is reported on stderr when it happens:

spawn(fn()
  wait(10ms)
  let order = nil
  print(order.total)
end)

spawn(fn()
  wait(20ms)
  print("the other task carries on")
end)

print("main done")
$ scua --fast task_fault.scua
main done
scua: task_fault.scua:4: task fault (the task stopped; the program carries on): fields only exist on tables and records, not on nil values
the other task carries on

When the program ends, it exits with status 5, the same as for a fault nothing caught, so a script that runs it, or a supervisor, still sees that something went wrong. A fault in the top-level code itself, outside any task, still stops the program, as described in Errors and faults.

Nothing can wait for a spawned task's result yet, so there's nowhere else for its fault to go. If another part of the program needs to know a task failed, catch the fault inside the task and send it on:

partition Jobs
  on Failed(why) print(`a job failed: {why}`) end
end
let jobs = Jobs()

spawn(fn()
  try
    let order = nil
    print(order.total)
  rescue e
    tell jobs.Failed(e)
  end
end)
$ scua --fast report_failure.scua
a job failed: fields only exist on tables and records, not on nil values

A task spawned inside a partition's handler (spawn do ... end) belongs to that partition: its fault is reported as the partition's, and the partition keeps running. See When a handler faults.

#sys.fail, sys.emit and sys.exit in a task

In the top-level code these three end the program. In a spawned task they end the task, and everything else carries on, the same as in a partition's handler:

  • sys.fail(reason, code?) stops the task and reports the reason on stderr straight away. It also marks the run as failed: the program runs to its end, then writes {"error": reason} to stderr and exits with code (1 if you leave it out).
  • sys.emit(value) writes its JSON to stdout and stops the task.
  • sys.exit(code) stops the task. A non-zero code marks the run as failed, as sys.fail does.

If more than one task or handler fails the run, the first one's code is the exit status.

import sys

spawn(fn()
  print("downloading…")
  sys.fail("network is down")
end)
wait(10ms)
print("main finished")
$ scua --fast download.scua
downloading…
scua: download.scua:5: task stopped with Error(network is down); the program carries on
main finished
{"error":"network is down"}

The program exits 1. A task inside a partition behaves the same way, and see sys.exit, sys.emit and sys.fail in a handler for handlers.

#Durations

A duration is written as a number with a unit suffix: 200ms, 5s, 2min, 1h. You can do arithmetic on them; under the hood a duration is a count of milliseconds, so now() - start is a plain number of milliseconds.

wait(200ms)
wait(5s)
wait(2min)
wait(1h)

#wait, now, and dt

wait(d) parks the current task until d has elapsed, then resumes it. While parked, the task uses no CPU and the partition can do other work.

now() is the current time, in milliseconds. It's stamped once per turn and frozen for that turn, so two now() calls in the same handler return the same value. Measure elapsed time by taking a now() at the start and subtracting:

let start = now()
wait(2s)
print(`waited {now() - start}ms`)   -- waited 2000ms

dt() is the time since the task last ran, also in milliseconds. The first time a task runs it's 0; after a wait(2s) it's 2000. It's the convenient form for per-step updates, where you want "how much time passed since my last tick" without tracking a timestamp yourself.

#Two clock modes

The same program can run against two different clocks, and the flag you pass decides which.

By default, the clock is real. wait(5s) really blocks for five seconds of wall-clock time. This is what you want when a server is actually serving, or when a delay should feel like a delay.

now() is a little different from a stopwatch, on purpose. It is read once when a task's turn starts and stays put until the turn waits, and a wait(d) moves it forward by exactly d, so a program prints the same times every run. Work done inside a turn, and the few milliseconds a real sleep overshoots, don't show in it: three thousand wait(1ms) calls move now() by 3,000 ms while the wall clock may move 4,500. To measure time that really passed (a lease, a per-call deadline, how long some work took), read time.mono_ms(), a monotonic count of milliseconds, or time.wall(), the current instant. Both read the clock on every call.

With --fast, the clock is virtual and fast-forwards. Instead of sleeping, the scheduler jumps straight to the next task's wake time. A simulation that describes an hour of game time finishes in milliseconds, and a test doesn't have to wait around. The output is identical; only the wall-clock time it takes to produce it changes. time.mono_ms() and time.wall() read the virtual clock too, as they do under scua test, so a test of code that measures time gives the same answer every run.

Here's a two-timeline simulation:

spawn(fn()
  let start = now()
  print(`[patrol] move to A  (t+{now() - start}ms)`)
  wait(3s)
  print(`[patrol] move to B  (t+{now() - start}ms)`)
  wait(2s)
  print(`[patrol] back to A  (t+{now() - start}ms)`)
end)

spawn(fn()
  let start = now()
  wait(1s)
  print(`[regen]  +10 hp     (t+{now() - start}ms)`)
  wait(1s)
  print(`[regen]  +10 hp     (t+{now() - start}ms)`)
end)

print("world: spawned 2 tasks")

Run it normally and the lines appear spread over about five real seconds:

$ scua scheduling.scua
world: spawned 2 tasks
[patrol] move to A  (t+0ms)
[regen]  +10 hp     (t+1000ms)
[regen]  +10 hp     (t+2000ms)
[patrol] move to B  (t+3000ms)
[patrol] back to A  (t+5000ms)

Run it with --fast and you get the exact same lines, instantly:

$ scua --fast scheduling.scua
world: spawned 2 tasks
[patrol] move to A  (t+0ms)
[regen]  +10 hp     (t+1000ms)
[regen]  +10 hp     (t+2000ms)
[patrol] move to B  (t+3000ms)
[patrol] back to A  (t+5000ms)

The t+ numbers are virtual game time in both runs. What changes is how long you sit there waiting for them.

#Selective receive with wait_for

A partition's mailbox is a single queue, but inside a handler you don't have to take messages in arrival order. wait_for(Tag) parks the handler until a message of that exact tag arrives, then consumes it, skipping over anything else that's waiting. The skipped messages stay queued for their normal handlers.

partition Inbox
  on Open()
    print("[inbox] opened; holding until the Priority message arrives")
    let p = wait_for(Priority)              -- skip anything that isn't Priority
    print(`[inbox] handled priority first: {p[0]}`)
  end

  on Mail(subject)
    print(`[inbox] mail: {subject}`)
  end
end

let box = Inbox()
tell box.Open()
tell box.Mail("newsletter")
tell box.Mail("receipt")
tell box.Priority("server on fire")        -- sent last, consumed first
$ scua --fast wait_for.scua
[inbox] opened; holding until the Priority message arrives
[inbox] handled priority first: server on fire
[inbox] mail: newsletter
[inbox] mail: receipt

Open parks on wait_for(Priority). The two Mail messages arrive and wait their turn. Priority arrives last but the parked handler was waiting for exactly it, so it's handled first. wait_for returns the message's arguments as a list, so p[0] is the first argument.

#Waiting on several things with select

select waits on more than one thing at once and takes the first arm that's ready. It has three kinds of arm:

  • wait_for(Tag) -> ... is ready when a message of that tag is queued.
  • wait(d) -> ... is a timeout: ready once d elapses.
  • default -> ... runs immediately if nothing else is ready, turning select into a non-blocking poll.
partition Worker
  on Job(name)
    select
      wait_for(Done) -> print(`worker: {name} finished`)
      wait(10s)      -> print(`worker: {name} timed out`)
    end
  end
end

partition Pinger
  on Check()
    select
      wait_for(Pong) -> print("pinger: got a pong")
      default        -> print("pinger: nothing waiting")
    end
  end
end

let busy = Worker()
tell busy.Job("render")
tell busy.Done()             -- the Done arrives, so wait_for(Done) wins

let stuck = Worker()
tell stuck.Job("upload")     -- no Done ever arrives, so wait(10s) wins

let p = Pinger()
tell p.Check()               -- no Pong queued, so default wins
$ scua --fast select.scua
worker: render finished
pinger: nothing waiting
worker: upload timed out

The first worker gets its Done and finishes. The second never does, so after ten seconds (instant under --fast) the timeout arm fires. The pinger has nothing queued, so its default arm wins without blocking. A select with a default never parks; one without a default parks until an arm is ready.

These are three independent partitions, so — as in the actors guide — which of the three lines appears first is not something to rely on. Each partition's own lines are in order; the interleaving between them is the runtime's business.

#What can wait where

This is the sharp edge to learn early. Suspending splits into worlds, and which one you are in decides what you may do.

Top-level code can wait. The main body of your program is a task like any other, so wait(2s) at the top level works, and background tasks you spawn keep running while it sleeps:

-- toplevel_wait.scua
spawn do wait(50ms) print("task woke") end
print("main start")
wait(100ms)
print("main resumed")
$ scua --fast toplevel_wait.scua
main start
task woke
main resumed

The other two worlds still differ from each other:

A message handler suspends through the mailbox. Inside on/ask, you may suspend with ask, wait_for, or a select. You may not call bare wait(d) to sleep. Try it and the call faults, like any other mistake: a pcall or try around it catches it, and if nothing does, the partition reports it and keeps running:

scua: file.scua:3: actor fault in partition Greeter (it keeps running): a handler may only suspend via ask, a full tell, wait_for or select: to pause, write `select wait(d) -> nil end`, or `spawn` a task that waits

If you want a delay inside a handler, use it as a select arm, select wait(1s) -> ... end, not a standalone wait.

A spawned task suspends through the clock. Inside spawn do ... end (and spawn(fn() ... end)), you may wait(d). You may not currently ask, wait_for, or select from a spawned task:

scua: file.scua:9: actor fault in partition Greeter (it keeps running): ask outside an actor handler — `ask` suspends until the reply arrives, and only an actor handler can be resumed that way; from a task or the top level, use `tell`

So the division of labour is: handlers talk to other partitions and react to messages; spawned tasks do timed background work. To get a result from a spawned task back into your state, have it tell your partition a message, the same way any other code would. Sending those two worlds together is a known limit, not a permanent design; for now, structure around it.

Waiting on I/O is simpler under --io=async: a fs, http, exec or net call that has to wait parks the top-level code, a task spawned from it, or the message handler that made it, and the rest of the program runs meanwhile. One place still waits in place, holding everything around it until the call returns: a task spawned inside a handler. An http.serve handler's calls park like any partition handler's, holding only that request (a program that serves parks its I/O without the flag).

#Fan-out with wait_all and map_all

The scheduler above is about work spread over time. A different need is work spread over a list: twenty URLs to fetch, a hundred files to read, a batch of inputs to process. For that, SCUA has two built-ins — wait_all and map_all — with no import and no async/await coloring.

wait_all takes a list of zero-argument functions, runs them, and returns their outcomes in order:

let results = wait_all([
    fn() return 1 + 1 end,
    fn() return "two" end,
  ])
print(results[0], results[1])   -- 2 two

map_all is the same idea over a list, applying one function to each element, at most concurrency at a time (default 8):

let doubled = map_all([10, 20, 30], fn(x) return x * 2 end, { concurrency = 4 })
print(doubled[0], doubled[1], doubled[2])   -- 20 40 60

Two properties define both:

  • Ordered. Slot i holds arm i's outcome, regardless of which finished first.
  • All-settled. One arm failing never cancels the rest. You always get a slot for every arm — there's no "first error wins" that throws the others away.

Each slot holds that arm's value as-is: a plain value when the arm just computes one, or the Ok/Error an I/O arm returned. And if an arm faults — a real bug, like an out-of-range index — that lands in its slot as an Error carrying a tagged fault record, so one broken arm can't take down the batch. Test e.fault to tell a bug apart from an ordinary failure:

let mixed = wait_all([
    fn() return "ok" end,
    fn() return error("boom") end,
  ])
match mixed[1]
  Ok(v)    -> print(`ok: {v}`)
  Error(e) -> print(`arm 1 faulted={e.fault}: {e.message}`)
end

Two things to keep straight. First, don't put ? on it — wait_all and map_all never fail "as a whole", there's no outer Ok/Error to short-circuit; the failures live in the slots. Second, each arm must be a function you hand over uncalled: wait_all([fn() return http.get(u) end]), not wait_all([http.get(u)]) (which would run the call before wait_all ever sees it). By default the arms run one after another; add --io=async and their fs, http, exec and net waits genuinely overlap — at the top level, in a task spawned from it, or in a handler — for the same results in less wall-clock time. The dedicated how-to walks through all of this: Run many things at once.

#Coroutines

Underneath spawn, wait, and select is a lower-level building block: coroutines. A coroutine is a function you can suspend and resume by hand. coroutine(fn) creates one (suspended), resume runs it until the next yield, yield(v) hands a value back to the resumer, and status tells you whether it's suspended or done.

let counter = coroutine(fn()
  let i = 0
  while i < 1000000 do
    i = i + 1
    yield(i)
  end
end)

print(resume(counter))   -- 1
print(resume(counter))   -- 2
print(status(counter))   -- suspended
$ scua coroutines.scua
1
2
suspended

yield is an expression: the value passed to the next resume becomes its result, so a coroutine can receive as well as produce. Most code reaches for spawn and select rather than coroutines directly, but they're there when you want manual control. The coroutines.scua example in the examples folder walks through generators, two-way communication, and what happens when a coroutine runs off the end.