SCUA

News

SCUA 0.39.0: a time limit on your own code

October 25, 2026

SCUA 0.39.0 lets a program put limits on its own work. within runs a function under a time limit, and optionally a step or memory budget, and when the limit is reached the work stops at the line it had reached and you get an Error you can answer in your own words. actors.stop stops another partition outright, even one stuck in a loop. A stdio server can write its replies one JSON line at a time with sys.emit_line. Every file-system failure now says what kind of failure it was. For game engines, a stuck actor now faults on its own while the rest keep running, a frame's actor work can be bounded by messages handled, and actors run on several cores on Linux too.

#Put a time limit on a piece of work

within(d, f) calls f. If f finishes in time you get its value as Ok(…), or its own Result as it is. If it doesn't, the work is stopped and you get Error({ kind = "timeout", message, at }), where at is the line it had reached:

fn lookup(name)
  return { name = name, stock = 12 }
end

fn forecast(days)
  let total = 0
  while true do total = total + days end
end

fn run_tool(label, f)
  match within(250ms, f)
    Ok(result) -> print(label, "->", result)
    Error(e) -> if e.kind == "timeout" then
        print(label, "-> didn't finish within 250 ms, stopped at line", e.at)
      else
        print(label, "-> failed:", e.message)
      end
  end
end

run_tool("lookup", fn() return lookup("rope") end)
run_tool("forecast", fn() return forecast(30) end)
$ scua tools.scua
lookup -> {name = rope, stock = 12}
forecast -> didn't finish within 250 ms, stopped at line 7

The work stops wherever it is, in the interpreter or in compiled code: in a loop like forecast's, or while waiting on an ask, a select, a file or a network call, which all end at the deadline. Files and connections the work opened are closed. A server that runs tools for an agent can now answer "the tool didn't finish within 10 s" itself, where before the client got a 503 when the whole request ran out of time. Limits nest, and the tighter one wins. Under scua test and --fast the deadline follows the virtual clock, so a test of a timeout gives the same answer every run.

Give within a table to set budgets as well as a time limit, any of the three:

fn build_report(n)
  let rows = {}
  for i in 0:n do rows[i] = `row {i}` end
  return len(rows)
end

let limits = { time = 5s, ops = 100000000, memory = 1024 * 1024 }
print(within(limits, fn() return build_report(1000) end))
print(within(limits, fn() return build_report(1000000) end))
$ scua budget.scua
Ok(1000)
Error({kind = memory, message = within: the memory budget ran out, and the work was stopped at line 3, at = 3})

ops counts the steps the work takes and stops it with kind = "budget". memory counts what it adds and stops it with kind = "memory".

#Stop a partition, whatever it is doing

actors.drop answers busy for a partition that is in the middle of a turn. actors.stop(a) ends the turn at its next check, even in a loop that never waits:

import actors

partition Worker
  state n = 0
  on Crunch()
    while true do n = n + 1 end -- never waits
  end
end

partition Supervisor
  on Watch(w)
    select wait(100ms) -> nil end
    print("stopped:", actors.stop(w))
  end
end

let w = Worker()
tell w.Crunch()
tell Supervisor().Watch(w)
$ scua stop.scua
stopped: Ok(true)

An ask the partition was serving is answered with Error({ kind = "stopped" }), the files and connections it held are closed, and then it is dropped.

#Answer one JSON line at a time

A tool that keeps running and answers request after request, such as a stdio server speaking JSON lines, writes each reply with sys.emit_line(value). The line is written and flushed straight away, and the program carries on:

import sys

while true do
  let line = sys.readline()
  if line == nil then break end
  print("got a request")
  sys.emit_line({ echo = line, n = len(line) })
end
$ printf 'alpha\nbeta\n' | scua --allow-tty --output=json server.scua 2>/dev/null
{"echo":"alpha","n":5}
{"echo":"beta","n":4}

Under --output=json these lines are the only thing that reaches stdout. The two prints went to stderr, so a stray debugging line in someone's tool code can't corrupt the stream. Partitions on different cores can call it at once, and their lines never mix.

#File errors that say what went wrong

Every fs failure now returns Error({ kind, message }), as fs.write and fs.lock already did. Code can decide on the kind and leave the message for people:

import fs

match fs.read_text("settings.toml")
  Ok(text) -> print("loaded", len(text), "bytes")
  Error(e) -> if e.kind == "not_found" then
      print("no settings yet, using the defaults")
    else
      print(`can't read the settings ({e.kind}): {e.message}`)
    end
end
$ scua --allow-fs=data settings.scua
no settings yet, using the defaults

The kinds are not_found, denied, exists, not_empty, bad_path, too_large, invalid, unsupported, closed, timeout and io. A program can now tell "it isn't there" from "you may not look" in one comparison. fs.exists uses the same distinction. It answers true or false when it knows, and an Error when it can't tell, so a program that starts fresh when it finds no save won't start fresh over a save it simply wasn't allowed to see:

import fs

print(fs.exists("settings.toml"))
print(fs.exists("private/save.dat"))
$ scua --allow-fs=data exists.scua
false
Error({kind = denied, message = private/save.dat: permission denied — whether it exists cannot be told})

#For game engines

One actor stuck in a loop no longer stops the others. With an op_budget set, a partition whose turn uses the whole budget is stopped by itself and reported to the fault hook with its name and line. It takes its next message as usual, and every other partition keeps running.

A frame's actor work can now be bounded by messages handled. Under scua_run_turns, max_turns counts messages, so a frame does at most that many handler calls however many partitions are busy:

scua_turn_budget b = { sizeof b, 64, 2000000 };   // at most 64 messages or 2 ms, whichever comes first
scua_run_turns(p, &b, &report, err, sizeof err);

A long handler is paused and carried on next frame, even when nothing else is running.

The fault hook hears more as well. A handler that ends with an Error nobody was waiting for arrives as SCUA_FAULT_ERROR. The partition keeps its state, and the call's status doesn't change. A text sent to a partition that takes only the messages it declares is refused with SCUA_ERR_TYPE.

static void on_fault(void *ctx, const scua_fault_info *f) {
    if (f->kind == SCUA_FAULT_ERROR) {      // a handler gave back an Error; the actor carries on
        log_warning(ctx, f->partition, f->partition_len, f->message, f->message_len);
        return;
    }
    mark_actor_broken(ctx, f->actor, f->line);
}

Reusable actor modules work with engine modules now. A partition can call an engine function through import engine as e, through a module's own alias for it, or through a record another module returns. Calls are checked exactly as before.

scua_set_workers(p, n) now runs a script's actors on several cores on Linux, as it does on macOS and Windows. The workers are ordinary threads of the game's process. The Linux libscua.a is built position-independent, so it links into a default (PIE) program without -no-pie. And a host can free the script text it passed to scua_load_script as soon as the call returns, because the loaded script keeps its own copy.

#Upgrading

  • Every fs error is now { kind, message }. Where you used one as a string, as in `could not read: {e}` or str.contains(e, …), use e.message.
  • fs.exists returns an Error when it can't tell. Used straight as an if condition, that Error stops the program with a message, so test for it first with matches Error where a folder may be unreadable.
  • Under scua_run_turns with a budget, max_turns counts messages handled, and a handler that runs long is paused at the budget even when nothing else is ready.
  • An actor that uses its whole op_budget is now a fault of that actor alone, reported to the fault hook. The drain carries on, and the call returns SCUA_ERR_OTHER as it does for any other actor fault, where it used to stop with SCUA_ERR_BUDGET.

The changelog lists everything else in 0.39.0, including http.serve naming the function and value a request partition couldn't be given, clearer messages for vector maths on the wrong kinds, and an enum reached through another module printing by name.