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.

#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.

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.

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.

#What can wait where

This is the sharp edge to learn early. Suspending splits into two worlds that don't currently overlap.

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 partition faults:

scua: file.scua: actor fault: a handler may only suspend via ask

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: actor fault: a spawned coroutine may only suspend via wait (ask/wait_for/select not yet supported there)

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.

#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 itwait_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 the fs/http waits genuinely overlap, 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.