Skip to main content

Errors in Concurrent Code

Scrii has no try/catch. Concurrent errors surface in two ways: futures (re-thrown by await) and last_error() on objects that run background work with no caller.

Errors you can await

spawn and defer return a future. If the fiber throws, the error is stored on the future and re-thrown by await as Async error: <message>. It does not abort the script at throw time.

var f = std.async.spawn(fn() { return missing_variable })
std.async.await(f)
// RUNTIME_ERROR: line 1: Async error: Unknown variable: missing_variable

Pattern for handling:

var f = std.async.spawn(fn() { return 1 / 0 })
var ok = std.assert.assert_throws(fn() { std.async.await(f) })
print(ok) // true — await threw as expected

The same applies to one-shot std.time.timer({repeat: false}) — it returns a future that re-throws on await.

var t = std.time.timer({callback: fn() { does_not_exist }, duration: 10})
std.async.await(t) // RUNTIME_ERROR: Async error: Unknown variable: does_not_exist

Errors you cannot await

Some work has no caller to receive an exception — repeating timers and background network loops. For these, Scrii records the most recent failure on the owning object (echoed to stderr as well) and exposes it via last_error():

OwnerQueryRecords
repeating timerhandle.last_error()Most recent tick callback error (null if none)
std.net.tcp_clientclient.last_error()recv failure, on_receive callback exception
std.net.tcp_serverserver.last_error()accept/TLS failure, on_connect/on_receive/on_disconnect callback exception
std.net.udpendpoint.last_error()recv failure, on_receive callback exception
std.net.http_serverserver.last_error()TLS handshake, route-handler exception
std.net.websocketws.last_error()connection/send failure, on_message/on_close callback exception

Every object records into its own slot — one timer failing does not mask another's error.

// repeating timer — error goes to the handle's last_error, not a future
var t = std.time.timer({callback: fn() { does_not_exist }, duration: 20, repeat: true})
std.async.sleep(80)
print(t.last_error()) // "Unknown variable: does_not_exist"
if (t.last_error() != null) { print("timer failed") }

// http_server — route handler exception
var srv = std.net.http_server()
srv.routes([{path: "*", handler: fn(req) { return missing_variable }}])
srv.listen(8080)
// ... after a request:
print(srv.last_error()) // "Unknown variable: missing_variable" or null

last_error() is not cleared when read and holds only the most recent message — a new failure on the same object overwrites the previous one.

Propagating vs. swallowing

  • Always await futures you care about — an un-awaited spawn silently swallows its error. Completed futures are swept periodically (60 s TTL), so in the worst case an un-awaited future is silently dropped, not kept forever.
  • For background objects, poll last_error() after sleep or on an interval.
  • All background failures are also printed to stderr, so they are visible even if you never query last_error().

Concurrency notes

  • Errors from race — a race completes on the first successful function. Individual throwing functions are discarded; await(race(...)) re-throws only when every function threw (Async error: All race() functions threw exceptions).
  • Callbacks reach data through globals — an error from a wrong variable name is almost always UNKNOWN_VARIABLE for a local you tried to capture. See Overview.
  • Host callbacks (C++ via engine.insert) that throw ScriptError{status, msg} surface with that status — prefer it over raw exceptions (see Embedding).

See also