SCUA

Manual

Errors and faults

SCUA splits failure into two kinds, and keeping them apart is most of the job.

Some failures are expected. A charge declines, input doesn't parse, a lookup misses. These are values: a function hands back a Result and the caller decides what to do. Other failures are bugs. A divide by zero, an index off the end, a broken invariant. These are faults: they unwind the stack until something recovers them, or they stop the program.

The short version: return a Result for a failure the caller should handle, raise a fault for a situation that means the code is wrong.

#Results: failure as a value

A fallible function returns Ok(x) on success or Error(e) on failure:

fn parse_amount(s)
  if s < 0 then return Error("amount cannot be negative") end
  return Ok(s)
end

The caller has to deal with both arms, which is the point. The postfix ? makes the common path short: it unwraps an Ok and keeps going, or short-circuits by returning the Error to its own caller.

fn parse_amount(s)
  if s < 0 then return Error("amount cannot be negative") end
  return Ok(s)
end

fn charge(balance, amount)
  let a = parse_amount(amount)?   -- unwrap Ok, or return the Error to our caller
  if a > balance then return Error("insufficient funds") end
  return Ok(balance - a)
end

fn report(balance, amount)
  match charge(balance, amount)
    Ok(left) -> print(`charged {amount}, {left} left`)
    Error(e) -> print(`declined: {e}`)
  end
end

report(100, 30)
report(100, 200)
report(100, -5)

Run it:

$ scua charge.scua
charged 30, 70 left
declined: insufficient funds
declined: amount cannot be negative

? is how an error from parse_amount flows out through charge without a manual check at every level. At the top you consume the Result with match, handling Ok and Error explicitly.

#Testing a Result

When you only need to know which one you got, test it with matches: r matches Ok is true for a success and r matches Error for a failure. Both are plain booleans and work on any value, so nil matches Ok is just false.

fn check_pin(pin)
  if pin == "1234" then return Ok("unlocked") end
  return Error("wrong pin")
end

let r = check_pin("0000")
if r matches Ok then print("in") end
if r matches Error then print("refused") end
match r
  Ok(msg) -> print(msg)
  Error(why) -> print(`no: {why}`)
end
$ scua pin.scua
refused
no: wrong pin

What you can't do is use the Result itself as a condition. An Error is a value, not false, so if r then would take the then branch for a failure too: a PIN check written that way lets every PIN in. SCUA stops instead, and tells you what to write:

fn check_pin(pin)
  if pin == "1234" then return Ok("unlocked") end
  return Error("wrong pin")
end

let r = check_pin("0000")
if r then print("in") end
$ scua refused.scua
scua: refused.scua:7: type error: a condition got a Result (`check_pin` always returns Ok or Error): an Ok/Error is never true or false. Handle both cases:
  match r  Ok(v) -> …  Error(e) -> …  end
Or test it: `r matches Ok` for success, `r matches Error` for failure.

The same goes for every place that asks a yes/no question: while, elseif, an if … then … else … end expression, not, the left side of and and or, a match guard, a where refinement, a contract clause and assert. When the checker can see that a value is a Result, you get the error before the program runs. It can see a call to a function that returns Ok or Error, whether check_pin is in the same file or comes from a module you import (if pins.check_pin("0000") then), and a variable that holds what such a call gave back, as r does here. A Result it can't see coming, such as one handed to a function through a parameter with no type, or read back out of a table field, is stopped when the condition runs instead, with a message that says the same thing. Inside a function the message also offers let v = r?, which hands an Error on to your caller.

One thing that looks like a test is not one. r != nil is always true, because an Error isn't nil, so comparing a Result with nil is a compile error, and the message names the test you meant. So is comparing one with a plain value such as r == 1, which is always false.

#A default with ??

?? gives you a default when there is no value. On a Result, there is no value when it's an Error, or an Ok with nil inside; an Ok with anything else gives you what it holds. On a value that isn't a Result, ?? works as it always has: the default replaces nil, and anything else passes through. The default is only worked out when it's used.

import json

let port = json.decode("{\"port\": 8080}")?.port ?? 80
let cfg = json.decode("not json") ?? { port = 80 }
print(port, cfg.port)
$ scua port-default.scua
8080 80

The first line takes the value out with ?, reads port, and would use 80 if the field were missing. The second gets an Error, so cfg is the default. That makes r? ?? d and r ?? d agree, and when the default isn't nil, neither is the answer. Use ?? where a missing value has a sensible stand-in. Where the failure itself matters, match the Result instead, so its reason isn't thrown away.

Ok and Error are reserved for results, so you can't name your own contract, record, type alias, interface, enum variant, flags member, message or partition handler Ok or Error. The compiler says, for example, "Error is reserved for results — rename this record".

#The checker makes you handle a Result

A Result is a wrapper around the value you want, and the checker will not let you use the wrapper as if it were the value. These are compile errors, each with the fix in the message:

  • passing a Result where its value is needed: to a function that wants bytes or a string, to an operator like + or <, to a field or method (resp.status), or to for … in;
  • putting a Result into text: a template hole, .., format, or a builder's add. print, a log line and tostring still show a Result as it is, because they show a value to a person;
  • giving a Result to sys.emit, which would send Error(…) as your program's answer;
  • a match that has Ok(…) or Error(…) arms but doesn't cover both. An arm with a when guard, or one with a literal inside such as Error("timeout"), covers only what it says, so add Error(e) -> … or _ -> …. When the checker knows the value inside an Ok is a bool, as it does for fs.paths_alias, an Ok(true) arm and an Ok(false) arm together cover it. A bare Ok -> arm is refused: it matches every value, an Error too.

A function that can return an Error, because it uses ? or because one of its returns gives an Ok or Error, has to return Ok or Error on every path. A plain return 5 in it is a compile error that tells you to write return Ok(5), and so is return f()?, which takes the value out of a Result you could pass on as it is (return f()). A function that uses ? and runs off its end, or reaches a bare return, gives Ok(nil): it got through everything that could fail, and had nothing more to say. A function that returns an Error without using ? must not be able to run off its end, because there running off the end gives nil, which is neither: end it with return Ok(nil). Declaring such a function -> int is refused too: declare it -> Result, or leave the return type off.

fn save(name)
  if name == "" then return Error("no name") end
  print(`saved {name}`)
  return Ok(nil)
end

fn save_all(names)
  for n in names do
    save(n)?
  end
end

print(save_all(["a", "b"]))
print(save_all(["a", "", "c"]))
$ scua save-all.scua
saved a
saved b
Ok(nil)
saved a
Error(no name)

Changing what a function returns changes what its callers get, so the message names the callers, in the same file and in the package's other files, and they change in the same edit. The common case is a helper that returns an Error, or nil when all is well, and whose callers write if bad != nil then return bad end. It now gives Ok(nil), and each caller writes check(x)? instead of the nil test. Changing only the helper would be worse than changing nothing: an Ok(nil) is not nil, so every != nil test would treat success as a failure. Comparing a Result with nil is an error for that reason, and so is a test's assert_eq(check(x), nil), which becomes assert_eq(check(x), Ok(nil)).

Some functions are meant to return a mix: a callback whose documented answer is true, false, nil or a Result, say, or a fake transport in a test that answers with a wrong value on purpose. Declare such a function -> any (a function literal too: fn(x) -> any … end) and the rule leaves it alone: it may return anything, ? still passes an Error on, and nothing is checked when it returns. Its callers get a value the checker knows nothing about, so they are not checked against it either; handle what it gives with match where it matters.

import json

fn port_of(text)
  let cfg = json.decode(text)?
  return Ok(cfg.port)          -- `return cfg.port` would be refused
end

match port_of("{\"port\": 8080}")
  Ok(p) -> print(`listening on {p}`)
  Error(e) -> print(`bad config: {e}`)
end
$ scua port.scua
listening on 8080

Calling a function that returns a Result and doing nothing with what it gives back is a warning, not an error: the program runs, but a failure there goes unnoticed. The warning names the call and how to handle it. When a failure there really doesn't matter, say so with _ (below). Storing a Result in a table field is a warning too, since whatever reads the field later gets the wrapper; when the field is meant to hold one, put it in a name declared any first, let outcome: any = run(job), and store that.

The checker follows a Result into the functions you call: passing one to a parameter with no type that the function then reads a field of, does arithmetic on or puts into text is the same error as doing that yourself, reported at your call, however many calls down the use is. A function that tests the parameter itself first, with type(p), matches, match, ? or ??, directly or in a helper it hands the parameter to, is expected to get one, and nothing is reported. A test that passes a Result on purpose, to check that a function refuses it, declares the variable any: let wrapped: any = load(). A Result kept in an array or a table field, or handed to a function that only passes it on, stops being one the checker can see, and the run time catches its misuse where it happens.

Putting such a Result into text is caught too. Wherever a value becomes text that goes on as data (a template, .., format, a builder's add, str.join, render, to_csv), a Result inside it, however deep, stops the program at that line with a fault you can catch, for an Ok as well as an Error. So the first test of the path that works finds the place, rather than a stored Error(…) turning up later. print, log lines and tostring show a Result in full, so tostring(r) is how you put one into text on purpose. json.encode writes one as {"kind": …, "value": …}, and sys.emit refuses one, since it would become your program's answer. The message never shows what an Ok holds, as with every fault message (see --debug-values).

const outcomes = [Ok(3), Error("no such file")]
for r in outcomes do
  match pcall(fn() return `outcome: {r}` end)
    Ok(text) -> print(text)
    Error(e) -> print(`refused: {e}`)
  end
  print(`on purpose: {tostring(r)}`)
end
$ scua outcomes.scua
refused: this template got an Ok where it needs text. Take the value out first with `?` or `match`.
on purpose: Ok(3)
refused: this template got an Error (no such file) where it needs text. Take the value out first with `?` or `match`.
on purpose: Error(no such file)

The code inside a partition's on and ask handlers is checked the same way. A handler is not a function that must return Ok or Error: there ? and return Error(e) end the turn (an ask handler's caller gets the Error as its reply), a return is the reply, and the end of the handler needs no return.

#Throwing a value away with _

_ = f() runs f() and throws away what it gives back, on purpose, so a dropped Result gets no warning. let _ = f() means the same, and also works on releases before this one, so code that has to run on both can use it. _ is never a variable: in let _, _ =, for _, v in …, a _ parameter or a _ pattern it binds nothing, and it can appear more than once.

fn save(name)
  if name == "" then return Error("no name") end
  return Ok(nil)
end

_ = save("draft")
for _, v in ["a", "b"] do print(v) end
$ scua discard.scua
a
b

Because nothing is kept, there is nothing to read back:

let _ = 5
print(_)
$ scua read-discard.scua
scua: read-discard.scua:2: `_` throws a value away, so there is nothing to read. Give it a name: `let total = …`.

A field named _, as in t._, is an ordinary field.

#? and return Error at the top level

At the top level of the script you run, ? on an Error, or return Error(e), stops the script exactly as sys.fail(e) does: {"error": …} on stderr, with the Error's value as JSON, and exit status 1. Whatever was already printed stays printed. A return Ok(v), or any other value, exits 0 as before.

import json

print("reading config")
let cfg = json.decode("{\"port\": 8080")?
print(`listening on {cfg.port}`)
$ scua top-level.scua
reading config
{"error":"invalid JSON at line 1, column 14"}

Elsewhere:

  • A module you import can't do either: its top level is how you get its exports, and those are never a Result. ? or a return the checker can see is a Result is a compile error there, and a module whose top level gives an Ok or an Error anyway makes the import fault, naming the module.
  • Under scua test, a test file whose top level stops on an Error is reported as failing (the top level returned Error: …), none of its tests run, and the other files still do.
  • In a sandbox, a load turn that stops this way gives the host Error({ kind = "failed", message, value, line }), where value is the Error's own value.

Beware a loop at the top level that serves many requests, such as while true do let c = net.accept(l)? … end: the first Error any one peer causes stops the whole server. Inside a serving loop, match what each request gives.

#Faults: failure as a bug

A fault is for the situation that shouldn't happen. You raise one with error(...), and the runtime raises one for you on things like a divide by zero. A fault unwinds until a try/rescue catches it. The rescue block binds the fault and runs, and the program carries on:

fn safe_div(a, b)
  try
    return Ok(a // b)
  rescue err
    return Error(`math error: {err}`)
  end
end

match safe_div(84, 2)
  Ok(x)    -> print(`84 / 2 = {x}`)
  Error(e) -> print(e)
end
match safe_div(1, 0)
  Ok(x)    -> print(x)
  Error(e) -> print(e)
end
print("still running")
$ scua safe-div.scua
84 / 2 = 42
math error: divide by zero
still running

safe_div catches the divide-by-zero fault and turns it into a Result, which is a reasonable thing to do at a boundary where you'd rather hand the caller a value than crash. The still running line confirms the program continued.

A fault that nothing rescues stops the program with a located message:

print("before")
let x = 1 // 0
print("after")
$ scua uncaught.scua
before
scua: uncaught.scua:2: divide by zero

after never prints. That's the right outcome for a real bug: fail loudly at the point of the mistake rather than limp along with bad state.

When the fault happens inside a chain of function calls, the report includes a backtrace so you can see how the program got there, innermost call first:

fn health_percent(current, max)
  return current * 100 // max
end
fn show_bar(entity)
  return health_percent(entity.hp, entity.max_hp)
end
const dead = { hp = 0, max_hp = 0 }
print(show_bar(dead))
$ scua bar.scua
scua: bar.scua:2: divide by zero
  at health_percent (bar.scua:2)
  at show_bar (bar.scua:5)
  at main (bar.scua:8)

The first line is the fault and where it happened; each at line below is a caller, up to the top-level main. Every line names its own file, so when a fault happens inside a module you imported, the trace says so instead of pointing at the file you ran. A fault that happens at the top level, not inside any call, just shows the one located line.

A rescue block that raises the fault it caught again, with error(e), passes it on unchanged: if nothing else catches it, the report and the backtrace still show where it first happened, not the line that re-raised it. That's true whether the block calls error(e) itself or hands e to a function that does. Raising a new value, like error(`saving failed: {e}`), is a new fault, reported where you raised it.

#Faults a running program survives

A fault nothing catches in your top-level code stops the program. Three places stop less than that, because they are where a long-running program does its work, and one bad input there shouldn't take everything else down:

  • An http.serve handler. The request that faulted gets a 500, and the server goes on answering.
  • A spawned task. The task stops. Other tasks, partitions and a running server carry on. See When a spawned task faults.
  • A partition's handler. That turn stops, and the partition goes on to its next message. What the handler already changed stays changed. See When a handler faults.

Each one is reported on stderr when it happens, with its location, so someone watching a running program sees it then and not only when the program exits. A server's line names the request:

scua: app.scua:12: http.serve GET /orders/7 answered 500: fields only exist on tables and records, not on nil values

Two things that aren't faults are reported the same way, because in each the Error reaches nobody else: a server handler that returned an Error (http.serve GET /orders/7 answered 500: the handler returned Error(no such order)), and a partition handler whose turn ended with one (see When a handler returns an Error).

A fault message names the value that caused it where that helps, as in match: no arm matched (got 99). It never shows what an Ok holds, which could be a decrypted secret: it says got an Ok instead, and inside a table or list Ok(…). An Error keeps its message. Under scua test and the debugger the full value is shown, and on an ordinary run --debug-values shows it, for the times you can't reproduce a fault under the debugger.

The client gets a bare 500 either way. The fault text goes to your stderr, not to whoever sent the request, unless you pass --serve-debug while developing. Characters in the path that could fake or recolour a log line, such as a newline or an escape code, are written as \x0a, \x1b and so on.

Whoever sends the bad requests decides how often these happen, so the reports are rate-limited: up to 20 are written at once, then one a second. Any held back are counted, and the next report that is written says how many:

scua: 37 more fault reports were not written (the log takes 20 at once, then one a second)

Returned Errors have a limit of their own, with their own count (scua: 280 more reports of returned Errors were not written …), so a flood of bad messages that each end a turn with an Error can't hide a real fault behind them. Each limit also says at exit how many it held back in all.

A program in which a task or a partition faulted still exits with status 5 when it ends, the same as for a fault nothing caught, so a script or supervisor running it sees the failure. (A server's 500s don't change its exit status: they were answered.)

#pcall: try/rescue as a value

pcall runs a function and reports the outcome as a Result instead of unwinding. It's the value-shaped form of try/rescue: you get Ok(result) if the function returned normally, or Error(message) if it faulted.

fn risky(n)
  if n == 0 then error("cannot be zero") end
  return 100 // n
end

match pcall(fn() return risky(4) end)
  Ok(v)    -> print(`got {v}`)
  Error(e) -> print(`failed: {e}`)
end
match pcall(fn() return risky(0) end)
  Ok(v)    -> print(`got {v}`)
  Error(e) -> print(`failed: {e}`)
end
$ scua pcall.scua
got 25
failed: cannot be zero

Reach for pcall when you want to run something that might fault and branch on the outcome inline, without wrapping it in a named try block.

pcall takes any function value with no arguments, a built-in or module function included: pcall(sys.readline) is Ok of the line, or Error of the fault.

#Running out of stack

Every function call the program is inside costs a call frame, and there is a ceiling: 4000 frames. Go past it and you get a fault — stack overflow: call depth exceeded 4000 frames — catchable with try/rescueorpcall`` — which behaves like every other fault. It unwinds, try/rescue and pcall catch it, and an uncaught one stops the program with a line and a backtrace.

Nine times in ten, hitting it means you wrote accidental infinite recursion: a function that calls itself without a base case, or two functions that call each other in a loop. Read the backtrace before you reach for rescue — the fix is usually the missing base case, not a handler.

The tenth time is real, and it's why the limit is catchable. If you write a library that walks data someone else supplies — a JSON document, a config tree, a parsed markup tree — you cannot know how deep it nests, and a document a few hundred levels deep is not your bug. Catch it at your boundary and hand the caller a value:

fn depth_of(node)
  if type(node) != "array" then return 0 end
  let best = 0
  for child in node do
    let d = depth_of(child)
    if d > best then best = d end
  end
  return 1 + best
end

fn measure(doc)
  try
    return Ok(depth_of(doc))
  rescue err
    return Error(`document nests too deeply: {err}`)
  end
end

fn nest(n)
  let acc: any = [1]
  for i in 0:n do acc = [acc] end
  return acc
end

match measure([1, [2, [3]]])
  Ok(d)    -> print(`depth {d}`)
  Error(e) -> print(e)
end
match measure(nest(5000))
  Ok(d)    -> print(`depth {d}`)
  Error(e) -> print(e)
end
print("the program keeps running")
$ scua deep.scua
depth 3
document nests too deeply: stack overflow: call depth exceeded 4000 frames — catchable with `try`/`rescue` or `pcall`
the program keeps running

That is the pattern: measure promises a Result, so "too deep" joins "malformed" and "empty" as one of the failures it reports rather than a way for it to kill its caller. Note that the frames are gone by the time your rescue block runs — unwinding is what freed them — so the handler has a full stack to work with, and returning a value from it is completely ordinary.

If you genuinely need to walk unbounded input, rewrite the recursion as a loop with your own stack. The catchable limit means you don't have to in order to be safe; it means you find out instead of dying.

#Values that get too big

One value — one array, one table, one string, one bytes — has a ceiling of 256 MiB. For an array of mixed values that is about 16.7 million elements. An array of numbers stores them unboxed in a block of its own, and can hold up to 4,294,967,295 of them, memory permitting. The ceiling is structural, not a budget: there is no flag that raises it.

Going over it is a fault, so it behaves like every other fault — it names the limit, try/rescue and pcall catch it, and an uncaught one stops the program with a line and a backtrace.

fn make_buffer(n)
  let buf = []
  try
    buf.reserve(n)
    return Ok(buf)
  rescue err
    return Error(`cannot hold {n} items: {err}`)
  end
end

match make_buffer(5000000000)
  Ok(b) -> print("reserved")
  Error(e) -> print(e)
end
$ scua buffer.scua
cannot hold 5000000000 items: reserve(5000000000) is over the largest an array may be (4294967295 elements)

The value you were building is untouched — the size is checked before anything is allocated, so there is no half-built array to clean up and you can carry on using it.

Most programs never come near this. If yours does, the answer is usually to stop holding the whole thing at once: write results out as you produce them, split the data across several values, or keep numbers in a typed array. builder() is the right way to accumulate text, but it does not lift the ceiling — it makes reaching it cheaper.

#What you can't catch

Two failures deliberately ignore try/rescue and pcall, because catching them would let a script escape a limit it was given:

  • used its whole CPU budget — the --max-ops reduction budget, when you (or a host, or a sandbox) set one. A handler would resume with the budget still exhausted and immediately trip again, so a script could loop forever catching its own kill switch. Without --max-ops there is no CPU limit to catch: the built-in ceiling refills rather than stopping the run, so an ordinary long computation just finishes.
  • memory limit exceeded — the --max-mem cap. Unwinding frees call frames, not the data that breached the cap, so a handler would run with nowhere to allocate.

The rule underneath is worth knowing, because it tells you which way a future limit will go: a limit is catchable when the handler runs in a state that no longer breaks it. Unwinding pops call frames, so the depth limit is catchable; nothing is allocated when a value is refused for being too big, so that one is too. Unwinding gives back no CPU budget and frees no memory, so those two are not — a handler would resume with the limit still breached and trip again on its next step.

print("printed before the budget ran out")
let n = 0
match pcall(fn() while true do n = n + 1 end return n end)
  Ok(v) -> print(v)
  Error(e) -> print(`caught: {e}`)
end
$ scua --max-ops=100k budget.scua
printed before the budget ran out
scua: budget.scua:3: this turn used its whole CPU budget of 100000 steps, set by `--max-ops`. Raise it, or remove the budget with `--max-ops=none`

The pcall does not intervene and the program stops. What it does do is print everything the script had already printed — a run that fails still shows you its output, which is usually where the clue is.

#Which one to use

Return a Result when failure is part of the function's normal contract and the caller is expected to handle it: parsing, validation, I/O, anything where "it didn't work" is a routine outcome. The Error carries the reason, ? propagates it, and match forces the caller to confront it.

Raise a fault when continuing would mean running on broken state: a violated invariant, an impossible branch, a precondition the caller was supposed to guarantee. Don't reach for try/rescue as routine control flow. Use it at the boundaries where you'd rather contain a fault than let it take down the whole program, the way safe_div does.

A useful rule of thumb: if you'd write a comment saying "this can't happen," that's a fault. If you'd write "the caller should check for this," that's a Result.

#See also