The book

Appendix D · Stdlib effects catalog

This appendix summarises the effects the stdlib exposes. Reference material: when a function’s signature in the documentation says : Unit / X, you come here to confirm what X provides.

The full specification lives at github.com/kaikailang-org/kaikai/docs/effects-stdlib.md. Here we show the effect’s declaration and what it’s for.

D.1 Basic IO

Stdout, Stderr and Stdin

The three standard streams are separate effects, one per file descriptor. That granularity is what lets a test harness capture stdout without touching what goes to stderr.

effect Stdout {
  print(s: String) : Unit
  is_tty()         : Bool
}

effect Stderr {
  eprint(s: String) : Unit
  is_tty()          : Bool
}

effect Stdin {
  read_line()        : Option[String]
  read_bytes(n: Int) : String
  is_tty()           : Bool
}

print and eprint append a newline. Neither carries a failure type: under the default handler the common recoverable fault (the pipe closed on the other side, EPIPE) is absorbed silently, and anything left is catastrophic enough to panic. read_line returns None at EOF.

is_tty() answers whether the stream is a terminal, per isatty(3) on its own descriptor. It’s the standard gate for ANSI colour: emit escapes when Stdout.is_tty(), plain text under a pipe or a redirection. Env.get("NO_COLOR") covers the other half of that convention.

The alias Console = Stdout + Stderr + Stdin bundles all three when the distinction doesn’t earn its keep.

Env

effect Env {
  args()                               : [String]
  get(name: String)                    : Option[String]
  set_var(name: String, value: String) : Result[Unit, String]
  unset_var(name: String)              : Result[Unit, String]
  vars()                               : [Pair[String, String]]
}

Access to the command-line arguments (argv) and to environment variables, for reading as well as writing.

File

perm read
perm write

effect File {
  read_file(path: String)                    : Result[String, String]
  write_file(path: String, contents: String) : Result[Unit, String]
  open_read(path: String)                    : Result[FileHandle<read>, String]
  read_chunk(h: FileHandle<read>, max: Int)  : Result[String, String]
  open_write(path: String)                   : Result[FileHandle<read + write>, String]
  write_chunk(h: FileHandle<write>, data: String) : Result[Unit, String]
  close_file(h: FileHandle)                  : Unit
}

Two levels. read_file / write_file move the whole file in one go, which is what you want most of the time. The rest is the chunked path, for files that don’t fit or that you don’t want to load whole.

Note the <...> on the handles: FileHandle<read> and FileHandle<read + write> carry the capability in the type. A handle opened for reading doesn’t typecheck where a FileHandle<write> is expected, and the compiler decides that, not a runtime check. It’s the Perm kind from chapter 19 §19.9 doing its job. What the type states is what the code asked for at open time, not the permission the operating system holds over the file: that failure still rides each op’s Result.

For directories and metadata, see the fs modules.

Log

effect Log {
  debug(msg: String) : Unit
  info(msg: String)  : Unit
  warn(msg: String)  : Unit
  error(msg: String) : Unit
}

Leveled logging. The default handler writes each message to stderr as [ISO8601Z] LEVEL message; installing your own lets you capture or filter it.

A note for anyone arriving from chapter 12: there we declare our own, smaller effect Log { log(msg: String) : Unit } to teach the mechanism. There’s no conflict — a local declaration shadows the stdlib name within its file. If you want the four-level one, don’t declare it.

D.2 Time and randomness

Clock

effect Clock {
  wall_now()        : WallTime
  monotonic_now()   : Instant
  sleep_ns(ns: Int) : Unit
}

Two distinct clocks, deliberately. wall_now gives calendar time (WallTime is { secs, nanos } since epoch), which jumps when someone adjusts the system clock. monotonic_now gives an Instant that only moves forward: that’s the one you want for measuring how long something took. sleep_ns parks the fiber, not the thread.

Random

effect Random {
  int_range(lo: Int, hi: Int) : Int
}

A single operation, uniform over [lo, hi] with both ends included. The default handler seeds a PCG64 from the process clock and pid. Higher-level helpers live in the Random module, built on this one op. Not for cryptography.

SecureRandom

effect SecureRandom {
  int_range(min: Int, max: Int) : Int
  bytes(n: Int)                 : [Int]
}

Cryptographically secure randomness, deliberately separate from Random so that a test handler stubbing Random cannot weaken a security-sensitive path by accident. bytes(n) yields n bytes as integers in [0, 255]. The default handler bridges to the OS CSPRNG (getrandom / arc4random).

D.3 Network

NetTcp

effect NetTcp {
  connect(host: String, port: Int) : Result[Conn, String]
  listen(host: String, port: Int)  : Result[Listener, String]
  accept(l: Listener)              : Result[Conn, String]
  send(c: Conn, data: [Int])       : Result[Int, String]
  recv(c: Conn, max: Int)          : Result[[Int], String]
  recv_timeout(c: Conn, max: Int, nanos: Int) : Option[Result[[Int], String]]
  close(c: Conn)                   : Unit
}

Byte-level TCP sockets; the bytes travel as [Int] in [0, 255]. The blocking operations (connect, accept, send, recv) park the fiber via the runtime’s reactor, not the OS thread. recv_timeout returns None when the deadline expires.

NetUdp and NetDns

effect NetUdp {
  bind(host: String, port: Int)                    : Result[UdpSocket, String]
  send(s: UdpSocket, dst: SocketAddr, data: [Int]) : Result[Int, String]
  recv(s: UdpSocket, max: Int) : Result[Pair[SocketAddr, [Int]], String]
  close(s: UdpSocket)                              : Unit
}

effect NetDns {
  resolve(host: String) : Result[[IpAddr], String]
}

Same style as NetTcp. The alias Net = NetTcp + NetUdp + NetDns is useful when a function uses all three.

D.4 Processes and signals

Process

type Child = { pid: Int }
type Exit  = Exited(Int) | Signaled(Int)

effect Process {
  start(cmd: String, args: [String]) : Child
  wait(c: Child)                     : Result[Exit, String]
  kill(c: Child, sig: Int)           : Result[Unit, String]
  exit(code: Int)                    : Nothing
  start_piped(cmd: String, args: [String],
              pipe_stdin: Bool, pipe_stdout: Bool) : Result[Child, String]
  write_stdin(c: Child, data: String) : Result[Unit, String]
  close_stdin(c: Child)               : Result[Unit, String]
  read_stdout(c: Child)               : Result[String, String]
}

Spawn and control subprocesses. start forks/execs and returns a Child (an opaque handle carrying the pid); wait reaps it into an Exit, which explicitly distinguishes exiting with a code from dying on a signal. kill sends a raw POSIX signo.

exit(code) terminates the current process through _exit(2), so stdio buffers are not flushed: print everything you want printed before calling it. The op never resumes (it returns Nothing) and Cancel handlers do not run. For the ordinary case — exiting with a status — returning an Int from main is enough (chapter 16 §16.1), and that one does take libc’s full exit path.

start_piped is the popen shape: it attaches pipes to the child’s stdin and/or stdout, and from there write_stdin, close_stdin and read_stdout carry the conversation.

Signal

type Sig = SigInt | SigTerm | SigHup | SigUsr1 | SigUsr2

effect Signal {
  on(sig: Sig)  : Unit
  off(sig: Sig) : Unit
  await()       : Sig
}

Wait for a POSIX signal without blocking the rest of the fibers. on(sig) subscribes the process to sig; the runtime blocks the signal at the process level so the kernel queues delivery instead of applying the default disposition. await() parks the calling fiber until any subscribed signal arrives and returns the matching Sig variant. off(sig) unsubscribes.

The default handler is installed around main whenever Signal is in the row. Only one fiber may sit in await() at a time; a second concurrent call panics. Useful for orderly shutdown — Signal.on(SigInt); match Signal.await() { ... } replaces the typical hand-written SIGINT handler.

D.5 State

State[T]

effect State[T] {
  get() : T
  set(v: T) : Unit
}

The canonical effect for carrying mutable state in an encapsulated way. The syntax var name = init is sugar over a State[T] handle (chapter 12 §12.7).

Reader[T] and Writer[W]

effect Reader[T] {
  ask() : T
}

effect Writer[W] {
  tell(w: W) : Unit
}

Read-only environment (immutable configuration) and output accumulation (logging, tracing). Classical effect-calculus patterns.

Mutable

effect Mutable {
  array_make[T](n: Int, init: T)             : Array[T]
  array_length[T](a: Array[T])               : Int
  array_get[T](a: Array[T], i: Int)          : T
  array_set[T](a: Array[T], i: Int, v: T)    : Array[T]
  array_grow[T](a: Array[T], n: Int, init: T): Array[T]
  ref_make[T](init: T)                       : Ref[T]
  ref_get[T](r: Ref[T])                      : T
  ref_set[T](r: Ref[T], v: T)                : Unit
}

The effect behind Ref[T] and Array[T]. Follows the discipline of observable effects (chapter 12 §12.7): an array_set requires Mutable in the row only when the mutation is visible to the caller. An array created locally and returned doesn’t require Mutable.

array_set and array_grow return the Array[T] rather than Unit. Not because they copy: they hand back the same array so Perceus (appendix B) can reason about the last use and decide whether to mutate in place. Chaining the operation is idiomatic.

D.6 Errors and control

ReadFault

effect ReadFault {
  bad_chunk(msg: String)  : Unit      # resumable: skip and go on
  open_fault(msg: String) : Nothing   # abort-only
}

The recoverable fault of streamed reads, declared in stdlib/stream.kai. It is the only failure-shaped effect the stdlib declares, and its design explains why: bad_chunk returns Unit, so a handler that resumes drops the bad chunk and continues (skip policy), while one that abandons the continuation aborts. open_fault returns Nothing: a stream whose source cannot open has nothing to resume into.

ReadFault carries no default handler. A consumer that forces the stream must pick a policy with handle ... with ReadFault, or the typer reports effect not handled: ReadFault. The abort path still releases the producer’s handle: read_lines and write_lines bracket the file in a handler carrying initially / finally (chapter 12 §12.8), so close_file runs on the unwind even though the fault jumps clean over the read loop.

Cancel

effect Cancel {
  raise() : Nothing
}

Cooperative cancellation. The scheduler injects Cancel.raise() into a canceled fiber at the next yield point. The fiber can install a Cancel handler for cleanup (chapter 13).

Fail: retired from the stdlib

Fail was a stdlib effect (fail(msg: String) : Nothing) with a default handler that printed a banner and exited 1. As of kaikai 0.106 it is gone. The removal ratifies what the stdlib already practiced: every fallible API returns Result and propagates with postfix !. Not one / Fail row was left across the stdlib.

What to reach for instead, by what you need:

You needUse
Inspectable failureResult[a, e] with postfix !
A failure whose policy the consumer picksa domain effect whose op returns Unit, so the handler can resume and skip
Deep non-local exitCancel.raise() : Nothing
Programming errorpanic

Fail remains a good teaching example of an operation that returns Nothing, which is why chapter 12 declares it locally in several examples. A Fail you declare yourself carries no default, so an unhandled fail is a compile error (effect not handled: Fail) rather than a runtime abort. That’s the whole practical difference.

D.7 Concurrency

Spawn

effect Spawn {
  yield()                       : Unit
  spawn[T](thunk: () -> T / e)  : Fiber[T]
  await[T](fiber: Fiber[T])     : T
  select[T](fibers: [Fiber[T]]) : T
  cancel[T](fiber: Fiber[T])    : Unit
  set_trap_exit(on: Bool)       : Unit
  scope_enter()                 : Unit
  scope_exit()                  : Unit
}

Create fibers, await them, race them, yield, cancel. nursery { n -> ... } from chapter 13 is sugar over handle ... with Spawn as n { ... }, and the last two operations are the machinery behind that sugar: scope_enter and scope_exit delimit the scope that joins the children. set_trap_exit decides whether a fiber receives a linked peer’s death as a message instead of dying with it (chapter 14 §14.6).

Actor[Msg]

effect Actor[Msg] {
  self()                        : Pid[Msg]
  send(pid: Pid[Msg], msg: Msg) : Unit
  receive()                     : Msg
  receive_timeout(nanos: Int)   : Option[Msg]
}

The effect underlying chapter 14’s actor model. with_mailbox { ... } and spawn_actor(...) are the stdlib wrappers that install this handler. receive_timeout returns None when nothing arrived within the deadline, which is what keeps an actor from hanging on a message that will never come.

effect Link {
  link(peer: Pid[Nothing]) : Unit
}

effect Monitor {
  monitor(target: Pid[Nothing]) : Pid[Nothing]
  demonitor(ref: Pid[Nothing])  : Unit
}

BEAM-style supervision (chapter 14 §14.6). Links are bidirectional: if one falls, the other receives Cancel.raise(). Monitors are unidirectional: the observer receives a MonitorDown message when the observed actor terminates.

D.8 Interoperability

Ffi

effect Ffi {}

The effect carried by every function declared with extern "C" fn. No operations of its own: it’s a marker so the type system knows which functions touch code not audited by kaikai. Chapter 16 §16.12 covers the declaration syntax, type mapping at the boundary, linking with C shims, and what FFI v1 does and doesn’t support.

D.9 Composition: the Console and Io aliases

type Console = Stdout + Stderr + Stdin
type Io      = Console + Env + File

Two levels of bundling. Console gathers the three standard streams; Io adds the environment and files on top. A function that says / Io declares it can touch the console, read env vars and manipulate files: the equivalent of “this function is not pure, it does things with the system”.

Note who is not in Io: Clock, Random, SecureRandom, the Net family and Process are kept out on purpose. A function that “logs and reads config” shouldn’t silently gain the capability to reach the network or spawn subprocesses just because both live under one convenient name. Those effects appear explicitly in the signature or not at all.

D.10 Default handlers

When main declares any of these effects in its row, the runtime automatically installs a default handler:

  • Stdout, Stderr, Stdin, Env, File → IO to the system.
  • Clock, Random, SecureRandom → system clock and RNG.
  • NetTcp, NetUdp, NetDns → POSIX sockets.
  • Process, Signal → POSIX calls.
  • Log → each message to stderr as [ISO8601Z] LEVEL message.
  • Mutable → real heap assignments.
  • Spawn, Cancel → the runtime’s fiber scheduler.

These handlers can be intercepted: any handle ... with X { ... } the user installs wins over the runtime’s handler for the duration of the body block. That’s what enables mocking in tests, capturing output, simulating the clock, etc.

← Back to contents