Oska runtime
oskac run <file.osk> [--format=json] [-- args...] checks one file and, if the check reports no diagnostic, runs it in the interpreter (oska-run). oskac test <file.osk> [--format=json] [--integration] runs its test blocks instead (see Tests). The interpreter executes the checker's checked program (ADR 0007); the rules it follows are in the "Runtime" section of design.md. This page describes how a run ends and the codes the runtime reports. Checker codes are in checker.md.
Exit codes
| Exit code | Meaning |
|---|---|
| 0 | main returned Ok or (). |
| 1 | The checker refused the program, or main returned Err: an expected failure. |
| 2 | The program uses something the interpreter cannot run yet (E0370, E0371, E0380). Nothing ran. |
| 101 | The program panicked: it has a bug. |
Refusals
Before a program starts, the runtime checks that it can run all of it, reached or not, so a program never fails halfway through because of a gap in the interpreter. A refusal is printed like a checker diagnostic: to stderr, as one JSON object per line with --format=json.
| Code | Refusal |
|---|---|
E0370 | main declares a capability that has no runtime yet: Net, NetConnect, NetListen, Spawn, or Sched. Spans main's body. (A main that declares a directory or file capability is already refused by the checker, E0217.) |
E0371 | The program imports a std module that has no runtime yet: std.proc or std.sql, used or not. Spans the module path of the use. |
E0380 | The program uses something the interpreter cannot run yet: a construct the checked program cannot express, a call of a prelude function with no runtime implementation, or a parameter of main that is neither a capability nor Args. The message names the construct, as in the interpreter cannot run a `From`-bound conversion yet; others are a constructor used as a function value, a call of a generic trait's method, and a ? or return in a contract outside any closure (the checker rejects that first, with E0282, so this is a second line of defence). Spans the construct or call, or main's body for a parameter. |
Refusal examples
Each refusal has a program that is refused with exactly that code and the same program fixed. The examples run on the in-memory host, so they depend on nothing outside the program.
E0370
fn main(net: Net) -> Result<(), IoError> {
Ok(())
}
fn main(io: Stdout) -> Result<(), IoError> {
io.println("hello")
}
E0371
use std.sql
fn main() {}
use std.html
fn main() {}
E0380
fn main() {
let make = Some
let x: Option<Int> = make(1)
}
fn main() {
let x: Option<Int> = Some(1)
}
Panics
A panic stops the program where a bug happened. It has a stable P-code, a message, and the span of the expression that panicked. Without --format=json, it is printed to stderr like a diagnostic, file.osk: error[P0004]: division by zero: ... (bytes 61..67). With --format=json, it is printed to stderr as one diagnostic object, in the same shape as the checker's (see ast.md):
{"code":"P0004","message":"division by zero: `10 / 0`","span":{"start":61,"end":70}}
Output written before the panic stays written.
Integers of every type, Int and the sized I8 to U64, are checked: arithmetic never wraps.
| Code | Panic |
|---|---|
P0001 | Integer overflow: the result of +, -, *, /, or unary - does not fit its type, as with a U8 0 - 1 or an I8 -128 / -1. Spans the operation. |
P0002 | Index out of bounds: List.at was given an index below 0 or not below the list's length. Spans the call. |
P0003 | todo() reached. Spans the call. |
P0004 | Division by zero: the right operand of / or % is zero. Spans the operation. |
P0005 | spawn on a finished scope: the handle's scope block has ended, and work never runs outside a scope. Spans the call. This is a second line of defence behind the checker (E0292), which already stops a handle from outliving its block: no checked program can reach this code, so it has no example. |
P0006 | Precondition violated: a requires clause of the function called is false, so the caller broke the contract. The message names the function and quotes the clause's condition. Spans the call. Every call evaluates the function's requires clauses, in every build, in the order written: after the arguments are evaluated and before the body runs. A clause sees the parameters and self. A panic while a clause is evaluated, such as an overflow, is reported as it is, not as P0006. |
P0007 | Postcondition violated: an ensures clause of the function that returned is false, so the function broke its promise. The message names the function, quotes the clause's condition, and names the line and column of the return that broke it, counting columns in characters from 1. Spans the ensures clause. Every return evaluates the function's ensures clauses, in every build, in the order written, with r bound to the value being returned. That covers falling off the end of the body (the return is its last expression, or its closing brace), an early return, and an early exit through ?, where r is the Err or None being returned. A clause sees the parameters and self; a method that changes var self shows its clauses the receiver it ends with. A contract that calls a function with contracts checks those too, and a panic while a clause is evaluated, such as an overflow, is reported as it is, not as P0007. |
P0010 | t.assert(cond) was given false. Fails the test (see Tests). Spans the call. |
P0011 | t.assert_eq(actual, expected) was given two values that differ through Eq. The message shows both, as dbg would. Spans the call. |
P0012 | t.assert_ne(actual, other) was given two equal values. Spans the call. |
P0013 | t.fail(message) was called. The message is message. Spans the call. |
P0020 | Env.secret asked for a variable the environment does not set. Its signature has no room for a missing secret, and running on with a stand-in such as an empty one could let an empty password through. Spans the call. |
P0021 | Calls nest too deep: runaway recursion. The interpreter runs on a thread of its own with a 256 MiB stack, and a call panics instead of starting when calls already nest 50,000 deep, or when so little of that stack is left that the next call could overflow it. The process never crashes on deep recursion: oskac run exits with 101, and under oskac test only the test that recursed fails. Spans the call that would have gone too deep. |
P0035 | String.slice was given indices out of bounds: a start or end below 0 or past the text's length in characters, or a start past the end. Spans the call. Indices count characters (Unicode code points), not bytes. |
P0036 | String.repeat was given a negative count. Spans the call. |
P0037 | String.repeat would build a text over 256 MiB, so the interpreter panics instead of running out of memory. Spans the call. |
Panic examples
Each panic has a program that stops with exactly that code and the same program fixed. The P001x examples are tests, because only a test block can fail an assertion, and run under oskac test. P0005 has no example: the checker (E0292) stops a program from keeping a scope handle past its block, so no checked program can reach it.
P0001
fn main() {
let x: U8 = 255
let y = x + 1
}
fn main() {
let x: Int = 255
let y = x + 1
}
P0002
fn main() {
let xs: List<Int> = List.new()
let first = xs.at(0)
}
fn main() {
let xs: List<Int> = List.new()
let first = xs.get(0)
}
P0003
fn main() {
todo()
}
fn main() {}
P0004
fn main() {
let zero = 0
let n = 10 / zero
}
fn main() {
let two = 2
let n = 10 / two
}
P0006
fn double(x: Int) -> Int
requires x > 0
{
x * 2
}
fn main() {
let y = double(-1)
}
fn double(x: Int) -> Int
requires x > 0
{
x * 2
}
fn main() {
let y = double(1)
}
P0007
fn absolute(x: Int) -> Int
ensures |r| r >= 0
{
x
}
fn main() {
let y = absolute(-1)
}
fn absolute(x: Int) -> Int
ensures |r| r >= 0
{
if x < 0 {
0 - x
} else {
x
}
}
fn main() {
let y = absolute(-1)
}
P0010
test "is positive" {
t.assert(1 < 0)
}
test "is positive" {
t.assert(0 < 1)
}
P0011
test "adds" {
t.assert_eq(1 + 1, 3)
}
test "adds" {
t.assert_eq(1 + 1, 2)
}
P0012
test "differs" {
t.assert_ne(2, 2)
}
test "differs" {
t.assert_ne(2, 3)
}
P0013
test "not written yet" {
t.fail("write this test")
}
test "written" {
t.assert(true)
}
P0020
fn main(env: Env) {
let key = env.secret("API_KEY")
}
fn main(env: Env) {
let key = env.get("API_KEY")
}
P0021
fn down(n: Int) -> Int {
1 + down(n - 1)
}
fn main() {
let depth = down(10)
}
fn down(n: Int) -> Int {
if n == 0 { 0 } else { 1 + down(n - 1) }
}
fn main() {
let depth = down(10)
}
P0035
fn main() {
let part = "abc".slice(0, 10)
}
fn main() {
let part = "abc".slice(0, 2)
}
P0036
fn main() {
let line = "ab".repeat(-1)
}
fn main() {
let line = "ab".repeat(3)
}
P0037
fn main() {
let huge = "ab".repeat(300000000)
}
fn main() {
let small = "ab".repeat(3000)
}
Errors and traits
mainreturningErr(e). The runtime printse.to_string(), theDisplaytext ofe, and a newline to stderr, and exits with code 1.?returnsNoneorErr(e)from the enclosing function or closure. When the function's error type differs,eis converted through theFromimpl the checker chose.- Trait calls. A trait method called on a value of a known type reaches that type's impl directly. One called on a value of a type parameter is dispatched on the value's runtime type tag, the name of its type without arguments (
Point,List,String), to the program's impl for that type or else the prelude's.Option,Result, andListcompare their items throughEqfor the items' type. - Every integer carries its own type as its tag:
Int, or a sized type fromI8toU64. A literal takes the type the checker gave it, arithmetic keeps its operands' type, and a range's items keep its bounds' type, so aU8reaches the impl forU8and anIntthe one forInt.
Capabilities
main receives exactly the capabilities its parameters declare, and an Args parameter receives the program's arguments: args.get(0) is the program path, and the arguments after -- follow. A capability is a runtime value that carries its attenuation state, so attenuation narrows authority at runtime as well as in the types.
Stdin,Stdout, andStderrreach the standard streams.Envreads environment variables;Env.secretwraps one in aSecret, which stays a wrapper at runtime, sodbgprints it as<redacted>.Clock.now_millis()is the time in milliseconds since the Unix epoch.Random.next_int()comes from a built-in pseudo-random generator (SplitMix64, not cryptographically secure), seeded from the clock and the process.Fs,FsRead, andFsWritegrant no file access themselves: they have no method that reads, lists, writes, or creates (a call of one isE0273), only attenuations. Code that needs the whole filesystem saysrestrict_to("/").restrict_toattenuates one to a directory capability,Dir,DirRead, orDirWrite, which carries a root and the same access.read_onlyandwrite_onlykeep the root and narrow the access, and a read or write the access does not allow is refused.restrict_to_fileonFsReadandFsWrite, or on aDirReadorDirWrite, attenuates to a file capability,FileReadorFileWrite, for exactly one file:read_to_string()andwrite(text)take no path, and no capability both reads and writes a file. Only attenuation makes a directory or file capability:maindeclaring one is refused by the checker (E0217).write(path, text)creates the file or replaces what it holds.create_dir(path)creates one directory, whose parent must exist. A directory already there is kept, with what it holds, so a program can rebuild into it; a file there is an error.list_dir(path)gives the names of a directory's entries, sorted by their bytes; a symlink is listed by its own name, and not followed.- Confinement.
restrict_to(dir)opensdiras the root, which must be a directory, and the directory capability holds it from then on. OnFs,FsRead, andFsWrite,diris trusted text, any path whose last component is not a symlink; earlier components are followed, since the root is trusted text. On a directory capability,diris aPath, which may come from untrusted text, walked beneath the current root as below, so a narrowing never widens. Attenuation never fails: a root that is missing, a symlink, not a directory, or outside the current root gives a directory capability that reaches nothing, and every operation on it fails with anIoErrornaming the root and the reason. A file passed torestrict_tois such a root: it is not a directory. - File capabilities.
restrict_to_file(file)opens the file's parent directory asrestrict_toopens a root (on a directory capability, by walking thePathbeneath the current root), and holds that handle together with the file's name. The file itself is not opened then, so aFileWritecan create it. Each use opens the name beneath the parent's handle without following a symlink, so a file replaced between uses can never lead outside: a symlink in its place is refused, and a parent swapped for a symlink is not met, because the capability holds the original. A parent that is missing, or beneath a symlink, makes every operation fail with anIoErrornaming the file; so does a name that is not a regular file. Every operation walks beneath the root.
read_to_string,write,create_dir,list_dir, and narrowing withrestrict_toresolvepathlike this, and a refusal is anErr(IoError):- An empty path is refused, and so is an absolute one, even one inside the root.
.is dropped, and..drops the name before it, on the path as written. A..that would climb above the root is refused;a/../bisb, even whenais a symlink.writeandcreate_dirneed a name of their own: the path as written must end in a plain name, not in a separator,., or...dang/andsub/.are refused, even though path libraries treat them asdangandsub.- On Linux and macOS, the runtime holds an OS handle for the root, opened when the filesystem was restricted and released with the last copy of it. Each component is opened relative to the one before, starting from that handle, and none is followed if it is a symlink, the last included (the
oska-syscrate, ADR 0009). A read opens its file beneath its parent's handle. A write opens or creates its file there, and empties it only once it is known to be a regular file, so a write never goes through a symlink and creates nothing outside the root.create_dirmakes its directory beneath its parent's handle, andlist_dirlists through the directory's own handle. There is no separate check to race: a symlink at any component, planted before the operation or swapped in during it, makes it fail. - Every symlink beneath the root is refused, dangling or not, whether it points inside the root or outside it. This is a change: before #94 and #95, a symlink that resolved inside the root was followed.
- A hard-linked file is refused on Linux and macOS. A hard link is the same file under a second name, so one planted beneath the root, such as in an upload directory, could expose or overwrite a file outside it. A read or a write that opens an existing regular file with more than one link fails with an
IoError(`name` has more than one hard link, and confined access never opens one), checked on the open handle before anything is read or the file is emptied. A write that creates a new file is unaffected. Windows does not check link counts. - A file that is not a regular one, such as a FIFO or a device, is refused rather than read or written.
Windows keeps a race.
stdhas no handle-relative open on Windows, so there each component is checked on the path not to be a symlink, the path is opened, and what was opened is checked again to lie under the root. That narrows the race but does not close it: a concurrent process that swaps an intermediate directory for a symlink at the right moment can still direct the open outside the root. The re-check then refuses to read or write what was opened, but a write may already have created an empty file there. Every name walked beneath the root is also refused on Windows when it is a reserved device name (CON,PRN,AUX,NUL,COM1toCOM9,LPT1toLPT9, with or without an extension, as innul.txt), which opens the device in every directory, or when it holds a:, which opens an alternate data stream.
Scopes and tasks
scope |s| { ... } runs tasks one at a time, in the order they were spawned, so the same program gives the same result on every run.
s.spawn(f)queuesfand returns a task. Nothing runs yet, so a task never starts in the middle of another task or of the block that spawned it.fsees the values it captured when it was spawned.t.join()runs the queued tasks of the scope, oldest first, up to and includingt, and returnst's result. Joining a later task first still runs the earlier ones first. Joining a task that has already run returns its result again.- A scope does not exit until all its tasks have finished: when the block ends, by its value or by a
return, the tasks still queued run in spawn order. A panic ends the program, so queued tasks do not run. - A scope handle is valid only while its block runs. When the block ends the scope is closed, and
spawnon its handle panics withP0005. A task can still be joined after its scope ends, because it has already run. - A panic in a task stops the program at the task, with the panic's own code and span.
Tests
oskac test file.osk checks the file, refuses it as oskac run would (by the checker's diagnostics, or by E0371 or E0380, for its functions and its tests), and then runs every test block in source order. A refused file runs no test.
- Stand-ins. Each test gets a fresh
t: TestContexton a fresh in-memory host, so nothing one test does is seen by another, and the same file gives the same report on every run:t.fsis an empty in-memory filesystem, with its working directory at/.t.clockis fixed at the Unix epoch:now_millis()is0.t.randomstarts from the same fixed seed in every test.t.stdoutrecords what it prints.t.printed()returns every line printed so far in the test, one perprintln, without its line break.
- Assertions.
t.assert,t.assert_eq,t.assert_ne, andt.failfail the test by panicking, withP0010toP0013and the span of the call. A failed assertion is a panic like any other: it stops the test, and its diagnostic is the test's failure. A panic from a bug, such asP0004, fails the test the same way. - Isolation. A panic stops only the test it happened in, runaway recursion (
P0021) included. Each test runs in a new interpreter, on a new host, on a thread of its own. - Integration tests. A test marked
#[integration]is skipped unless--integrationis given. Then it runs on the real OS:t.fsis anFsover the real filesystem (attenuate it like anyFs),t.clockthe real time, andt.randomseeded from the clock. Itst.stdoutis still recorded, not written to the terminal, sot.printed()works and the report is the only thing on stdout.
Report and exit codes
Without --format=json, one line per test, then the failures, then a summary:
test adds ... ok
test divides ... FAILED
test reads the real config ... skipped (#[integration]; run with --integration)
failures:
test divides: math.osk: error[P0011]: assertion failed: `actual == expected`: actual 2, expected 3 (bytes 120..141)
3 tests: 1 passed, 1 failed, 1 skipped
Each failure is named by its test. Control characters in test names and failure messages are escaped (\n, \r, \t, \u{..}), so a message cannot forge a line of the report.
Output. What a test writes to stderr, dbg included, goes to the real stderr as it happens, each line prefixed with the test's name in brackets, such as [divides] [dbg] 4. What it prints to t.stdout is recorded for t.printed(), for integration tests too, so the report is the only thing on stdout.
With --format=json, one JSON object on stdout. Each test has its name, its status (passed, failed, or skipped), and its failure, a diagnostic object or null. A refused file has no tests and its diagnostics in diagnostics:
{"tests":[{"name":"adds","status":"passed","failure":null},{"name":"divides","status":"failed","failure":{"code":"P0011","message":"...","span":{"start":120,"end":141}}}],"passed":1,"failed":1,"skipped":0,"diagnostics":[]}
| Exit code | Meaning |
|---|---|
| 0 | No test failed. Skipped tests do not count. |
| 1 | A test failed, or the checker refused the file. |
| 2 | The file uses something the interpreter cannot run yet (E0371, E0380). No test ran. |