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():
| Owner | Query | Records |
|---|---|---|
| repeating timer | handle.last_error() |
Most recent tick callback error (nil if none) |
std.net.tcp_client |
client.last_error() |
recv failure, on_receive callback exception |
std.net.tcp_server |
server.last_error() |
accept/TLS failure, on_connect/on_receive/on_disconnect callback exception |
std.net.udp |
endpoint.last_error() |
recv failure, on_receive callback exception |
std.net.http_server |
server.last_error() |
TLS handshake, route-handler exception |
std.net.websocket |
ws.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) # keep the script alive so the tick can fire
print(t.last_error()) # "Unknown variable: does_not_exist"
if (t.last_error() != nil) { 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 nil
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
awaitfutures you care about — an un-awaitedspawnsilently 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()aftersleepor 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_VARIABLEfor a local you tried to capture. See Overview. - Host callbacks (C++ via
engine.insert) that throwScriptError{status, msg}surface with that status — prefer it over raw exceptions (see Embedding).
See also
- std.time — Timers —
timerfuture vs handle - Overview — yield points