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 (null 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)
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
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