Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Effects, and the event loop

Every program in this book so far computed a value. This chapter is about programs that do things — print, wait, read a file, open a socket — and about the machine that lets them do those things without threads. Bend calls it IO.

One honest note before starting. The guide’s prose on IO is thin, and its own effects note (bend guide effects) says of itself that an AI wrote it and a human will revise it. So this part of the book came from probes rather than from reading: fx/ in this repository holds them, and its READMEs are the full tables. What follows is what they measured.

The shape at the seam

You have written do IO<Unit>: blocks since the first chapter; this is where their rules were pinned down. Each is a corner where the pure language meets the loop, and each one cost a compile cycle while the probes were written:

what you writewhat you get
match inside a do blocka match heads a def body, not a term — pull the match into its own def
a let without its type (path = "...")a parse error that points at the next line
a block that ends with a bind (x : T <- m)expected : a term / observed : end of input — end with a Unit step or return
f(g(x)) where g is an effecta type error — g(x) is an IO(..), not a value; bind it with <- first

None of these is deep. The fourth is the one that generalises: an effectful call is not a value, so it can never be an argument. Every IO needs its own line.

The event loop

The guide states the model in one sentence:

A Bend program is a set of computations interleaved by one event loop, as in Node.js: each runs its pure code (in parallel, on every core) up to its next effect, and one that waits on a socket, a sleep or a channel steps aside for the others.

The probes give that sentence numbers. Two computations, each sleeping 700 ms, forked and joined:

c1 : Chan(U32) <- IO.fork(U32, nap(700))
c2 : Chan(U32) <- IO.fork(U32, nap(700))
r1 : U32 <- IO.join(U32, c1)
r2 : U32 <- IO.join(U32, c2)

Measured: total ms=709. One sleep’s worth of wall clock, two sleeps’ worth of work — the loop really does overlap them, with no threads in sight.

Then the sentence’s second half — “the program ends when every computation is done, or reports a deadlock when the remaining ones all wait” — has a case the guide does not spell out. Main returning is not the program ending.

what the probes dowhat happens
IO.spawn a task that sleeps 300 ms, then return from mainthe program waits for it: spawned ran after 300ms, exit 0
fork a task that waits on a channel nobody will ever send to, then return from mainprints nothing else, then bend: deadlock: every computation waits on a channel, exit 1

The second one is the surprise worth keeping: a leftover task turns the whole exit into a deadlock report — the runtime refuses to exit quietly while someone is still waiting. And it prints the same line when everyone is stuck; the two situations are not distinguished. Both exit 1.

Channels

Fork and join are built on channels, and the whole state table is small enough to pin down completely:

operationstateresult
Chan.sendroom availabletrue
Chan.sendroom fullblocks until a receiver appears
Chan.sendchannel closedfalse
Chan.recvvalue availableSome(value), FIFO order
Chan.recvclosed and emptyNone

Two rows deserve a second look. A full room blocks; it does not refuse. The probe sends twice into a room-1 channel with nobody receiving; the first send answers true, the second never returns, and the program ends as the deadlock above. And closed is not an error: send on a closed channel answers false, recv answers None, and the program continues.

IO.join is the pair done for you: one recv, one close. That makes it sharper than it first looks.

+ch : Chan(U32) <- IO.fork(U32, one())
r1 : U32 <- IO.join(U32, ch)
r2 : U32 <- IO.join(U32, ch)     # the channel is closed now

With the channel marked +, both joins compile, and the second one dies at runtime: IO.join: the channel was closed, exit 1. Without the +, it does not even compile — consumed more than once — which is the affine rule from earlier chapters arriving on schedule. A join consumes the channel; a consumer that wants more values uses Chan.recv directly.

Things that fail

An effect can fail, and Bend makes that a value: the effect answers Result<&1, &1, U32 & String, A> — an error code and message, or the answer. IO.try unwraps it or exits with the error. The exit codes are the machine’s:

probestderrexit
open a file that is not thereNo such file or directory2 (ENOENT)
connect where nobody listensConnection refused61 (ECONNREFUSED)

The message is strerror and the code is the errno — an exit code is the interface a shell sees, so it is the one the runtime keeps exact. When you want your own, IO.die(A, code, msg) exits with your code and prints your message:

$ bend die.bend
before die
$ echo $?        # 7
custom failure message          # on stderr

and the statements after the die do not run.

A file, up close

File is the effect family worth reading closely, because every fallible effect works the same way: the handle is affine, so every effect on it hands the handle back beside its result. A read is (File & Result<&1, &1, U32 & String, String>) — the file, and what happened.

The unwrap worth memorising, because it is the shape you will write a dozen times:

def r_done(m: File & Result<&1, &1, U32 & String, String>) -> IO(Unit):
  (f, r) = m
  do IO<Unit>:
    s : String <- IO.pass(String, r)
    IO.print("read back: " ++ s)
    File.close(f)

Destructure the pair in a parameter position; IO.pass the Result. This is the same bargain as arrays, for the same reason — one owner at a time — and it buys the same thing: the compiler catches handle misuse. A read after close is not a runtime surprise; it does not compile.

The full roundtrip — write, size, close, reopen, read — is one probe, and it prints size=11, read back: hello, file. The errors are probes too: opening a missing file exits 2; that is the whole File story that matters before you write your own effects.

Two loose ends

IO.args answers the command line, minus the runtime’s own options — and -- is how you draw that line yourself. bend args.bend alpha beta -- gamma passes alpha, beta, gamma: the -- stops Bend’s own option parsing and is not itself passed. argc=3.

The window, audio and graphics effects (App.run and friends) are out of scope here: this book’s probes all run headless. They are the one branch of the effect tree left untested by it, and they are honest about why.

Running it

cd fx/io
bend deadlock.bend          # ❌ the deadlock message, rc 1
bend sleep_par.bend         # ✅ total ms=709
bend close_recv.bend        # ✅ send/recv/close semantics
bend join_twice.bend        # ❌ IO.join: the channel was closed, rc 1

Next: writing an effect of your own.