What's changed
Release by release
Every change, as it landed. A release names what it gathers; the entries under it are the steps, newest first, each written when it was made.
Since 0.13.0
not yet released · 5 changes
changeexamples that are tests, on every function of the library
A comment line indented four spaces past the # is an example, and after # => it says what show prints of its value. rill doc shows it as code; rill doctest runs every one — as a program written beside the module that imports it by its name, so an example reads as a user would write it, with ? allowed, an import line hoisted, and a # not run block shown and left alone — and holds it to its answer, naming the comment's line when it is wrong. Every public function of the standard library's forty-three modules now carries one, 1,122 of them checked by the test suite and shown on the reference pages, and the helpers that were never an interface are private now, so a page shows what a caller may call: 989 functions where there were 1,497. Two things were found on the way: cli/args aligned its --help line apart from the others, and Ok(()) still stops codegen at "ADT fields are never Unit", which is a bug for another day.
The reference has a chapter on the grid library — what a grid is, how one is made, read, reshaped, combined, masked, reduced, sorted and solved, what makes it fast, the twelve benchmarks against NumPy, and the Python binding — every example of which is run by rill doctest when the page is built.
changethe editor moves out
The model editor is a repository of its own now, rillsdl: its server, its page, its forms and their tests, with a Dockerfile that builds on the released toolchain rather than on the compiler's source. What stays here is what it needs of the compiler — rill diagram --json, the trace, rill lsp, the sandbox — and tools/Dockerfile, which builds the Linux toolchain the editor's image fetches.
changepackaged
tools/dist.sh packages a toolchain that needs nothing but a C linker: bin/rill with its runtime, the wasm runtime and libc under lib/rill/, the standard library under lib/ — which the compiler now finds beside its own bin/, so a packaged toolchain needs no RILL_PATH — the examples and the reference, as rill-<version>-<os>-<arch>.tar.gz — the model editor stays out of it and runs at rillsdl.baltavista.com. On a Mac the Homebrew zstd the compiler links travels with it and the binary is signed again. tools/dist-linux.sh builds the Linux x86-64 one in Docker, a dist stage of the editor's Dockerfile. Both are on rill.baltavista.com/dl. The wasm runtime had stopped compiling on two 64-bit-only spots — the virtual clock's start and the free-list mask — and does again.
changea sandbox
--sandbox on rill build, run, test and check refuses a program that would reach past the process: the four things a Rill program can do that the language cannot account for are now named capabilities — files (reading, writing, listing, renaming), net (sockets), ffi (extern fn, asm fn, raw memory through Ptr) and os (waiting on the process's signals) — and --sandbox=files,net allows the ones named. The question is answered before the build, over the checked program: every function reachable from where the run starts — main and the exports, or the tests under rill test — is searched for a runtime or foreign call that needs a withheld capability. A library that would need one is fine to import and fine to leave uncalled; the call is what is refused, and the error lands on the author's own line even when the call is the library's, saying what it was reached through. The console, the clocks, strands, channels, random numbers and sleeping stay open, since none of them reaches past the process; time and memory are the host's to bound.
The model editor's server uses it: opened to the network with --host, it runs every scenario and check sandboxed, --allow files,net names what a program may reach, --allow all trusts whoever can reach the server, --sandbox puts the sandbox on at home too; what a run says names the file by its path under the root, not the machine's; and a main is built into the server's scratch directory rather than left as a binary beside the model. editors/web/deploy/ publishes it: a two-stage Dockerfile that builds the compiler with LLVM 21 and keeps only what runs, a compose file that bounds memory, processes and the filesystem, the nginx site, and --static on the server so the page comes from the image and the models from a volume. It runs at rillsdl.baltavista.com.
Every compiler-facing request — the model, the check, the format, the scenarios, a run, an edit — can bring the text it is about, and is then answered from a working copy of that text, one per request, gone after: what the page has is checked, drawn and run as it stands, and nothing is written. On that the server has --protect a.rill,b.rill, the examples everyone starts from — edited and run as they stand, never saved over, a copy kept under another name with "Save as…" — and --readonly, which saves nothing at all: the page hides Save and New model, every edit lives in the buffer, and that is how the public server runs until there are accounts.
changethe diagrams build without the code being written
An input, after or when arm opens in a form now, and the dashed + input symbol starts one: the signal picked from the file's, or _ for any other, a guard typed if there is one; the actions added a row at a time from what SDL's symbols are — an output to a mailbox the machine holds or sender, a reply, a timer set or reset, a send on a channel, a task as a line of code — each signal picked by name, its arguments typed against the field names the type declares; then the next state picked from the machine's with its fields, or stay, stop, done. A save and a priority are the same form with no body: the kind picked, the signal's shape, a guard. The lines the arm will be written as show under the form as it is built, and Save hands them to the same edit the box of lines makes. An arm the form cannot say — an if or a match in the body, a binding — opens as code, and the "as code" button gets there from any form.
A route of a system opens in a form too, and + route and the new system button start one: from and to picked from env and the machines, one way or both, the signals ticked off the signal type. A signal and a state open in a constructor form: the name, and fields added a row at a time with their types picked from the language's, the file's and the shapes a model uses; a state's form changes its constructor in the state type and its state line together, and deletes both.
And a scenario is built too: + scenario on the Simulate bar makes a test_ function from steps — an instance spawned, its machine picked, its channels and mailboxes named and its first state chosen; a signal posted to an instance; what the environment receives expected, as a match that fails on anything else; a wait; a line of code — and edit opens the selected scenario back in the form when it says what the form can, as code otherwise, main always as code. What is still typed is what is an expression — a guard, a condition, a constructor's arguments, a task — so a model is drawn, built, and run from the diagrams, the code read beside them. The Simulate view shows the machines a tab each, the tab naming the state its machine is in as the run plays and underlined when the machine moved last; the run brings that machine's tab to the front unless a tab was picked by hand, and "follow the run" gives it back. The files on the left and the code on the right slide shut and open — a button at the right of each pane's own header shuts it, an upright ear on the edge brings it back, as does a click on the handle, which still drags to resize — and stay as they were left. Run has its green play. editors/web/test/forms.js checks the forms in Node, no browser: every arm, save, priority, route and scenario of the examples read into a form and written back as it was, and what each form writes from what it holds; the Rust suite runs it where there is a node.
0.13.0 — the model editor
0 changes gathered
The release where the models got an editor in the browser, with its server written in Rill on the standard library. The page shows a file's system as the block diagram, channels routed as SDL drew them, and each machine as a process in SDL's symbols, beside its coloured code, every symbol a click from its line. The diagrams edit: a route, an arm, a state, a save or a priority opens in a box with its own lines, the dashed symbols and the edit bar add a state, an input, a signal, a machine, a system, and everything can be deleted; every edit is lines replaced in the file, kept only if the result still parses and written back formatted, so the code stays the model. The Simulate view runs a scenario traced on the virtual clock and replays it, the sequence chart growing event by event while the diagrams light the state and transition each machine is at. Hover shows a type and ⌘-click a definition, from a rill lsp kept alive beside the server, which listens on this machine alone. Zoom, splitters, a new model from a template, the models alone in the list.
And rill doc: a module's documentation read off the file, from the comments its declarations already carry and the signatures the checker infers, as Markdown, a page a module. Under both: rill diagram --json, rill test passing a scenario's trace through, tcp_listen_at, listen_at and serve_at. The sections below this one tell each of them properly.
0.12.0 — a modelling language
0 changes gathered
The release where machine became what SDL was: a process with a mailbox, drawn as a diagram, checked as a model, and run as a simulation. Named timers that survive a state change, save for the signal a later state answers, state * for the arms every state has, stay and done(v); a Mail that says who sent it, so every arm has sender and reply, and output and post say who is asking; a mailbox() that refuses nothing, as SDL's input port never did; priority for the signal that goes first, when for the transition taken with no signal, and save … if for the input with a condition; a machine called inside another's transition as a procedure with states.
Around the machines, the tools that made SDL a modelling language. system declares the block diagram — the routes between the kinds of machine and the environment with the signals each carries — and the compiler refuses a signal sent or answered that no route explains. rill check reports what each state drops. RILL_TRACE=1 makes a run say what every machine did, and rill msc draws it as the message sequence chart, in Mermaid or as a picture; rill diagram draws the system and each process in SDL's symbols. RILL_VIRTUAL_TIME=1 runs a model on the clock a simulator keeps, so timers fire the moment nothing else can happen and a run is the same every run. examples/abp.rill, the alternating-bit protocol, uses all of it, with tests; Chapter 17 of the book walks through it. The sections below this one tell each of them properly.
0.11.0 — a standard library, and a machine
0 changes gathered
The release where lib/ became a standard library: twenty-one modules in a day, each a module with its use at its head, its helpers private, and its own tests against the published vectors where there are any. Hashes to sign with (crypto/sha2) and sums to check with (data/checksum); CSV; DEFLATE both ways with gzip and zlib around it; the environment, a command run, a log; text laid out and numbers spelled; a set, a heap and a deque; UUIDs; URLs, media types, forms; datagrams, names through the resolver, and TLS through OpenSSL. The prelude gained the list words programs kept rewriting — find, zip, partition, group_by, a stable sort — and eprintln; the language gained (), the value of Unit, and float_bits for the bits of a float.
machine is the other half: a state machine with a mailbox written as one, its states the constructors of a type, each state answering its signals as the arms of a match, after ms -> for the silence — lowered to the tail-recursive loop over recv and match a strand doing this is anyway — and rill diagram draws one from its declaration, as Mermaid or as the SDL process diagram in its own symbols.
Writing the libraries found four bugs the compiler and runtime had kept: a blocking extern called from a spawned strand got its answer written into another strand's frame, since the slot was on a stack that is copied away while parked; a field of a generic record handed to a tail call crashed codegen; the formatter dropped the parentheses around a match in a then branch; and the ring check refused a part of a value as the value. Each is fixed with a test. The sections below this one tell each of them properly.
0.10.0 — a library to reach for, and a compiler that says why
77 changes gathered
The release where Rill got a standard library worth the name, and a compiler that explains itself. lib/ went from a handful of files to nine folders: strings, a key–value store that reads ahead of SQLite, arrays with a shape that NumPy's notation sits on, an HTTP server with middleware, regular expressions, dates, paths, random numbers, a program's own command line, decimals and big integers — each a module that says its name on its first line, keeps its helpers private, and carries tests that rill test runs. The sections below this one tell each of them properly.
The type system learned the things a program stumbles on in its first week: a field names its type, a field every constructor has can be read without a match, a signature written down is taken at any type so a function may recurse polymorphically, ? works on an option and carries an error across layers, a branch keeps what it binds, and a trait can have defaults, supersets, a where said in the signature, a method that belongs to it by name, an alias, and an object made from it. Integer arithmetic says what it does at its edges, and a \u escape is a character.
Around the language, the tools were opened up. rill explain reports what codegen decided about memory, line by line. rill check --parallel refuses storage two strands could write. A ring is refused where it is made. An extern that blocks is blocking, and a while gives way. The compiler builds on LLVM 21, releases what it drops in place, and reaches the instructions the machine has that C has no name for — it is level with or ahead of the C on every benchmark in benchmarks/c/.
change`rill doc`
A module's documentation, read off the file: the comment above a declaration is its documentation and the block after module the module's, as every module in lib/ was written, so nothing had to be rewritten to be documented. rill doc file.rill gives Markdown — the head, the types with their constructors, the traits with their methods, the systems and machines with their routes, states and diagrams, the functions with the signatures the checker inferred, private ones left out — and rill doc lib -o docs/lib a page a module. Tuples are shown as (a, b) in every signature now, not as Tuple2.
changethe model editor starts a model, and says where its edits are
New model above the file list makes a file from a template — a signal type, a state type, a machine, a system, a main and a test — and opens it. An edit bar above the diagrams says what a click does and adds what a click cannot: + signal into the signal type, + machine with a state type of its own at the end of the file, + system with a first route. The problems list has a height of its own, dragged by a handle, and an icon per kind: error, warning, note. Everything on a diagram can be deleted: a state opens in its box with its whole block and Delete takes the constructor out of the state type with it; edit beside a machine's or a system's name opens the whole block, to change or delete. The file list shows the models — the files with a machine or a system in them — and every .rill file on request.
The diagrams zoom: −, 100%, + beside the tabs, ⌥ with +, − and 0, and ⌘ with the wheel over a diagram; the size is kept between visits. Coming back to the Model tab from Simulate shows the model again, which it did not; the code view's selection is translucent, so a selected line still reads; and the Simulate view says what it is for before a run.
The editor's server keeps rill lsp alive beside it, on two named pipes, and one strand asks it questions one at a time: hover on the code shows a name's type, ⌘-click goes to its definition, both on the text as the page has it, saved or not. And it listens on 127.0.0.1 alone: tcp_listen_at(host, port) joins the runtime, listen_at the prelude and serve_at the HTTP library, for a tool that runs programs on request and is for the machine it runs on.
changethe model editor edits
The diagrams are editable. A route on the block diagram, an arm, a save or a priority on a process diagram opens in a box with its own lines, to change or delete; a dashed + input at the end of each state and + state at the end of each machine add one — the new state's constructor going into the state type and its state with a first arm at the end of the machine. Every edit is a range of lines replaced, which the server keeps only if the result still parses and writes back formatted, or answers with the parser's reason, shown in the box. The code is the model, so what the box holds is code. rill diagram --json no longer needs the program to type-check, so a diagram stays drawn while a mistake on it is being put right; the check's errors are listed under the code meanwhile.
changethe model editor simulates, and colours its code
The code view is coloured: comments, strings and their escapes, numbers, the keywords and the machine's own words, constructors and types by their capital, a call by its parenthesis — a small tokenizer of its own, with the same classes the VS Code grammar names, laid behind the text as it is typed.
The editor's Simulate view runs a scenario of the file — main, or a test_ function — on the server with the trace on and the virtual clock by default, and replays it: the sequence chart grows event by event, and on the process diagrams the state each machine is in and the transition it is taking light up, with play, pause, step and a slider to any moment. The program's own output sits below. rill test passes a scenario's trace through as it came, so a test is a scenario.
changethe model editor, first view
editors/web/ is a page that shows a file's system as the block diagram and each machine as SDL drew a process, beside the code: every symbol carries the line it was written on, so a click on it finds the code and the cursor's line lights the symbols written there. The notes of rill check sit on the states as badges and under the code as a list. Its server is written in Rill on the standard library — web/http serves, cli/proc runs the compiler, web/mime, data/json, text/path and cli/args do the rest — and keeps every path inside its root. Under it, rill diagram --json gives the model as data with the lines, and the block diagram's channels are routed as SDL drew them, up, across in a lane of their own, and down, in the page and on the command line alike.
changethe alternating-bit protocol, and a chapter
examples/abp.rill is the model that uses everything: a sender with a retransmission timer, a medium that loses what it is told to, a receiver that delivers once and acknowledges always, a system the compiler checks, save and priority, mailboxes, and three tests that run on the virtual clock. Its first version hung, and the chart said why at event 16. Chapter 17 of the book walks through it: the process as SDL drew it, and every tool around it. A timer's signal is now answered without a route, since it comes from the machine itself.
changethe block diagram
system name declares the routes between the kinds of machine and the environment, with the signals each carries — env -> line: OffHook, Digit, line <-> line: Ring — and the compiler checks it against the machines: a signal sent that no route carries, a signal answered that none brings, a route to nothing, a signal that does not exist, each refused by name. rill diagram draws it as SDL's block diagram, in Mermaid or SVG, ahead of the machines; rill fmt writes it back. With it the SDL list is closed: structure, process, timers, save, priority, conditions, procedures, the trace, the charts, and a clock of its own.
changethe clock a simulator keeps
RILL_VIRTUAL_TIME=1 runs a program on virtual time: it does not pass while anything runs, and when every strand is waiting and the soonest wait is a timer, the clock jumps to it and the strand wakes. The exchange's after 50 fires the moment nothing else can happen, an hour's sleep and a day's deadline take none, and the trace of a run is the same every time — the exchange in 5 ms instead of 58, identical three times over. Sleeps and select deadlines go into a table in the runtime instead of the poller; sockets and syncs wait as they did.
changethe sequence chart as a picture
rill msc --svg draws a trace as SVG — a lifeline per machine and per mailbox of the environment, an arrow per signal with a timer's coming back to its own line, a note per state change, drop, timer, when and stop — for a page or a document that has no Mermaid. The Mermaid form and the picture are drawn from one list of arrows and notes.
changea signal that goes first, and a machine that answers
priority Sig in a state answers that signal before any that came earlier — SDL's priority input: what has arrived joins the pending list and the priority one is taken from it first. done(v) at an arm's tail ends a machine with a value, so a machine called inside another's transition on the same mailbox is SDL's procedure with states, and the caller goes on with what it answered; rill types shows the machine's type as -> Bool, and the diagram draws done(v) at the stop mark.
changea transition with no signal, and a signal that waits
when cond -> body in a state is SDL's continuous signal: taken with no signal at all, when the machine is in the state, nothing pending can be answered, and the condition holds; a signal already in the mailbox is answered first. save Sig if !c before an arm for Sig is SDL's input Sig provided c, and was already there: the signal waits until the condition holds. Both drawn by rill diagram, and the trace says when with the condition.
changea mailbox that refuses nothing
mailbox() is a channel whose buffer grows rather than making a sender wait — SDL's input port, which never refuses a signal. Two machines sending each other signals at once, each with the other's buffer full, held each other up for ever on channel(16); on mailboxes both finish. The channel carries its buffer as a pointer now, inline for one made with a size and its own block for a mailbox, doubled when full. The exchange example makes its mailboxes this way.
changea run's trace, drawn
RILL_TRACE=1 makes every machine say what it did: one line an event on standard error — received, in what state, from whom; the next state; sent, to whom; a timer set or reset; saved, dropped, an after run out, a stop — numbered by the runtime in the order events happened across every strand, each machine named by its mailbox's number. rill msc draws the lines as the message sequence chart SDL's tools drew from a simulation: a lifeline per machine and per mailbox of the environment, an arrow per signal, a note per state change. The state of every machine at every moment is in it, which is the read-out from outside a Report signal used to be for. Off, a machine pays one load per event. tracing(), trace_line(s) and chan_id(ch) are the builtins under it, and a program may use them for a trace of its own.
changewhat a machine drops, said
rill check reports a machine's implicit consumption, as SDL's tools did: for each state, the signals some other state answers and this one neither answers nor saves, with the line; and once per machine, the signals no state answers at all — not inputs of this machine, or a mistake in every state. A state with _ -> stay drops nothing. The notes come from check and the language server, not from a build, since dropping is what a process does by default. The checker carries warnings on the typed module for this, the first it has had.
changea machine knows who is asking
What a machine receives is a Mail now: the signal, and the mailbox it came from. So every arm has sender, reply(sig) answers it, and output(to, sig) sends to any mailbox with the machine as the sender — a signal that wants an answer no longer carries a channel, and Ring is Ring, with Ringing(sender) the state it leads to. From outside a machine, post(to, from, sig) names the mailbox the reply comes to, because in SDL the environment is a process too. A machine's mailbox is a Chan(Mail(Signal)); a timer's signal comes from the machine itself, and a saved signal keeps its sender. The exchange example is written the new way, and rill diagram draws a reply as an output to sender.
changethe arms every state answers
state * in a machine holds the arms every state answers — the report of where it is, the signal that stops it, the after for a silence — put after each state's own so the state's own win, and stay at an arm's tail is the state the machine is in, SDL's nextstate -. A state whose arms then answer every signal has no "any other" arm, and the one the lowering writes is left out rather than refused as unreachable. Drawn as a state of its own, * (every state).
changea machine's timers, and the signal it keeps for later
Two of SDL's a machine did not have. Named timers: set(Retry, 200) in an arm starts one that delivers Retry in 200 ms — a timer is named by its signal, may carry a value, is moved by setting it again, stopped by reset, asked after by active, and keeps running across states, which is what after could not do; when it runs out its signal is answered by whatever state the machine is in. save Sig in a state keeps a signal for a later state instead of answering or dropping it: the saved wait in the order they came, and each state takes the first it does not save before reading its mailbox. A machine with either carries a table and a list as parameters no program can name, and is two functions, the one declared and its loop; one with neither is lowered as before. rill fmt writes both back, rill diagram draws a set or reset as a task with the hourglass and a save as SDL's notched symbol, and rill types no longer lists the loop.
change`rill diagram`
A machine drawn from its declaration: rill diagram file.rill prints Mermaid's stateDiagram-v2 — an arrow per transition, labelled with the signal, its guard and the signals sent on the way — and --svg the process diagram SDL drew, in its symbols: the state, the input with its notch, the output with its point, the task, the decision, the hourglass of a timer, the stop mark; a column per signal under each state, laid out top-down, since that shape is a tree and needs no arrows routed. The program is checked first, so what is drawn compiles.
changea machine: a state and a mailbox
machine is a state machine with a mailbox, written as one: the states are the constructors of a type, each state lists the signals it answers as the arms of a match — patterns, guards, a body whose value is the next state or stop — and after ms -> is the arm taken when no signal has come for that long. A signal no arm names is consumed and the state kept, as the telephone exchanges' SDL had it, unless _ -> says otherwise; a state of the type left out is refused by name. The parser lowers it to the tail-recursive function over the mailbox and the state that a strand doing this is anyway — recv, or select with a timeout, then a match — so it costs what that costs, rill explain shows the tail calls, and rill fmt writes the declaration back. The machine is started with its initial state as its last argument: spawn line(a, log, Idle). examples/exchange.rill is an exchange of lines.
changethe bits of a float
float_bits(x) and bits_float(n) are a float's 64 bits as an Int and back: a bitcast, no instruction on the machine. A program that stored a float in a key or a page had been writing it into an eight-byte buffer and reading it back as the other type — an allocation and two stores for every real read off a page, which was a fifth of a sum over 800k keys.
changea part of a value is not the value
The ring check took one relation for two: the names a value holds and the names it is inside. So f.subs[id] = Sub(f.db) — one field of f into a map that is another field of f — was refused as f holding itself, and so was a group read out of a map with map_or and written back with its tally, both of which sqlbite does on every statement. The check keeps three sets now — what a value may be, what it may reach, and what it may be reached from — and a projection (r.f, m[k], a match binding, a function's answer that is part of its argument) reaches nothing its base does not and is not its base. A ring is m[k] = v where something v reaches is something m is inside. A field read from a name is a name of its own to it, so n.links[k] = Node(n.links) is still seen, and named as n.links. The sharing check is as it was: a strand writing a field of r is writing r.
changeTLS, and a blocking call's answer that went astray
web/tls is TLS over a socket through OpenSSL 3 — Homebrew's on macOS, libssl on Linux — with a socket's own shape: connect verifies the chain against the system's roots and the name against the host and says what did not check; connect_insecure is for a certificate you signed yourself; read, read_all, write, disconnect; listen with a certificate and key and accept for the other side; get and ask for the one request. Every wait is a blocking extern. Secure Transport was the plan for macOS and is not the choice: it wants read and write callbacks, and a call made off the worker cannot take one into Rill.
Writing it found the bug: a blocking extern's arguments and answer went through a slot on the strand's frame, and a parked spawned strand's frames are copied away — so the helper thread's answer landed in whichever strand was running there, and the caller read a zero. The main strand's stack is never copied, which is why a client alone always worked and a server in the same program never did. The slot is copied to the heap for the helper now and back after, the rule the request itself already kept. Every blocking extern called from a spawned strand — cli/proc, net/dns, a sleep — had the same hole.
changeforms, datagrams and names
web/form reads what a <form> sends: urlencoded pairs, or multipart/form-data as parts, each with its field name, its file name and content type if it was a file, and its bytes as they were. net/udp sends and receives datagrams whole, with the sender's address on the ones that arrive; a receive parks the strand on the poller as a TCP read does. net/dns resolves a name through the system's resolver, IPv4 first; the lookup is the one wait the runtime never allowed a worker, and it is a blocking extern now, so the strand parks and the worker goes on. The runtime gained rill_udp_open, rill_udp_port, rill_udp_send, rill_udp_recv and rill_dns_lookup.
changeids, URLs, media types, a log, and standard error
random/uuid makes version-4 and version-7 UUIDs from the operating system's entropy, reads and writes the text and byte forms, and a v7's first 48 bits are the millisecond it was made. web/url takes a URL apart and puts it together, reads a query as pairs, encodes and decodes, and resolves a relative reference against a base as a browser follows a link. web/mime names the media type a file is served as. cli/log is a logger that is a value — a name, a level from RILL_LOG, a line with the time on standard error — because a program without globals hands its logger to what needs it. The prelude has eprintln and eprint.
Two fixes on the way: the formatter wrote a match in a then branch without its parentheses, so the else after it became part of the last arm; and a field of a generic record handed straight to a tail call crashed codegen.
changedeflate, gzip and zlib
data/deflate reads any DEFLATE stream — stored, fixed and dynamic Huffman blocks — and writes one as LZ77 matches over a 32 KB window in fixed Huffman codes; gzip/gunzip and zlib/unzlib are the two envelopes, checksummed with data/checksum. What it writes, gzip and Python's zlib read, and what they write, it reads. On the 100 KB language reference: 48 KB where gzip -1 gives 45 and -6 gives 39, in 14 ms; back in 3 ms. Dynamic Huffman on the way out is the next step, when a size matters more than a listing this short.
changethree collections, and a field the compiler dropped
coll/set is a set over the hash table Map already is — storage, like a map, of anything a map can key, with union, intersection, difference and subset. coll/heap is a priority queue that is a value: a leftist heap ordered by < or by whatever new_with(before) was given, so a heap of (priority, task) pairs orders by the priority. coll/deque is a queue open at both ends that is a value, two lists turned round as one runs dry. Writing the set found a compiler crash: a field of a generic record handed straight to a tail call was asked about with no substitution for the record's type parameter. Fixed, with a test.
changetext laid out, numbers spelled
text/fmt is the layout a report wants: text("{} has {:>6}", args) places strings in a template by order or index, padded to a width and aligned left, right or centred with any fill; fixed(x, 2), sci, percent, thousands, zero_pad, signed, hex, binary, radix spell numbers; bytes(n) picks the unit; ordinal and plural say 22nd and 2 files; table(rows) pads each column to its widest cell. Widths count characters, not bytes. The template only ever places text, and a number is spelled first by the function that knows how — so there is one way to say {:>8} and none to guess what %8.2f meant.
changethe list words a program kept rewriting
The prelude has the combinators programs had been writing for themselves: find, index_of, has, count, last, each, zip, zip_with, unzip, fst, snd, enumerate, take_while, drop_while, partition, flatten, flat_map, chunks, distinct, group_by, min_by, max_by, and a stable merge sort as sort, sort_by and sort_with. Each stands on its own helpers, so a program that defines a find of its own loses the prelude's find and nothing else.
changethe process and its surroundings
cli/env is the environment: get, get_or, set, unset, all as pairs, the working directory read and moved, the host's name, the process id. cli/proc runs a command line through the shell — run for the exit code and the output together, output for the output or an Err naming the status, shell for a command that writes to our terminal — and command builds a line from a program and its arguments with each one quoted, so a name with a space in it stays one argument. The calls that wait are blocking, and other strands run meanwhile. The runtime gained rill_env_at, environ reached as a function because an extern names one.
change`()`, and CSV
There was no way to write the value of Unit: a walk over a list whose other arm only prints had nothing to put after Nil ->, and reached for print(""). () is that value now — typed Unit, formatted as itself, refused by println like any other non-value.
data/csv reads and writes CSV as RFC 4180 has it — quoted fields, doubled quotes, line breaks inside quotes, \r\n or \n — and forgives what real files do (a quote mid-field, a blank line, a short row) while refusing what would hide an error (a quoted field that never closes, text after a closing quote), naming the line. records reads the first line as names and gives a Map(Str, Str) a row; parse_with and write_with take another separator.
changea hash to sign with, and a sum to check with
crypto/sha2 is SHA-256 and SHA-512 with HMAC over each, checked against FIPS 180-4 and RFC 4231, and same for comparing a tag in constant time. SHA-256 is 32-bit arithmetic masked on the way, SHA-512 the machine's own 64-bit wrapping arithmetic, and both keep the schedule in a buffer and read a block straight from the message: 16 MB in 75 ms and 33 ms. data/checksum is CRC-32 and Adler-32, whole or continued across pieces, for the formats that carry one.
change`lib/`, by kind
The library folder was a flat list, and grew until a name said less than a folder would. It is nine folders now, each for one kind of thing: cli/ (args), crypto/ (sha1, passwd), data/ (json, yaml), db/ (kv and the OxiDB clients), num/ (grid, col, decimal, bigint), random/ (rnd), text/ (str, regex, path), time/ (clock, date) and web/ (http, ws, tmpl). An import names the folder — import "text/str", import "db/kv" — and the module keeps its own name for the alias and for module on its first line. Every example, benchmark and page that imported by the old bare name says the new one.
changea file that says what it is
A library had no way to say so: every file was a program to the compiler, and rill check on lib/text/str.rill ended with "no main function defined". A library begins module date now — its own name, which has to agree with the file's, so a file copied under another name is caught at once — and then no main is expected: it is checked, typed, formatted and rill tested as it is, and rill run refuses it, saying to import it from a program. A file with neither a module line nor a main is asked which it is. Every library and every file that exists to be imported says it now; the template generator writes it into what it makes.
changeeight libraries, in folders
lib/ was eighteen files in one flat list, and the things a program reaches for first — a date, a regular expression, a path, a random number, its own command line, a decimal amount, a big integer — were not among them. They are now, in folders, each a module with its use at its head, its helpers private, and its own tests:
time/clock— elapsed time, a timed call, a duration in words;time/date— civil dates and times in UTC, the counts, the calendar, ISO 8601, with nothing invented about time zones.text/regex— a backtracking matcher: classes, anchors, groups, alternatives, greedy and lazy counts, leftmost-first as Perl has it;text/path— join, take apart, normalize, relative.random/rnd— PCG32 as a value handed along, its reference stream reproduced to the number.cli/args— switches, values, slots,--helpfor free.num/decimal— a count of units and a scale, rounded four ways on request;num/bigint— any size, base a billion, division by binary search on the quotient limb.
A folder is a namespace: import "text/regex" as re. The reference lists the libraries in one table. On the way, the formatter was found dropping the parentheses around a comparison inside a comparison — (a < 0) != (b < 0) came back as a line that does not parse — and keeps them now.
changea ring, refused where it is made
Reference counting cannot let go of a value that holds itself, and a program of immutable values can make one in exactly one way: a value is built from what exists before it, so only storage can come to hold what holds it. m[k] = v where v holds m — a closure over the map, a record with it in — is refused now, naming the map and what to do instead; so is a channel sent a value that holds it. The check follows what a value holds through bindings, fields, matches and calls, as the --parallel sharing check does, and runs on every build. A ring made through two maps, each holding what holds the other, is beyond it, and for that the allocator's report at exit — RILL_DEBUG_ALLOC=1 with RILL_ALLOC_CHECK=1 on a --parallel build — now says which functions made the blocks that were never let go, by walking to the innermost Rill frame at each allocation.
changea function named `bind`
A Rill function named bind, connect, read or write used to take libc's place at link time, and the runtime's socket calls jumped into the program: a crash with the wrong frame on top, a silent exit, a hang. The linkage change of 5 September — every Rill function but main and an export fn is internal to the object — closed that without saying so; the reference says so now, and a test keeps it: a program defining all four, serving itself over a socket.
changea strand that waits in C, and a `while` that never gave way
A C call that blocks holds the worker thread, and every strand on that worker with it: a server whose handler read a file, slept, or looked a name up stopped answering everyone while it did. extern blocking fn says a call may wait, and such a call is made on a helper thread while the strand is parked — the machinery file_sync already had, made general: the arguments go into a slot on the frame, a wrapper made once per declaration makes the call from the slot, and the answer comes back through it. The worker goes on with the others; a program without strands makes the call itself. It is said per declaration because it costs a hand-over each way, and a blocking call cannot take a callback, which would run where Rill code cannot.
A while whose body called nothing was exempt from the preemption check, as a for is — but a for is bounded by its range and a while by nothing but its condition, and a strand waiting in a while for something another strand on the same worker would set waited for it forever. A while is checked now, whatever it calls. A for stays as it was, and a long one that should share its worker says so with yield(), which gives up the turn if another strand is waiting and returns at once if none is.
changea function the file keeps to itself
Everything a file defined was the importer's, its helpers included, and two files could not both have a walk. private fn is a function the file keeps to itself: an importer does not see it, cannot call it — the error names the file and says to call what it offers — and may have a function of the same name. The loader renames a private function to stem..name, a spelling no program can write since .. is the range operator, and rewrites the file's own references to match, its impl methods and trait defaults included; a second file of the same stem gets a number. rill fmt writes it back, rill types shows it as the private fn it is, and the default stays as it was: a library written before this keeps offering everything.
changea closure crosses into C
A callback handed to C had to be a function known while compiling: a lambda or a closure has an environment, and a bare code pointer has nowhere to keep it. Most APIs that take a callback also take a pointer they hand back to it unread — qsort_r's arg, pthread_create's, a library's "user data" — and that is where it goes now. The signature says so with Env: extern fn qsort_r(base: Ptr, n: U64, w: U64, cmp: Fn(Ptr, Ptr, Env) -> I32, arg: Env) is the parameter the pointer goes out by and the slot it comes back into. The Rill call leaves the Env out, and the callback is written in the types that remain. What goes out is a box holding the closure — code and environment, two words — and what C calls is a trampoline made once per C signature, which unpacks the box and calls the closure the way every closure is called. The box and the frame's hold on the closure last the call, so this is for a callback C uses during the call; one C keeps is still a named function. The runtime has rill_sum_by(n, f, arg), what such a C library looks like, for the example and the tests.
change`rill test`
There was no way to say a test in Rill: the compiler's own tests are Rust, and a program's were shell scripts. A test is a function named test_... that takes nothing, and rill test file.rill runs every one the file has — each in a process of its own, so an assertion, a fault or a deadlock in one is one test failing and not the rest — and says which passed, which failed and what the failing one said and printed. assert(c) ends a test where c is false, saying the line; assert_eq(got, want) says both values too. Nothing is caught: a test that failed is a program that ended. The file is built once, with a main that runs the test its argument names, and the file's own main stands aside.
changewhat strands may share
Nothing stopped two strands from writing one buffer. A --parallel build checks it now, before the program runs: storage — a Buf, a Map, a StrBuf, or anything holding one — handed to a strand by a spawn or a send and used afterwards by the side that gave it is shared, and shared storage that either side writes is refused, naming the value and the line. Sharing to read is fine; a value not used afterwards is the strand's alone. A function value is opaque — what it holds and writes is not in its type — so a closure shared between strands is refused too, unless the program says what it knows: apart(x) is x and the claim that the writes are kept apart, which the compiler cannot check and a reader can find. rill check --parallel asks the same of a file; a one-worker build, which interleaves strands only where they wait, is not checked.
The check follows a value through bindings, fields, matches and calls that answer with a parameter, knows which parameters a function writes through every call, and assumes a write wherever it cannot see. It found two things on the way: the catalogue's memory cell, two words one strand writes and every handler reads, and lib/http's handler, which every connection's strand is handed and which the middleware example has closing over a rate limiter's table that every strand writes. Both now say apart, and the second says in the library what the claim covers and what it does not.
change`rill explain`
The memory model was documented and invisible: the rules said when a parameter is borrowed, when a constructor reuses the box a match took apart, when a binding is handed to a call rather than retained, when a pipeline is one loop — and nothing said which of them had fired where, so a number that came out slow was a surprise with no address. rill explain file.rill is the code generator's own account, taken as it generates: per function of the file, which parameters are borrowed and which owned, each constructor call as reuse, allocation or a flat value, each binding at a call as handed over or retained and why, each pipeline that became a loop, each closure and what it holds. Once per function, line by line, in the generator's words, with nothing guessed after the fact.
changea method with a default, and a trait that requires others
A trait method may come with a default body, written after its signature with its parameters named — priority(x: a) -> Int = 1 — and an impl that leaves the method out gets it. The default is checked once per such impl, at that impl's type, as if the impl had written it, so a default that calls another method of the trait calls that impl's. trait Pretty(a): Show, Describe requires the others of any type that implements it, and an impl for a type that lacks one is refused naming it; Ord and Show may be required and are met the way Int and Str meet them, without an impl.
A loop or a ? inside an impl method was an internal error — "while was not expanded" — because impl bodies were never handed to the pass that spells those out. They are now, and default bodies with them.
changea requirement said in the signature
A generic function's requirements were whatever its body implied — an a > b made a ordered — and travelled with the scheme unseen. A where clause says them: fn shout(x: a) -> Str where Describe(a) requires an implementation of Describe of a, checked at every use like an implied requirement is, printed by rill types as "'a implements Describe", and refused at the definition where a is not one of the signature's variables or the trait does not exist. Ord and Show may be required the same way.
changea method's name is its trait's
A method name was global: two traits could not both have a describe, and a library that named a method had named it for every other. A method belongs to its trait now. describe(x) by the bare name is settled by the type of x — whichever of the traits is implemented for it — and where that cannot tell, because both are or x is a parameter whose type is not known yet, the call says which: Describe.describe(x), which is always allowed. Constructor names stay global.
changea name for a type
type Meters = Float is a type alias, and type Pair(a) = (a, a) one with parameters. It is a name and nothing more: every mention is read as what it names before anything else looks at the program, so an alias and what it names are one type, an alias prints as what it names, and one that names itself is refused. Where a type is to be kept apart from what it holds, a constructor is still the way.
changean object of a trait
A trait's dispatch is static, so a List(a) held one type, and a list of things that could all speak had to be a sum type listing them. Now a value can be made an object of the trait: Speak(rex) is a record of the trait's methods, each a closure over rex, and its type is the trait's name, so a List(Speak) holds a dog and a robot and s.speak() calls whichever it is. Written out it is Speak.obj(\ -> speak(rex)), a record whose fields hold functions, made by a constructor the trait declares — the checker writes that and infers it as any expression. Each call inside dispatches statically as before; what is paid is the closures, one allocation per method when the object is made, and the indirect call through each. A trait can be the type of objects when every method takes the trait's type once, and first; Speak(x) on one that compares two of them, or answers with one, says which method is in the way.
Three smaller things it needed, each a gap on its own. A function's type could not be written in an annotation — the reference's table said (A, B) -> C and the parser said "expected a field type" — so a record of closures, the functional object, could not be declared at all; it can now, () -> Str included. A field that holds a function is called as s.speak(). And \ -> e is a lambda that takes nothing.
changea `\u` escape is a character
lib/json read \u00e9 as one byte — chr(233), which is not UTF-8 — and everything above \u00ff as ?. A \uXXXX is a UTF-16 unit: a character below 65536 outright, or one half of a surrogate pair spelling a character above it, \uD83D\uDE00 for one emoji. It goes in as the bytes UTF-8 spells it with now, pairs joined, and a half with no other half as U+FFFD, the replacement character.
The reference had never mentioned the character layer the prelude has had since August — char_count, char_at, chars, from_char and the rest, on top of the bytes #s and s[i] are and stay — nor that chr is a byte and not a character. It has a section for them now.
changewhat integer arithmetic does at the edges
7 / 0 was 1. So was 7 % 0, and to_int(1.0e30), and to_int of a NaN, and shl(1, 64): each is undefined for the machine, LLVM folded the undefined into whatever came out, and what came out was a number with no sign it was wrong. They are decided now. / and % by zero end the program the way an index off the end does, saying where; the smallest integer over -1 — the one quotient a machine may trap on — is done as a negation and wraps to itself, with a remainder of zero. to_int saturates at the ends of Int and gives zero for a NaN (llvm.fptosi.sat). A shift by 64 or more, or by a negative count, is zero: the count is masked for the machine and the answer chosen by the count as written. +, - and * wrapped before and still do; the reference now says so, and says that there are no narrower numbers as values — a byte is an Int, and U8, I32, F32 name storage and C signatures.
The divide costs two compares and two selects on a path that already waits for the machine's slowest arithmetic; the check is kept under --unchecked too, since a wrong quotient is not a bounds test.
change`?` on an option, and an error carried across layers
? took the value out of a Result and nothing else. It takes the value out of an Option now, in a function that answers one: the two arms it becomes read Some and None, and the None goes out as itself — the same match, the same nothing at run time. It does not cross between the two kinds, and the error for an Option in a function that answers a Result says what to write instead.
An error of another layer's kind crosses at the ? now: where a function answers Result(_, AppErr) and the ? meets an IoErr, the error goes out inside the one constructor of AppErr that takes an IoErr — Err(e) -> Err(Io(e)), the match a program wrote by hand before. The sum type is the declaration; nothing converts that it did not declare, and two constructors that take the error, or none, is an error that asks for map_err. The function's error type has to be known — written as its return type, or settled by the block — and the error says so where it is not.
The prelude has the crossings written out for the rest: ok_or(o, e) is an option as a result, the error being what the program says its absence is, and map_err(r, f) is a result with its error rewritten.
changethe formatter keeps the breaks
A line could continue the one above it since August — |> sum under xs, + " more" under a string — and rill fmt put every such line back onto the one it continued, so in a repository that checks its formatting the continuation could not be kept. The parser now records where the author broke a line (newline on a binary operator and on the if that && and || become, stacked on a call whose arguments began lines) and the formatter writes the break back, one level in from the statement: operators, |>, then and else at the head of their lines, arguments one to a line with the first beside the name. A trailing comment on such a statement stays on its first line.
The reference's layout section said only then and else could begin a line; it now says what can, and shows a pipeline.
changewhat a branch binds stays in the branch
A name bound in one branch of an if was there after the if: z = 5 inside a then block read as z further down the enclosing block, and x = "str" inside one changed what x was outside it — the checker went on with the branch's type, and where that happened to agree the generated code went on with the branch's value. Match arms had always been scoped; if branches and blocks in general now are, in the checker (a block infers in its own environment) and in codegen (a block puts the scope back as it found it, and the two branches of an if in expression position bind for themselves, as they already did in tail position). Rebinding a name in the same block — x = x + 1 — was always allowed and still is; the reference says so now.
A lambda's body may be an indented block on the lines after the ->, the way a function body or an if branch may. The formatter writes it back that way.
The middleware example asked rate_limit(2) to tell its clients apart by X-Forwarded-For, which the limiter stopped doing when it learned not to believe a header a caller can write. It asks rate_limits(2, 0, 1) now — one proxy in front, the last entry is the caller — and says why.
changea signature written down can be taken at any type
Two functions inferred together each had one type, so an f that used g at Int and at Str while g called f back was refused, and so was a function calling itself at another type. A whole signature with type variables in it — fn g(x: a, n: Int) -> a — now declares the scheme up front: a use inside the function's own group instantiates the declaration rather than sharing the provisional signature, which is polymorphic recursion at the price of writing the signature. The body is still inferred against variables standing for the declared ones and held to the declaration at the end; fn f(x: a) -> a = x + 1 is refused as "declared ('a) -> 'a, but its body only works as (Int) -> Int". A lowercase name in any annotation is a type variable now, the same name the same type throughout the signature; before it was "unknown type".
What a monomorphizing compiler cannot do is make infinitely many copies. A call to itself at a bigger type each time is stopped in codegen when an instantiation's types have grown past anything a program writes, and a type that holds itself at a bigger type — Nest(a) holding a Nest((a, a)) — is refused where it is written, since every structural walk over its layout would otherwise go on forever (it did).
The reference and the book claimed a function used before its definition was pinned to that use. It has not been since dependency groups were ordered; both say what is true now.
changea field every constructor has
s.name on a type with several constructors was refused outright: with two, a field name did not say which. It does when every constructor has it, of one type — Circle(r, name) and Rect(w, h, name) both have a name, so the field is certainly there whichever the value is, and it reads with the dot like any other. { s | name = "x" } rebuilds whichever constructor s is. Both become the match a program would have written, one arm per constructor, so codegen learned nothing new; the positions may differ between constructors and the type may be generic. A field some constructor lacks still needs the match, and the error now names the constructor that lacks it rather than the count. The fields-name-the-type rule counts such types too.
changethe fields say the type
fn dist(a, b) = a.x - b.x used to be refused: the type of a was never said, and .x could not say it, since two types may both have an x. Now it can, when they do not. A value nothing else names is of the one single-constructor type that has every field read from it — .x and .y together pick Point out from a Vec3 that has an x too. Two types that both have all of them is the case an annotation is still for, and the error names them (Point and Vec3 alike have a field x); a field no type has is reported as that, with what the near misses do have. Groups are answered in two passes, the outright ones first, since naming q can settle the p in if p.x > 0 then p else q.pos.
{ p | x = 1 } is deferred the way p.x has been since August: written before the line that says what p is, it is built at the end, and the fields it sets name the type the way the fields a read asks do. The reference had never mentioned the notation; it does now, and dist in the records example has lost its annotations.
changea short string out of a long builder, and a length read in time
Every JSON answer a server built in a strbuf and sent — between 129 and 240 bytes long — kept 272 bytes of malloc for good. The builder's block had doubled to 256 bytes, beyond the pool, and strbuf_str on its last use handed that block over as the string; the string was then freed by its own length, into a pool free list the block never came from, and nothing takes a block off a list but a request for that size. The catalogue example grew from 12 MB to 290 MB over 900,000 requests for one row. The block is now handed over only when the string is beyond the pool too; otherwise it is copied, as it always was for a builder that never left the pool.
str_len(f()) — and #f() on a buffer — read the length after the value was released. The loads were marked invariant.load, so LLVM could hoist them out of loops that store into a buffer; the same mark let it sink them past the release. For a string or buffer beyond the pool free writes over the header, and the length came back as whatever it left: a thousand 300-byte strings added up to 1,010,688. The mark is gone; a loop that checks bounds reads the header again on each, which costs the engine a few percent on inserts, and a length is now read where the program reads it.
RILL_ALLOC_CHECK now works for a program that never spawns, and a --parallel build under it sends every block through the runtime, checks and all (a plain build is told the checks cover only what the runtime allocates). extern fn rill_mem_report() -> Int writes where a program's memory is — blocks out, pool chunks and what sits on free lists, big blocks kept, strand records and their saved stacks — to standard error, for a server's /stat. examples/short_of_long.rill is both bugs, and an e2e test runs it under the checks.
changea shared box let go of atomically, and an allocator that says who
A match that empties a box it owns — the in-place reuse that lets map rebuild a list in the cells it was given — let go of a box that turned out to be shared with a plain load, subtract and store of its count, on a --parallel build as on any other. Another worker's retain landing between the load and the store was lost, the count came up one short, and the box was freed under a holder: a strand reading a freed cell of a list every strand was given, some runs and not others. Both places that did this (empty_box's shared path and take_reuse's fresh path) now take the count down the way every other release on a parallel build does — atomically, and a zero coming back frees after all. examples/shared_list.rill is the case, and an e2e test runs it on eight workers.
RILL_ALLOC_CHECK=1 at run time turns on the pool allocator's checks: every block freed is painted and its freer's frames kept (the last two frees of it), a block handed out or freed while its state says otherwise is reported, and a freed block whose count was touched before it came off the free list names the freer. RILL_ALLOC_CHECK=2 adds a quarantine: every small block gets a page of its own that is made inaccessible at its free, so a stale holder faults on its own instruction (slow — a syscall per allocation). RILL_RC_CHECK=1 in the compiler's environment builds a program whose every count update first asks the runtime whether the block is out at all — a touch of a freed block is reported in the toucher's frames, with the block's whole history of retains, releases, hand-outs and frees — which is what found the bug above.
rill build --emit-ir --parallel now prints the parallel build's IR; it printed the single-worker one.
changea crash says where, and two buffer calls that ask first
A fault — a bad address through an extern or a Ptr, a strand off the end of its stack, an illegal instruction — is reported before the program dies: the signal and the address, whether the address is a stack's guard page (a stack overflow, named as one), the strand and the worker, and the Rill functions on the stack by name, innermost first. The names come from a table the compiler now leaves in every binary — each function's start and its name, two pointers apiece — so a stripped release build reports what a --debug one would. The handler runs on a stack of its own on every thread, since the fault it most wants to report is the one that left no stack; the stack is walked by frame pointers, which every Rill function now keeps (one register on x86-64; arm64 kept them anyway), and the runtime too. RILL_CRASH_WAIT=1 makes the program wait for a debugger instead of ending. A failed bounds check reports its frames the same way, and to standard error now.
buf_copy(dst, to, src, from, n) and buf_cmp(a, at, b, bt, n): memmove and memcmp between buffers with both ends bounds-checked, for the code that was reaching for ptr_offset and an extern to move bytes — and finding out, when a length came off a corrupt page, what the memory past a buffer holds.
examples/crashes.rill is one of each.
changea pattern may name an aliased constructor
match v j.JStr(s) -> s for a module imported as j: the parser took j.JStr for a variable, since it begins with a lowercase letter, and refused the parentheses after it. The segment after the last dot is the one that says whether a name is a constructor.
changea key–value store, and the three calls it asked for
lib/db/kv.rill (import "db/kv"): a key–value store on disk — a B+ tree of 4 KB pages, an 8 MB clock cache, a write-ahead log with a checksum on every frame, one sync per commit, checkpoints, rollback, range scans, and an exclusive lock so a second process is refused rather than let in. A crash between any two writes loses the uncommitted transaction and never a page; the test stages one. Against SQLite through its C API, same durability and same cache: reads three times ahead, the range scan three times, the bulk load somewhat ahead, the durable commit level — it is the drive's four milliseconds either way (benchmarks/kv/).
The runtime learned three calls on the way, each a few lines: file_pread_into reads a block into a byte buffer the caller owns, at an offset in it, with no string made; file_pwrite writes from one at a file offset without moving the descriptor; file_lock and file_unlock are the whole-file advisory lock, taken at once or not at all.
changeLLVM 21
The compiler now builds against LLVM 21 (brew install llvm@21; the prefix is wired in .cargo/config.toml, and inkwell's feature is llvm21-1). Not a line of the code generator changed: the IR Rill emits, the tailcc + musttail promise it rests on, and the wasm32-wasip1 target all carried over unchanged, and every test passes as it did.
What changed is what comes out the other end. Three years of AArch64 work sit between LLVM 18 and 21, and the C table moved the way one would hope: spectral 123 → 95 ms (ahead of C by 18%, where it was level), qsort 290 → 272, fannkuch 118 → 109, lcg 128 → 122; nothing moved the other way. The idiom that started this — x % 3 == 0 in pipeline's filter, which LLVM 18 spelled in six instructions and Apple's newer clang in five — is now five in Rill too, and that row is a tie to the hundredth of a millisecond.
changea string library, a `while`, and boxes that empty themselves
lib/text/str.rill (import "text/str" as s): 145 functions — searching, transforming, padding, strict parsing, a hash, an edit distance, every encoding a text arrives in (UTF-8 validation and repair, UTF-16/32 in either order, Latin-1, cp1252, cp1254, Unicode case with the Turkish rule, ascii_fold, base64, hex, percent, HTML entities, C escapes), and par_count / par_index_of / par_lines, which cut a text across strands. Against C on a 44 MB text (benchmarks/strings/): the searches two to three times ahead, replace and upper ahead, the rest level.
while cond with (a, b: T) = init: the loop that stops on the data rather than on a count, opened like for into a tail-recursive function. The state is what the condition and the body see, the body's value is the next state, and the loop is worth the state the condition first refused. A tuple state travels as separate parameters wherever the body ends in a tuple written in place; an annotated one types the loop's result as well. No break, on purpose: a loop with one exit is the loop that vectorizes. Rewriting the string library on it took it from 180 functions to 145 at the same speed.
And the code generator learned to take a box apart without counting: an arm that returns empties the box it matches when the frame's reference is the only one, its fields become the frame's own and the box a shell for the answer to be built in; a tuple written as a scrutinee is never built; a constructor that is the answer moves what it is given. deriv, a symbolic differentiator against C, went from 94 ms to 48 — ahead of the C given a free-list pool, and 4.8× ahead of the C with malloc.
changeinstructions the machine has and C has no name for
asm fn declares a run of instructions the way extern fn declares a symbol:
asm fn outb(port: U16, v: U8) = "outb $1, $0" : "{dx},{al}" asm fn read_cr3() -> U64 = "mov %cr3, $0" : "=r" asm fn barrier() = "dmb sy" : "~{memory}"
Found trying to say what an operating system in Rill would be missing. The answer was mostly this: cli, lgdt, invlpg, wrmsr, in/out, cpuid, wfi, and the barriers — none of which libc has a name for, and all of which are one instruction. A language that can reach C but not the instruction under it can drive a machine only as far as somebody else already wrapped it.
The parameter list is extern's, deliberately: the question of what may cross into an instruction is the question of what may cross into C, and the widths (I8…U64, USize, Ptr) already answer it. What is new is the constraint string, and it is LLVM's rather than ours — a template and its constraints go through as written, the way extern "-L/opt/homebrew/lib" goes to the linker as written. Omitting them asks for =r and r, which is what most instructions want; naming them is for the register the instruction insists on.
A constraint string that does not describe the signature is a compile error with a line number. This is not politeness: LLVM answers a mismatched one with an assertion, and the compiler dies without saying where.
Side effects are always assumed. Two identical calls stay two calls, and a result nothing reads still runs — a device register read twice was meant twice. The optimizer is otherwise free, and the emitted code is the instruction and nothing around it: add x0, x8, x9 on arm64, addq %rax, %rdi on x86-64.
naked asm fn is the other half: the instructions are the whole function, and LLVM puts the name in front of them with no prologue and no epilogue. An interrupt handler ends in iretq because that is what it is for, and there was never a caller to return to.
naked asm fn isr_timer() = "push %rax\n ...\n pop %rax\n iretq" naked asm fn _kstart() = "mov $0x1000, %rsp\n call kmain\n hlt"
No parameters, no result, no constraints — refused with a line number rather than compiled into something wrong at the one moment it runs. Nothing in Rill may call one, and the error says why. The address is what a program has use for, and taking it is an ordinary asm fn. Inside a naked body $ is the assembler's literal rather than an operand reference, so an immediate is written the way the manual has it.
wasm refuses either kind outright.
changebytes back from C, and a name two libraries wanted
str_from_ptr(p, n) copies n bytes out of C memory into a Rill string. from_cstr was the only way back and it stops at the first zero, which is right for a C string and wrong for everything a library hands back by filling a buffer and returning a count — a read, a decompression, an SSL_read. The alternative was a loop appending one byte at a time into a StrBuf, which is sixteen thousand calls for a sixteen-kilobyte read and is not what a bridge to C should cost.
Found writing TLS for a message broker in Rill: OpenSSL is reached entirely through extern, with no runtime change at all, once there is a way to take the plaintext out of the buffer it decrypted into.
lib/web/http.rill's render is now response_bytes. render is the method lib/web/tmpl.rill's Render trait owns, so a program that serves pages built from templates could not have both files at once — which is the ordinary shape of a web program and so the one case both libraries were written for.
changea wait that the write before it was ending
sock_wait(fd, ms) answered false in under a millisecond if the same socket had just been written to. The deadline had not remotely passed; the wait had simply been woken by something that was not what it was waiting for, and reported that as the time running out.
A descriptor is registered for reading and for writing separately, and the poller names a registration by the descriptor alone. A socket written to a moment ago is writable, and that edge is delivered to whatever strand is parked on that descriptor — which the runtime already knew could happen, since wait_ident exists to drop an event whose descriptor is not the one being waited for. It cannot tell a read from a write on the same descriptor, and that is the case that was getting through.
So the wait is a loop against a deadline rather than one sleep, and only the clock ends it. Anything that wakes it and is not something to read puts it back to sleep for what is left. This is what a wait on a condition has always had to be, and the reason it was not is that the wake looked reliable.
It is sock_wait that carried it, so what was affected is everything with a deadline on it: a server reading a request after writing a response, which is every keep-alive connection, and any client that writes and then waits. Found by writing a client that sends a command and waits for the answer, which is the smallest program that can hit it.
changea refused connection that left its registration behind
connect_to to a port nothing is listening on answers Err and closes the descriptor it made. It did not take that descriptor back out of the poller first, and the number then goes back to the operating system to hand out again.
The next socket given that number, waited on by the same strand, is taken for one already registered — on a single worker the poller keeps a table of who is waiting on what, and finding the same strand there already, it does not register anything. So the wait never ends. Two refused connections in a row were enough, and the second one hung for ever.
Found by a broker whose nodes dial each other at startup: the first peer being down was fine and the second one being down was a hang.
sock_close had always done this; a failed connect is the other path that closes a descriptor the poller knows about, and it now does the same.
changea `select` arm that let go of what it took
A value received by a select arm comes out of the channel owned: the channel has given up its reference and somebody has to have it. An arm that hands it on does — x = recv(ch) -> Some(x) moves it — but an arm that only reads it hands it to nobody, and the reference was dropped on the floor.
got = select v = recv(ch) -> str_len(v) # one string leaked, every time round recv(done) -> 0
A loop receiving a million strings and asking each one its length ended with a million of them still allocated. So did a loop that bound a value and ignored it. What made it hard to see is that the obvious spelling is the safe one: anything that passes the value to a function or puts it in a constructor moves it, and moving is what most arms do.
The fix is the rule a block's own bindings already follow. A block notes what the frame owed before it began and releases whatever is left over at the end that was not there before; a move takes the entry out of that list on the way past, so nothing is released twice. A select arm now does the same, and the arm's own answer is the one thing excused, because that leaves with the select.
Found by measuring a message broker written in Rill, which was holding seventeen allocations per message and giving none of them back. It was this, all of it: with the arm releasing what it bound, twenty thousand messages published, delivered and acknowledged leave two allocations at exit rather than three hundred and forty thousand, and the broker's memory per message went from about eight hundred bytes to a hundred and sixty-four.
changea deadline on a wait
select takes a timeout(ms) arm, ready when nothing else has been for that long.
r = select v = recv(ch) -> "answered " + int_to_str(v) timeout(ms) -> "gave up"
A select that had to give up needed a strand and a channel to be its clock, and the reference is right that a ticker is three lines — for a rate. For a deadline it is the wrong price: a server putting a limit on every request in flight pays a strand and a channel per request, for a clock each of them looks at once. The arm parks in the poller instead, beside the channels it is waiting with, and costs nothing while it waits.
The interesting part is the race. A strand in a select with a deadline is registered in two places that can both come ready, and queueing a strand twice corrupts the run queue. The poller already solved this for a socket armed with a deadline — whoever gets there first claims the strand and the second finds on_io already false — so the deadline arm uses the same claim and nothing new had to be invented. What did have to change is that the poller stamped case zero on a strand it woke, which a select cannot tell from its first arm having won; it stamps a mark of its own now, far above any case number. Four thousand rounds with the deadline swept across the arrival, half answered by the value and half by the clock, leave no allocation behind on one worker or on four.
The shortest deadline is the one that answers, so a wait may carry a warning and a limit at once. timeout(0) is ready immediately, which makes it a poll — default by another name, and the better spelling where the number is the point. Writing default and timeout in the same select is refused rather than silently ignoring the deadline. examples/timeouts.rill is the five shapes of it.
changeand a way to be told to
wait_signal() parks until the program is asked something and answers which: sig_hup(), sig_int() or sig_term(). It is the other end of close, and the two are written together — one strand waits and tells the others, so the waiter never has to know how many others there are.
fn shutdown(done) = wait_signal() close(done)
A signal arrives in the worst context there is: on whichever thread the operating system picked, between any two instructions, possibly while a strand holds the channel lock. Nothing a program has written may run there. So the handler does one thing — writes a byte to a pipe — and wait_signal is a strand parked on that pipe, through the same poller a strand waiting on a socket parks in. Waiting for a signal costs a worker nothing, which is the whole point when waiting is all the strand does, and a program whose only strand is waiting is not reported as a deadlock because it is waiting on something that answers.
The pipe is only the wakeup. Which signal it was, and whether it is a new one, come from a counter the handler bumps and the strand compares against what it noted before parking — so a byte left in a pipe by an earlier signal is harmless rather than something bookkeeping has to be right about. Each waiting strand gets a pipe of its own, because two strands parked on one descriptor would be two registrations of the same descriptor and only the second would wake; sixteen may wait at once and the seventeenth is told to have one wait and close a channel the others watch.
SIGUSR1 and SIGUSR2 are deliberately absent. They are 30 and 31 on a Mac and 10 and 12 on Linux, and a number that means one thing where it was written and another where it runs is worse than not having it; SIGHUP is the traditional "read your configuration again" and is what a server would have used them for. examples/signals.rill raises one on itself so that there is something to catch.
changea way to stop
A strand parked on recv was parked until a value arrived. There was no other outcome. So tearing down whatever was going to send one left the strand where it was, holding its stack, for as long as the program ran — and a server with a strand per connection has as many of those as it has had connections. The language could start things and could not stop them.
close(ch) # nothing more will be sent, and everyone parked on it wakes closed(ch) # whether it has been recv_opt(ch) # Some(v) while there is something, None once closed and empty
Closing is a broadcast, which is the thing sending a value cannot be. It reaches everyone parked on the channel at once, however many that is, so a program tearing down does not have to know the number — which is the whole difficulty, because the number is usually the thing it is least sure of. Closing twice is not an error, so a teardown that arrives from two directions does not have to arrange which of them gets there first. And what is already buffered survives the close and comes out first, so a producer may close the moment it has nothing more to say without the consumer losing anything in flight.
recv still answers the element type, and that is a decision rather than an oversight. Most channels never end — a worker pool, a pipeline, a ticker — and making every one of them answer an Option would have put a match in front of every receive in the language to pay for the few that do. recv on a closed channel ends the program and says to use recv_opt, which is the answer an index outside a buffer gets and is the same reason: there is no value to hand back that would not be a lie about a string or a list.
Sending into a closed channel ends the program too, and the rule that avoids it is the one Go arrived at: whoever sends is whoever closes. Where a strand really is parked on a send nobody will ever take, select is how it hears:
r = select send(out, n) -> push(out, stop, n + 1) recv(stop) -> "gave up"
A closed channel makes its receive arm ready, so one close(stop) reaches that strand whatever the other arm is doing. The arm binds nothing, and that is exactly why it works — there is no value for a closed channel to produce. An arm written x = recv(stop) -> would have to produce one, and ends the program saying so rather than binding a word that means nothing.
chan_wait and chan_taken are the two builtins recv_opt is written from, the way map_get is written from map_find and map_val_at. The answer has two shapes and a call has one, so the wait and the value are separate calls and the prelude puts them back together.
sock_shutdown(fd), for the other half of the same problem. The reference already said that a strand parked on a read cannot be got out of it by closing its socket from elsewhere: the reader is inside the poller, closing does not wake it, and the number can be handed to the next connection while the old registration is still armed. Shutting down acts on the connection rather than on the descriptor. The number stays this program's until it closes it, and the socket becomes readable-at-end-of-file at once — a readiness event, so the poller wakes the strand and its sock_read answers "". The reader then closes the descriptor itself, on its own strand, where that has always been safe. A server putting a limit on how long it holds a connection, or dropping all of them on the way down, now has something to call.
examples/closing.rill is the three shapes of it: a producer that runs out, a worker waiting on two things at once, and one close reaching four listeners.
changea file it does not have to hold, and a sync that does not stop the world
read_file and write_file were the whole of what a program could do with a file, and both of them are "all of it, now". A log is not that shape, and neither is a journal, or a store several readers are at different places in. So a file is now a descriptor — the same Int a socket is, deliberately, so that a program holding both is not holding two ideas of what an open thing is.
fd = file_open(path, "a") # "r" "w" "a", and each with "+" to read as well at = file_size(fd) # where this record will start file_write(fd, record) file_sync(fd) # now it is on the disk, and not before file_close(fd)
file_read and file_write where the descriptor is, file_pread at an offset without moving it, file_seek, file_pos, file_size, file_truncate, file_remove, file_rename, file_exists, and dir_make, dir_list and dir_sync for the directory around them. The prelude has open_file as a Result, dir_names as a list, and replace_file for the whole write-sync-rename-sync dance. §10 of the reference is the surface and examples/files.rill is it working.
file_pread is the one worth naming twice. Reading at an offset leaves the descriptor where it was, so several readers of one file do not have to take turns, and none of them disturbs whatever is appending to the end of it. That is the difference between one open file answering a hundred consumers and a hundred open files.
The sync does not hold the worker. This is the part that took the design rather than the code. A write that has returned is in the operating system's cache, and fsync is the wait for the disk to say otherwise — a millisecond on a good drive and ten on a bad one, which on a single worker is the whole program standing still. So the strand hands the request to a helper thread and parks on a pipe, which is the same manoeuvre a strand waiting on a socket makes and goes through the same poller. Four small syscalls to hide a wait a thousand times longer.
The measurement is not how fast a sync is, because that is the disk's business and not the language's. It is whether anything else can run during one: a strand computing beside a strand syncing takes 173 ms against the 180 ms it takes alone, while a hundred syncs happen next to it and take about three hundred. Held the worker, it would have had to wait for every one of them.
The rule the whole thing rests on is that the request is on the heap and not on the strand's stack. A parked strand's frames are copied away while it waits — that is how one worker runs many strands on one stack — so an address into them means nothing to another thread. Before the scheduler exists at all, a sync is simply done where it stands: there is no worker to spare and nothing to be an improvement on.
Two smaller things that are easy to get wrong and are therefore not left to the caller. On macOS the sync is F_FULLFSYNC and not fsync, because fsync there hands the bytes to the drive and returns without waiting for the drive to keep them, which is fast and is not durability. And dir_sync exists because syncing a file says the bytes are there and does not say the file is: creating and renaming both change the directory, and that change is cached like any other, so a program can sync a file it has just made and come back after a power cut to a directory that has never heard of it.
changea page, and the appends underneath it
tools/tmplgen.rill turns an HTML template into Rill, before the program that uses it is compiled. There is no template at run time and nothing to interpret: <%= e %> becomes a call, the text between the tags becomes appends, and a loop in the template becomes a loop in the language.
%% import "shop" %= product(p: Item, seen) <h1><%= p.name %></h1> <% if seen > 0 %><p><%= seen %> people looked</p><% end %> <ul><% each tag, i in p.tags %><li><%= i %>: <%= tag %></li><% end %></ul>
<%= %> escapes, <%! %> does not, <% %> carries for, each, if / elif / else, let, include and end, <%# %> is a comment, and <%- / -%> eat the whitespace around a tag so a template can be laid out to read rather than to print.
A page may be built out of several templates. <% include row(it, i) %> is the call row(b, it, i) — the same builder, and whatever the including template has in scope handed over as arguments — and the template it names is reached with a %% import like any other function in another file. There is no separate notion of a partial or a layout, and nothing is looked up by name at run time: a template is a function, and a page made of four of them is four calls. examples/template/ is that page. What is inside a tag is Rill — <% if seen > 0 %> is that expression, not a second language that happens to look like it. lib/web/tmpl.rill is the half that stays: the Render and Raw traits, so <%= %> works for anything with an impl, and an escape that copies the text whole until it meets a byte that needs a name.
benchmarks/template measures it against the Rust engines on the two pages the askama-rs suite renders, byte for byte identical output: 11 455 ns against sailfish's 11 546 on a 292 KB table, and 29 ns against its 32 on a small page — ahead of markup, askama and maud by two to three times, and of an interpreted engine by sixty.
examples/oxidb/catalogue.rill is the first thing written on it. Its page was markup built by hand — nine functions concatenating strings, and no way to see what the page looked like without reading them; it is now four templates — the page including the form, the table and the pager — with catview.rill beside them saying what a page is given. What comes out carries the same content on every route, the server answers the same 5,600 requests a second at a hundred users, and the HTML is laid out to read rather than run together, which costs about a fifth more bytes on the wire.
lib/web/http.rill is a good deal less of a toy. It reads a body that arrives in its own packet rather than with the headers, and one sent in pieces (Transfer-Encoding: chunked); it keeps what a read took past the end of one request, so a pipelined pair gets two answers rather than one; and it decides keep-alive from the headers of the request in hand rather than from the bytes in the buffer, which may hold the next one.
It also says no. A connection that goes quiet for fifteen seconds is let go, a header block over 64 KB and a body over a megabyte are refused before they are read, and a request that tells two stories about its own length — a Content-Length beside a Transfer-Encoding, two lengths, a length that is not a number — is refused outright: the danger there is not that this server picks the wrong story but that the proxy in front picks the other one, which is what request smuggling is.
Plus the things a server needs to be one: Date on every answer (which wanted a wall clock, so now_millis() is a builtin now — now_nanos measures how long something took, this says when it happened), HEAD answered without a body, twenty-four status texts instead of six, percent-decoding and query parsing (path_of, query_of, param, params, decoded, encoded — the catalogue had written five of those for itself, without the decoding, so a filter with a space in it did not work), cookies in and out, and a client that can send a method, headers and a body rather than only GET.
lib/web/http.rill learned headers, both ways. A Request carries the ones it arrived with, folded to lower case because HTTP says the case does not matter and a proxy will one day disagree about it; header(req, name) is the lookup. A Response carries whatever it wants to say beyond its type, and with_header adds one. That is what the two things below are made of.
Middleware. A handler answers a request; a middleware is one layer around it, handed the request and the handler underneath, free to answer without passing it on. wrap stacks them outermost first and hands back a handler like any other, so serve, serve_n and accept_forever take it unchanged. rate_limit(n) is the first: n writes a second per client, 429 for the rest, reads never counted — and a client is whoever X-Forwarded-For names, since that is all a server behind a proxy can see. examples/http/middleware.rill is the whole of it in forty lines.
What the catalogue serves twice, it now sends once. The nine modules a page pulls in are read from disk once and kept, and go back with Cache-Control and an ETag; a request that already has that version is told 304. A repeat visit that fetched 123 KB fetches nothing at all — measured in the browser, fifteen files, zero bytes.
It is also where the template engine meets a client one. The page arrives whole and works with JavaScript off; static/catalogue.js then takes it over with zap — signals, no virtual DOM — and its components: Select and Button in the toolbar, DataGrid for the table, which sorts, filters and pages a window of rows with nothing leaving the browser, and shows what the rows cost to produce — the database's time and the writing's — beside its row count. The table is editable: a cell opens the editor its column's type asks for, and what comes out is posted back to the document it came from, one field at a time. The rules are written on both sides deliberately — the grid stops you where the column says, and the server checks the same limits again before it writes, because a request need not have come from the page — and a write that lands says how long the database took, in a notice that shows for three seconds. Markup the first paint needs stays in the templates, state moves to signals, and the JSON the server already had for that page is in the page, so there is nothing to fetch before the first paint and nothing to swap in after it. Narrowing a filter is a question for the database, which has an index behind every field the toolbar offers; sorting a column is not.
Almost none of that is the generator. A page is a run of appends, and the appends are what changed:
- An append is written where it stands.
b += swas a call into the runtime that loaded the string's length out of its header and branched on a width the caller already knew; a literal now goes in as whole words, its length a constant, andb += byteandb += int_to_str(n)are laid down the same way. - The runtime is handed the block, not the builder.
rill_sb_growtakes the text's block and gives back a bigger one; the pointer, the length and the capacity are written by the caller. That is what lets a loop of appends keep all three in registers instead of reading the header back after every write — the store-to-load turnaround was most of what an append cost. - The header and the text are told apart. The bytes a builder holds live in a block of their own, so a byte written into the text can never land on the length; saying so in the IR is what makes the above hold across an append that grows.
- Digits go in directly. How many there are is read off the top bit rather than found by dividing, and they are written two at a time out of a table. The magnitude is carried unsigned, so the smallest integer needs no case of its own.
- A builder may be borrowed by an impl method.
render(b, x)was taking a reference and giving it back on every interpolation — a load, a compare and a store on the header, in the middle of the tightest loop a page has. strbuf_clear(b)empties a builder and keeps the room it took, for text built over and over into the same one.- The range test a
forover a buffer makes is written out rather than called for, with the call left for the case that ends the program. Both it and the growth path are marked cold, so what is laid down in the loop is the appends and nothing else.
Together these are about four times off the cost of building a page, and they show up anywhere a program writes text: the JSON benchmark in benchmarks/tokens builds its output through a builder, and every server in examples/ writes its responses through one.
The catalogue's grid groups, and the database does the grouping
Drag a column header onto the panel above the catalogue's table and the rows fold into groups, one level per header dropped — role › region › stock is three drops, and a chip dragged back onto the table takes its level out. A group opens on a click to the level under it, and the last level opens to the documents. Every level is one question to the server and the groups are paged the way documents are, so a hundred thousand groups is four thousand pages of them, not a screen that stops answering.
/rows?group=<field> is the question. The filters stay what they were, and the answer is what the matching documents come to under each value of that field: the count, the average score and the stock in total, as one pipeline in OxiDB — $match, $group, and $facet to split the result into the page asked for and the count of groups, so the pager has its total without a second trip. lib/db/oxidb.rill gains aggregate, which is the one call it takes. Nothing on the server reads a document, and nothing in the browser counts anything: what a group says is about the whole data set, not the window the flat grid holds.
The grid's side of it is three props — groupable, groupBy and a source that answers a level given the path down to it — and a column may say what it shows on a group row with aggregate. static/components/grid.js carries the change until it goes back to zap.
changea shape over a buffer, and NumPy behind it
lib/num/grid.rill: arrays of numbers with a shape, the way NumPy has them. A grid is one flat Buf(Float) and the shape laid over it — shape, strides, off — so transpose, rows, col and slice1 are views that cost nothing, and copy, reshape and every operation that makes a grid lay it out contiguously. Making: zeros/ones/full, arange, linspace, of, from_fn1/from_fn2, identity. Elementwise: add, sub, mul, div, scale, shift, power, neg, gabs, gsqrt, gexp, glog, maximum, minimum, and map/zip with a lambda. Comparisons make a Mask — a byte per element — gt lt ge le eq ne and gtk ltk gek lek eqk nek against one number, with where, mcount, any, all, mask_and/or/not, to_grid. Reductions: sum, mean, prod, gmax, gmin, argmax, argmin, var, std, dot, norm, cumsum, sum_axis, mean_axis. Linear algebra: matmul, matvec, outer, trace. Elements: at1/at2, put1/put2 in place. println(g) prints it. On a --parallel build with RILL_THREADS set, anything over a few million elements is split across the workers in strands.
bindings/python: the same library from Python — import grid as g, then a = g.arange(6).reshape(2, 3), a * 2.0 + 1.0, a @ a.T, a[a > 2], .sum(axis=0), np.asarray(a). gridlib.rill is a set of export fns built with rill build --shared; the package is a ctypes layer of operators, indexing and shape checks. Nothing is computed in Python.
The rest of what an array library is expected to have, all of it checked against NumPy value by value:
- The maths:
gsin,gcos,gtanand the inverses, the hyperbolics,gexp2,gexpm1,glog2,glog10,glog1p,gcbrt,gfloor,gceil,gtrunc,ground(to the even one on a half, as NumPy rounds),gsign,gsquare,greciprocal,gdegrees,gradians,gclip,gmaxk/gminkagainst one number, andgatan2,ghypot,gfmod,gcopysign,gpowgover two grids. - Shape and joining:
zeros_likeand its family,squeeze,ravel,logspace,geomspace,concat,vstack,hstack,stack,tile,repeat,diag,tril,triu,meshgrid,fliplr,flipud,flip,roll,take. - Order:
sort(quicksort, median-of-three, the larger half left to the tail call, so the stack is logarithmic),argsort(a stable merge sort over the positions),distinct,searchsorted,nonzero,count_nonzero,median,percentile,quantile,ptp,diff,cumprod, and thenan-skipping reductionsnansum,nanmean,nanmax,nanmin,nanargmax,nanargmin, withisnan,isinf,isfiniteandisclosegiving masks. - Solving:
lufactors a matrix once with partial pivoting, sixty-four columns at a time, its trailing update and the substitutions afterwards going through the sameFloat2kernelmatmuluses;solve(a vector or a matrix of right-hand sides),inv,det,matrix_power(by squaring),lstsq(through the normal equations),norm1,norm_inf,norm_fro,cond,kron,cross,innerandvdotare written on it. A singular matrix stops a program with a message and hands a host an empty grid instead, whichgrid.solvein Python raisesLinAlgErroron.
solve, det and inv on a 400 × 400 matrix went from 43 ms to 6.3, which is level with LAPACK through NumPy on one thread. Four things did it, in the order they mattered: the trailing update of the factorisation goes through the same Float2 kernel as matmul and runs at 51 GFLOP/s; the block of columns being factored is copied out transposed first, because a column of a row-major matrix is one number per cache line and the pivot search, the scaling and the rank-one update all walk one; inv exploits the identity's shape rather than solving against it, which is a third of the forward pass rather than all of it, at the price of putting the answer's columns back in order at the end; and what is left is divided between the workers in pieces sized by the arithmetic in them, since starting ten strands costs thirty-five microseconds and four costs five. On all cores OpenBLAS is still ahead, 5.3 ms against 6.2.
sort above a few thousand elements sorts by the bits rather than by comparing: a float's order is its bit pattern's order once the sign is folded in, so the numbers go into place a byte at a time, in eight counting passes over two buffers, with one histogram pass serving all eight. There is nothing to mispredict, which is what a comparison sort spends its time on, and it runs 2.3× faster than NumPy's sort where the quicksort it replaces ran 1.4× slower. Below that it is still a quicksort, since the passes and the two buffers are not worth it for a short range. median and percentile select rather than sort, as NumPy's do.
benchmarks/numpy/ measures the library against NumPy on twelve programs, on one core and on all of them. On one core Rill is ahead on eight and level on four: two hundred thousand small operations 11×, sorting 2.3×, reductions and moving numbers about 1.9×, a mask and a where 1.8×, order statistics and a matrix product 1.1×, and level where both sides stream memory at the machine's limit, call the same maths library, or solve a linear system. On all cores NumPy keeps two — a threaded matrix product and a threaded factorisation. The whole process is 2–12× shorter on all twelve. benchmarks/numpy/README.md has the table and the reasoning.
The language and compiler, on the way there:
Float2: two floats in one register, with+ - * /on both lanes,float2,splat2,lane0/lane1,f2_load/f2_storeandfma2. A kernel written in it says its vectors; the grid's matrix product keeps a 4 × 10 block of answers in twenty of them and runs at the speed of a BLAS.unique(x): whether this frame holds the only reference. A value asked about is passed owned, so a caller still using it keeps its own reference and the answer isfalse. What lets an operation write into a temporary's storage.- A binding's last use moves it into the call — no retain before, no release after, the callee the one holder — on the statement's unconditional path, when the name is not spoken again.
- Tuples hold up to eight values (
Tuple5–Tuple8), so a reduction can carry eight partial sums in eight registers. - Flat values cross a call as their fields, one parameter each, instead of one aggregate: a loop carrying a tuple of sums is a function tail-calling itself with one, and LLVM's aggregate
phifor it hid the sums from the vectorizer. sqrt,fabs,floor,ceil,trunc,round,rint,fma,fmin,fmax,copysigndeclared from libm are the instruction, not the call: C'ssqrtmay seterrno, which is why a loop of them never vectorized.- A
forloop whose body calls nothing is not preemption-checked: the check is a volatile load and a branch per element, and a program that spawns anything had it in every loop. workers(),buf_float_uninit(n)(a buffer about to be written in full, no clearing pass), and a runtime cache of the last few big blocks freed, so an array library's eighty-megabyte results are not page-faulted in afresh every time.rill build --shared: a shared library whoseexport fns a C or Python host calls. Counted values cross as handles, lent in and handed out, withrill_drop_<Type>for the host to let go by;mainruns asrill_init.--emit-irhonours--unchecked.- A worker with nothing to do no longer takes every other worker's queue lock on every turn of its idle loop; it asks the queued count first. Six idle workers were spending a busy worker's cache line on a question whose answer was no.
examples/oxidb/catalogue.rillputs the two together: a hundred thousand documents seeded into OxiDB's document engine and served over HTTP./is a page a browser can use — a form for the filters, a table, and a pager that carries them from page to page — and the same server answers/count,/doc/42,/find?role=eng&limit=3and/top?by=score&limit=5as JSON. A thousand documents to a frame puts them in in 624 ms; four indexes take 466 more; then a page renders in about half a millisecond, where without the indexes finding one document takes 15 ms and sorting by a field 471. Paging by offset is what a URL wants and what it costs: page 1 in 0.4 ms, page 1000 in 1.4, page 3900 in 95. Neither library knows about the other — what is written is the joining.
Under a hundred concurrent searchers the first version of it fell over, and the reason is worth the example: it opened a connection to the database per request, and at four thousand requests a second the closed ones piled up in TIME_WAIT until the machine had no ports left — nine replies in ten were 502. The connections are now sixteen, handed round on a channel, and a request that finds none waits. A hundred strands on one worker then answer 5,600 searches a second with half of them inside 18 ms and none failed; five hundred at once is the same throughput with the queue in the latency. examples/oxidb/search.lua is the mix wrk was given.
http.rill learned what a reply is as well as what it says: Response carries a content type, and text_response, html_response and json_response name the three a small server needs. A browser shown text/plain renders the tags instead of the page. oxidb.rill gained count_where and find_page — how many match, and one page of them — both of which the server had all along.
helloreads the reply the server actually sends. The handshake is the one command answered before the server has looked at what format the request was written in, so it comes back as JSON whether or not the rest of the connection is binary, and in the server's own shape rather than the usual envelope. The client read it as OxiWire and refused it. It now reads either, andserver_name,server_version,wire_versionandserver_featuressay what came back. Found by running all five clients against a realoxidb-serverrather than the stand-ins.sock_port(fd): the port this end of a socket is on.listen_on(0)asks the kernel for a free one, and until now nothing could ask which it gave — so a program that wanted a port had to name one, which is a promise about a machine rather than about a program.examples/http/server.rillandexamples/echo_server.rillnow let the kernel choose; both used to answer with whatever else happened to hold 8080 or 7878.rill fmtno longer invents a blank line after a statement written across several lines. What it prints is anchored to the line a construct starts on, so the lines a multi-line statement left behind looked like a gap the author had left; it now asks whether any of them was actually blank. Three files inlib/that the formatter had been unable to leave alone are formatted.!=on floats is now unordered, as C, Python and IEEE 754 have it:x != xis true of a NaN, where it used to be false.==is unchanged — a NaN is equal to nothing, itself included — and nothing else about comparison moved. It is how a program asks whether a number is a number.
Building it found four things in the compiler:
- A record's field indexed in a loop —
g.data[k]— was retained and released on every element, and the release's branch kept the loop from vectorizing. A field of a box the frame holds is now read without a count taken for the look. - A
forbody that indexes a record's field reads the field into a name of its own before the loop, and an indexv[e + i]with a loop-invariante—a[i*n + j]in a matrix loop — is bounds-checked once for the whole loop, asv[i]already was. - Bindings inside a non-tail
ifbranch's block were released at the function's exit, on a path where they never existed. A block's bindings are now released when the block ends — by identity, since a call in the block may have been handed one of the frame's own. - Three faults a module imported under an alias showed: an
implinside it did not find its own type; a type annotation in it (g: Grid) was taken for a type parameter, the alias being lowercase; and aforbody that read a field of a captured record (p.x) did not capturep. All three fixed.
changebytes in, bytes out
Seven disk and network programs beside their Go twins (benchmarks/io/), and what the first run found:
- Standard output is buffered, the way C's is: a line at a time when it is a terminal, 64 KB at a time otherwise. It is written out before anything that waits — the program ending (through
atexit, so a C callback that prints aftermainis not lost),exit, a read of standard input, a strand parking on a socket or a timer — and before an abort, so the message that explains the abort follows what the program said. Two millionprintlns were four millionwritecalls; they are now 2× faster than Go's buffered writer. A--parallelbuild with several workers writes as before, unbuffered, since the workers share no buffer. read_lineno longer copies each line through a scratch block: the line is nearly always whole in the block last read, so it is onememchrand one copy. Standard input is read 256 KB at a time.- Copies of up to sixteen bytes are two overlapping word moves instead of a byte loop; every short string in the language got quicker for it (json 144 → 124 ms, dijkstra 105 → 91).
sock_readkeeps one 64 KB scratch per worker instead of allocating and freeing one per call.s[i]insidefor i in 0..#sis read without its per-byte test — it is in range by construction — while every other string index keeps its-1past the end.- A socket's registration in the poller outlives the wait that made it. On one worker a descriptor is registered once, edge-triggered, for the strand that waits on it, and re-registered only when a different strand comes; before, every wait was a
keventto arm and akeventto wait. An event for a descriptor the strand has since left is recognised and dropped, and a closed descriptor is forgotten so its number can come round again. Sound because every wait follows anEAGAINfrom the operation itself. Linux keeps its one-shotepollregistrations for now; several workers keep the one-shot path everywhere. sock_buffered(fd)andsock_flush(fd): an opt-in 64 KB write buffer per socket.sock_writequeues; the queue is written when it fills, onsock_flush, whenever the strand parks (a read, a channel, a timer — so nothing a program wrote waits on the program), onsock_close, and at exit. A stream of small writes is one system call for many; an echo loop is unchanged. Nothing is buffered on several workers.- The accept loop in the language reference spawned a strand around the accept and so never waited:
spawn handle(tcp_accept(s))spins. It readsconn = tcp_accept(s)first now, and the concurrency section says why.
changeas fast as the C
A day spent putting nineteen programs beside their C twins (benchmarks/c/) and closing what it found. The morning's table was three wins, four ties and twelve losses; the evening's is eight wins and eleven within run-to-run noise, nothing behind. What changed, each measured alone:
- Every Rill function but
mainand a wasm export isinternalto its module, so LLVM inlines it freely — which is what let a matrix multiply's inner loop lose its bounds checks and vectorize (65 ms → 20, C's 20). - Borrowed parameters. A buffer, string, builder or map parameter that a function only reads is passed without a reference of its own and released by nobody; the retain the call took and the release its callee made were a load, a compare and a store each on the same header, in the inner loop of every sort and heap. Tail calls stay tail calls: only a borrowed parameter of the calling function may go through a borrowed position there, and anything else forces the callee's parameter back to owned.
- Pipeline fusion.
range |> filter |> map |> sum(andlen,fold), over lambdas written in place and scalar types, is one loop with no list: 83 ms and 154 MB became 5 ms and 1 MB. - Flat records. A type with one constructor and only scalar fields is a struct in registers — no box, no count, no tag. A function returning
(q, r)used to allocate; 100 million of them ran in 559 ms and now in 288, exactly C's. Such a value is boxed only at the edge of a table or a channel. - The allocator's fast path is generated inline: a free-list pop or push, three instructions, with the runtime asked only when a list is empty.
- Tables know when their key is an
Intand their value a scalar, hash and compare in place, probe once on insert, and answermap_orin a single call — forIntkeys, in generated code with no call at all. TheInthash is one multiply and a fold. Two million inserts and lookups: 193 ms → 97, against a hand-written C table's 105. b += int_to_str(n)writes the digits straight into the builder, and the decimal formatter behind everyint_to_strandprintlncounts digits by comparison and writes two at a time;b += byteis inline when there is room. A buffer's length is written where LLVM can read it, so loops it can relate tonlose their checks. A closure call no longer retains and releases its callee.- A lambda written in place for a parameter that never escapes — the prelude's
map,filter,fold— borrows its captures: its environment holds no reference of its own, since the frame that made it outlives the call. Not in tail position, where the frame has already let go. - Bounds checks hoisted out of
for. A body that indexesv[i]by the loop's own variable gets one test in front of the loop —lo >= 0andhi <= #v— and none on the elements. The check is emitted only whenvis a buffer; a string keeps its per-byte test, sinces[i]past the end is-1by contract. An out-of-range loop now stops before it starts, with the index it would have reached. strbuf_str(b)hands the builder's block over as the string whenbis never named again in its block, instead of copying it; the builder is left empty and usable. A builder read twice is copied as before.rill build --unchecked(andrill run --unchecked): no bounds test on buffer accesses, for a program that has been run checked first. The trade Zig's ReleaseFast and Swift's -Ounchecked make; off by default.- A buffer value is the address of its first element; its count and length sit in the sixteen bytes before it.
v[i]is one register-indexed load, where the header in front cost anaddon every element of every scan. Foreign code that took a buffer as aPtrsees exactly what it saw. - Borrowed data parameters. A list or record parameter that a function only matches on or reads fields of — and whose type the body never builds, so in-place reuse has nothing to lose — is passed without a count of its own; so is a binding a
matchtakes out of it. Walking a tree or a list no longer touches a count per node. map(xs, \\x -> …)over numbers or flat records is a loop: no closure, no call per element, the result built forward with no second pass.rill fmtlost the body ofv[i] = for … with …— it printed.... Found when it ate a benchmark. Fixed.
changea socket that does not wait to be asked twice
sock_nodelay(fd) turns Nagle's algorithm off, and says whether the kernel took it. It is a call rather than a default because it is a real trade: Nagle holds a small segment until the previous one has been acknowledged, which is right for a file being copied and wrong the moment a program sends small messages on a clock faster than the round trip — every message then waits on the one before it, and the wait is the difference between the two.
Found while measuring a game server: nothing in the language could reach the option, so the delay was neither visible in the code nor measurable from inside the program. On the valley's own numbers it costs nothing today — the tick is fifty milliseconds and the round trip thirty-six, so each write is acknowledged before the next is built — and it starts costing the moment the tick goes under the round trip, which is exactly the change that was being considered.
The two constants agree across the Unixes and are written out once, unlike SOL_SOCKET and its neighbours; the call is verified on macOS and on Linux, where a descriptor that is not a socket answers 0 rather than reporting success.
changea scratch handed back
The binary wire's float-to-bytes conversion took an eight-byte alloc_bytes scratch per message — and alloc_bytes is deliberately uncounted, so every snapshot, every input and every event broadcast left eight bytes (and a malloc header) on the floor for ever. Invisible at a glance and unmissable in a soak: sixteen players for nineteen minutes walked RSS from three megabytes to sixteen in a perfectly straight line, about half a megabyte per ninety- second session, while an idle server and six hundred join/leave cycles both held flat — which is what pointed the finger at the traffic rather than the connections. Every scratch is now free_ptr'd before its function returns, and the same soak holds level after warm-up. The test rides along as wt_load.rill (sixteen concurrent players) and wt_churn.rill (three hundred joins and leaves), both against a live server, both read with ps.
Two production faults in one evening, each found by somebody standing at the door of a live server.
- A ring cursor outlived its match. The server's tick reads every ring cursor before the world steps and sends the difference after;
new_matchempties the rings. When the step that ends a match falls between those two, the old cursor is a count the new match may never reach, and the send loop — which walks upward until it equals the cursor — walks for ever, broadcasting as it goes. The hub never comes back: nobody new is admitted, nobody quiet is dropped, and the process reads as one pegged core with no syscalls, which is howperffound it (broadcast,strbuf_byte,offer, round and round).since(from, now)clamps a cursor from behind a reset to nought, where the new match's events genuinely begin. ws.rillread header names with their case on. Header names are case-insensitive (RFC 7230) and always were; what changed is who was asking. HTTP/2 lowercases every header it carries, and a handshake that has been through Cloudflare or an h2-fronted nginx arrives assec-websocket-key:— which a case-sensitivestr_finddoes not find, so the upgrade was refused by silence and nginx turned the silence into a 502. The name is now matched case-blind and the value still read from the original bytes, because a base64 key is exactly the kind of value case matters to.
0.5.0 — the words a program reaches for
12 changes gathered
The release where the language grew its everyday vocabulary. Map(k, v), p.x, a guard after a pattern, ? for the error that only wants passing on, add(2, _) for the argument left for later, and a character layer over the byte strings — none of them new ideas, all of them words a program reaches for in its first ten lines, and each arrived because a real program had just reached for it and found it missing. The sections below this one tell each of them properly.
Underneath them, two promises were made hard. A tail call is musttail on its own calling convention now — a jump by contract rather than by an optimizer's mood — which is what lets the valley's server recurse for a day without a stack. And the compiler stopped being a closed box: rill check reports everything one pass can find, rill types says what was inferred, and rill lsp hands both to an editor.
The valley kept being the proving ground, and the release closes with two things it asked for:
- The wire is binary. One message per frame, little-endian, and the first byte is the letter the text protocol used — a snapshot still starts with an
sin a hexdump. The layouts live in one place,server.rill's "bytes on the wire" section, andnet.jsmirrors them field for field; a snapshot went from about 70 bytes a player to 37, and neither end pays forfloat_to_stror the parse back.ws.rilllearned to send binary frames (ws_write_bin,client_frame_bin) without giving up text, andwt_wire.rillis a client that checks the whole door sequence against a running server, byte by byte. - A shot-at wall settles. Stones above the hole used to keep their height, hanging in the air on nothing. Now every break packs the column down — each standing stone drops to sit on however many standing stones are left below it, on the ground if there are none. Computed from the broken set alone, like the rubble's resting place, so a client that arrives an hour late and replays the history ends with the same wall as everybody who watched it come down. Settling happens after the whole blast has had its say: settling mid-loop fed the survivors downward into the blast sphere one course at a time, and one rocket ate the column to the top.
changean argument left for later
\x -> add(2, x) is a lot of notation for very little thought.
add(2, _)is the function you get by leaving that argument to be given later. Written out as the lambda it means, inlift, so everything after that pass sees an ordinary closure.- Any position, not only the ones at the end:
bol(_, 2)divides by two, which currying cannot express without writing the lambda out. Two underscores make a function of two arguments, in the order they were left. - Constructors and builtins too:
Daire(_)andint_to_str(_). The second matters — a builtin was not a value at all before, somap(l, int_to_str)said "unknown variable" and there was nothing to write instead but a lambda. - Too few arguments is still an error.
add(2)says so. That is the reason for the underscore rather than bare under-application: otherwise every miscounted argument list becomes a value of the wrong type and the complaint arrives several functions away.
Not yet: calling the result immediately. add(2, _)(5) does not parse, because calling what a call returned is not a shape the parser has.
changea field is answered at the end, not where it was written
p.x used to be resolved the moment it was read, so it depended on the order the statements were in: a = uzak(p) then p.x compiled, and the same two lines the other way round did not, though the second line says exactly as much about p as the first. Reordering two lines changed whether a function compiled.
.xis deferred. The field name is carried on the node and an obligation is recorded against the type; both are settled once the whole dependency group is inferred, which is the first moment the answer can exist. Settling happens before the numeric and ordering requirements are discharged, sincea = p.xfollowed bya + 1leaves a requirement on whateverais and only the field says what that is.- The annotation is still needed where inference never learns the type — a function meant to work on any record — and that is what the error says now, at the end, having actually looked.
- Record update is not deferred: it needs the whole field list, and the shape of its expansion depends on the answer, so
{ p | x = 1 }still wants the type known where it is written.
changethree things a match could not say
1 | 2 -> ...: arms that go the same way say so once.liftturns one arm holding alternatives into one arm per alternative before anything else sees it, so exhaustiveness, reachability and guards go on working unchanged. Alternatives must bind the same names — the body is written once, so a name it uses has to mean something whichever alternative was taken. Top of an arm only.hepsi @ Daire(r) -> hepsi: names the whole of what a pattern matched, so an arm can take a value apart to ask about it and hand back the value it was given rather than building an equal one. Nests, unlike|.{ p | x = 9 }: every fieldphad, apart from those named. Becomes a call to the constructor; the base is bound first, so it is read once however many fields are carried over. Needs a single-constructor type, the rule.xalready has.
A name is a binding and not a test, so everything that asks a pattern what it tests for reads through the @ first. One place deliberately does not: the optimisation that lets an arm build over the box it took apart, which an @ name is precisely something that can still be holding.
Also: the rejected-source test helpers all wrote to one file per process, so two of them running at once — the harness runs tests in parallel — could read back each other's source. Latent until three more callers made it show.
changea byte is not a character
#s is ten for "günaydın", which is eight letters, and s[0:2] cut the ü in half and handed back something that was not text. Bytes were all there were.
- A character layer in the prelude:
char_count,char_at,char_sub,char_take,char_drop,chars,from_char,from_chars, andchar_decode/char_wide/char_offsetfor stepping through byte offsets without counting from the start each time. #s,s[i]ands[i:j]still mean bytes, deliberately. A lexer wants bytes and a wire protocol wants bytes, and the compiler that compiles the prelude is one of them: changing what#counts would change what all of them count. The characters say what they are in their names instead.from_charspells a codepoint in UTF-8, one to four bytes.chrwrites a single low byte and still does — right for building bytes, wrong for every character past 127.- Written in Rill on top of
str_getandstr_sub, so the runtime is unchanged; malformed input terminates and never reads past the end of the string; and nothing that does not call it pays for it — a wasm build of the valley came out byte-identical.
changea tail call is a jump, and now it is a promise
The valley server segfaulted three times in two hours, disconnecting everybody in it each time. The core said what it was: eight thousand stacked frames of reader, gate_on, handle_line, route — the loop that reads one connection, every call in it in tail position, every one of them supposed to be a jump.
- Rill's own functions use
tailcc, LLVM's guaranteed-tail-call convention, and a tail call is emitted asmusttail. The C convention could not keep the promise this language makes about tail calls:tailis a hint there, and the optimizer may take it away. It does so in a shape that is not rare at all — when every path through a loop returns the same value, the returns are folded into one block, and a call that no longer ends its function is not one the backend will jump to. That is what every loop written for its effects looks like, and it cost one frame per message until the strand ran off the end of its stack.musttailis an invariant instead of a hint: no pass may break it, and undertailccthe two functions need not even share a signature — which is what a loop written as several functions calling each other needs. - Everything the runtime calls keeps the C convention:
main, whose address goes to the scheduler; the body of aspawn; an environment's drop function; a map's vtable; and every trampoline C itself calls. A closure is reached through a pointer, so lambdas, the shims that wrap named functions, and the indirect call itself all use the one convention and can be sure of each other. On wasm nothing changes: a tail call there needs a feature the module may not be allowed to declare. - Measured on the machine it mattered on: before, the reader cycle compiled to four calls and five jumps on x86-64, and the server died at about eight thousand messages on one connection. After, it is one call — the one that enters the loop — and eight jumps, and the deployed valley took twenty-four thousand messages on one connection without growing.
a_tail_call_cycle_runs_in_constant_stackis the test that would have caught it: three functions calling each other in tail position, two hundred thousand turns, every path returning the same thing.
changethe compiler, kept open
A language that infers everything is a language whose types are somewhere the program does not say them. Three things say them now.
rill checkreports everything one pass can find rather than the first thing. A function whose body does not check is recorded and stepped over — its signature stays provisional, which is a fresh variable at every call, so the functions after it mostly check as though it were fine and each says its own first problem. Three mistakes take one run to learn about.rill types <file.rill>prints the signature the checker worked out for every function in the file, with the requirements the body imposed:fn merge(a: List('a), b: List('a)) -> List('a) # where 'a is ordered, over a source line that says nothing about being ordered.rill lspis a language server, built into the compiler and speaking the protocol on stdin and stdout. Diagnostics on the buffer as it stands — nothing has to be saved, since the loader will take the entry file's text from memory — the type of the name under the cursor, go to definition, an outline, and formatting.editors/README.mdhas the three lines each that Neovim, Helix and Emacs need; VS Code still wants a client extension, which is not here yet.- What the server answers is worked out from names rather than positions, because the AST records the line a thing is on and not the column. So a diagnostic underlines a line and hover reads the identifier written under the cursor. That is exact for anything declared at the top level and right for a local wherever one name in a function means one thing; a name bound twice in one body shows the first. Columns in the tree would fix that, and are a bigger change than this one was.
- The protocol is spoken without a dependency:
crates/rill_cli/src/json.rsis a value, a parser and an escaper, which is as much JSON asinitializeandtextDocument/hoverneed between them. This toolchain has one dependency and it is LLVM.
changea loop inside a loop counted the wrong one
- Nested
forloops were wrong, and had been sinceforexisted. Both the end of the range and the loop function it expands to were named the same thing every time —for.hiandfor— so an inner loop's end answered for the outer one's:for i in 0..3aroundfor j in 0..5ran the outer body five times, and every count above it was wrong by whatever the inner loop said. Each loop takes its names from the same pool the lifted functions use now, so each is its own. Three levels deep,withinside plain, and two loops both counting withiall give the answers they read as. examples/loops.rillgained theforform it never had and the nested loop that would have caught this, and stopped saying Rill has nofor.
changeguards
An arm can say what a pattern cannot.
pattern if cond ->: the arm is taken when the pattern matches and the condition holds, and a false one falls through to the arms below — which the value is then matched against as though the guarded arm were not written.- A guarded arm covers nothing.
Cons(x, _) if x > 0leavesCons(_, _)to be answered, and a match that does not answer it is not exhaustive; the checker names the case as it always did. The other side of the same rule: nothing below a guarded arm becomes unreachable, while an unguarded arm above still makes things unreachable. - The decision tree keeps its shape. A guarded leaf carries what to do when the condition is false — the rest of the match, built in the same value context — so an unguarded match compiles exactly as it did, and a guarded one pays by testing the arms below it again rather than by going linear. A guard is asked at the leaf, with the pattern's bindings borrowed out of the box the way the arm's own are, which is what keeps a tail call in a guarded arm a tail call: two million of them run in constant stack.
examples/patterns.rillgained three functions and the sentence that goes with them.
changefields have names, and names can be read
A constructor's fields could always carry names — P(x: Int, y: Int) says which number is which — and nothing but a human reader could use them. They are a read now.
p.x, on any type with a single constructor. That is the whole condition, and it is the one a binding already had:P(x, y) = preads as a binding because it cannot fail, andp.xfor the same reason. A type with several constructors has no field that is certainly there, and the error says so, naming them.- Anywhere, not only on a name.
mk(1).y,p.pos.x,find(n)?.y. A dot written against a name is still part of that name as far as the lexer is concerned —geo.areais one too — and the checker tells a module alias from a value with fields; a dot after anything else is a token of its own now, which is what makesf(x).yparse. - A field is read, never written.
p.x = 3used to parse as a binding calledp.xand quietly shadow nothing at all; it is refused now, and says what to do instead: build the record you want, which is what changing a value means. - The one thing it asks is that the type be known where the dot is written. Inference does not work backwards from a field name — two types may both have an
x— so a parameter read this way is annotated, and the error names it. examples/records.rillis a player, a position inside it, and the four lines that make a hurt one;CtorSigkeeps field names for it.
change`?`
Errors are Result values and always were. What that cost was a match per call, two of whose three lines were the same two lines every time.
e?is the value inside theOk, or else the whole function'sErr. It is amatchand nothing else:lifttakes the rest of the block as theOkarm and addsErr(e) -> Err(e), before the checker sees the program. Nothing is added at run time, an error travels no further than it did, and a tail call written after a?is still a tail call — two million of them run in constant stack.- Anywhere in an expression.
number(field(text, "port")?)?names the inner result first, in the order the expression evaluates. A statement of its own (field(text, "host")?) and a binding that takes the value apart ((a, b) = pair()?) work too. - One rule, and it is the one the desugaring implies: a
?belongs where the block it is in is what the function returns, since that is the door itsErrleaves by. In a branch or an arm that is the function's answer, it works. Written where the block is worth something else, the error names that something else — the generatedmatchnever appears in a message, because a program that did not write it should not have to read it. - On anything but a
Result, the error says what to write instead:matchon anOption, orunwrap_orfor a default. There is noFromconversion between error types either, and none is wanted:Err(e) -> Err(e)carries the same value out. examples/results.rillis the whole of it — a four-step parse where every step can fail, the failures coming out of the middle with nothing written to carry them. Andexamples/showcase.rill's interpreter lost itseval2: the five lines that evaluated both sides of a binary operator and passed the first error along are three now, and print what they always printed.
changea buffer is its address where C wants one
buf_data(b) said the same thing at fifty call sites: the first element's address. It is now what handing the buffer over means.
- A
Bufdecays to aPtrwhere one is wanted — a foreign parameter, apeekor apoke, a Rill parameter annotatedPtr. This is C's own rule for an array, and it is the only place in the language where a value is not the type it was written as.glGetShaderiv(sh, status_name(), status)reads the way the C does, andstatus[0]reads it back. buf_datais gone, and the fifty calls to it with it. Nothing else changed at those sites: the buffer stays counted, stays the caller's, and the address it lends is still only good for the call.- A buffer built in the argument no longer dangles.
strlen(buf_u8(4))used to hand C an address and release the buffer before the call — the block went back to the pool with C still reading it. A buffer decayed at a call is released after the call now, which is the only reason the shorter spelling is safe to encourage.examples/cocoa/cocoa.rillhad a comment warning about exactly this;frame_ofhands back the buffer instead of a pointer into it, and the warning is gone. - The one thing decay does not do is make a buffer a pointer anywhere else: a binding, a branch, a data field and a channel all still say which they hold.
changehash tables
Rill could look a thing up by walking a list or by keeping a sorted tree, and both are in examples/showcase.rill because there was nothing else. Map(k, v) is the thing that was missing: open addressing, linear probing, and a key type the compiler works the hashing out for.
Map(k, v), the second thing in the language that is storage rather than a value.map_new()makes one,m[k] = vinserts or replaces,m[k]answersSome(v)orNone— a key may not be there, and absence is anOptionhere as it is everywhere else —#mis how many entries, andmap_del,map_has,map_or,map_keys,map_values,map_items,map_ofandmap_clearare the rest of it. Both type arguments are inferred; unlike a buffer's width they are ordinary types.- Any value type is a key. A number, a string, a tuple, a data type, nested as deep as it likes. The compiler generates
hash$Tbeside theeq$Tit already generated, walking the same fields in the same order — which is what makes the two agree. A function or a channel is refused where the key is written, not somewhere later. - One table for every instantiation. The probing loop is in the runtime and knows nothing about what it holds; what it cannot know — hashing a key, comparing two, counting a key's and a value's references, and how wide a value is — is compiled per instantiation into a constant vtable beside the program.
- Seventeen bytes an entry. One control byte says whether a slot holds anything and, if it does, carries seven bits of the key's hash; the key and the value are sixteen more, side by side. A probe walks the control bytes — two megabytes for two million slots, which stays in cache while the slots cannot — and touches a slot once, for the one key it believes it has found. A key is one word, because what has structural equality fits in one; a value is one too unless it is a closure, which is the only Rill value needing its code and its environment, and which of the two comes from the vtable. Not keeping the whole hash costs a rehash of every key when the table grows, which is about a tenth of the time it takes to fill one.
- Counted like a string. What a table holds is released when the table is, including the entries a
map_delor amap_cleardrops. Every map program in the tests ends onlive allocations: 0, including one that inserts, deletes and reinserts five hundred keys. - Ahead of Go's map on all three. A million
Intkeys: 26 ms to insert against Go's 61, 22 ms to read back against Go's 23, peaking at 73 MB against Go's 85, on an M4. Half a millionStrkeys, string building included: 28 ms against 58, 20 ms against 37, 53 MB against 67.map_or(m, k, d)is the read for a hot loop —unwrap_or(m[k], 0)builds aSometo take apart again and costs about a third more. - Two errors say what to do.
println(m)is refused like a buffer, and points atmap_items(m).m[k] += 1expands tom[k] = m[k] + 1, where anOptionmeets a number — the message namesunwrap_or(m[k], 0)rather than leavingOption(Int)againstIntto be worked out. - wasm found an ABI sloppiness native had tolerated. A vtable's value retain and release are called through a pointer, so they take two words whatever the value is stored in — an extra argument a native call ignores is a signature mismatch to wasm, and traps. Both are two words now, and the first
wasm32-wasip1build ofexamples/maps.rillprinted what the native one does. - Codegen's own type substitution never looked inside a
Chan(a), so a generic function taking one was monomorphized with the element type left as a parameter. Nothing had ever depended on it — releasing a channel is the same whatever it carries — but a map's key type decides how the key is hashed, and found it at once. Both shapes substitute now.
0.4.0 — a valley with other people in it
0 changes gathered
The release where a Rill program was handed to strangers. examples/webgpu/world went from one browser walking around its own valley to one server holding a single world for everybody in it: the crystals, the rounds in the air, the craters, the shark and every player's health are the server's, and a browser is told about them rather than deciding any of it for itself. It is a few hundred lines of Rill on a socket, published at a name, with the word at the door and people who are not us behind it.
Nothing sharpens a runtime like that. The list below is mostly the language being found out by a program that could not be restarted quietly: a write to a peer that had gone raised SIGPIPE and killed the process, a read was assumed to be a frame and was not, a socket read had no deadline so a client that connected and said nothing held a strand for ever, a per-second budget stopped parsing but not reading and starved everybody honest, and — the one that took longest — a strand parked on a channel and a strand parked on a socket looked identical to the poller, so an event that had outlived its wait delivered a value that had never been sent. That last one read as heap corruption for weeks and was nothing of the kind.
The rest is the game: a skinned figure with a rifle that points where it is aimed, damage that depends on where the round lands, a scope, a roster, spawns that are not all the same patch of grass, and three defences against a modified client — a shot has to go roughly where its owner is looking, a name belongs to a token rather than to whoever typed it first, and a client is no longer told where somebody it cannot see is standing. The last of those is the only one a client cannot work around, because it is not a rule it is asked to follow.
A client is no longer told where it cannot see. The snapshot carried everybody's position to everybody, and no amount of care in the renderer can unsay that: a browser that has been given a coordinate can draw a figure through a hill. The server now works the line of sight out per reader — eye to eye, sampled against the terrain every three metres — and a player behind the ground arrives with their position blanked and a zero on the end of their block.
They stay in the roster: who is in the valley is not a secret from anyone standing in it, and health and the two counts are not positions. The cost is the one-line broadcast, which becomes one string per reader; measured with everybody running about it does not show, 20 snapshots a second either way and one per cent of a core at twenty-four players. What it buys is the terrain's to give, and this terrain is a bowl — about one player in twenty is blanked at any moment. It is also the only one of the three defences a modified client cannot work around, because it is not a rule it is asked to follow.
A name belongs to somebody now. The server mints a token on a browser's first visit, hands it back in
hello, and the browser sends it in the query from then on;who.txtholds one<token> <name>line per person and the name is refused to everybody else, including while its owner is away. The entropy is half the clock and half the sixteen random bytes a browser puts inSec-WebSocket-Key— the clock so one client cannot get the same token twice, the key so nobody else can work out what it was.It is not an account: never typed, not recoverable, and whoever copies it out of a browser is that player. It buys two things that were impossible while everybody was anonymous — a name that stays yours, and
banned.txt, read on every knock so that saying somebody is not welcome does not need a restart.write_filecould not create a file on Linux, and never could. The three flags it handsopenwere the numbers a Mac uses, and the difference is not that Linux calls them something else — it is that the same numbers mean other things there.O_CREAT | O_TRUNCfrom BSD reads on Linux asO_TRUNC | O_APPEND, with nothing saying create. Writing over a file that existed worked, which is why nothing noticed for so long; the first program to want a file that was not there yet found out.examples/io.rillnow writes to a path nothing has used before, so creating is what the test tests.fn bindin a Rill program takes the name away from the C library. Top level functions are emitted under their own names, so a program that defines one the runtime calls through libc replaces it — and the failure is a socket call landing in your own function, which is a segfault at startup with a stack that makes no sense. Renamed here; the compiler should be the one saying it, and does not yet.The server used to fall over about one arrival in ten, and it was the scheduler. A strand parked on a socket and a strand parked on a channel both had
selectedset to -1, and the poller took that one flag to mean "waiting on me". A registration can outlive the wait that made it — the strand was woken by the other half of a two-part wait, or the descriptor was handed on — and when that event was finally delivered, the strand named in it had usually gone on torecvon a channel. The poller made it runnable, andrecvreturned the two zero words its waiter was initialised with: a null string, handed to the first thing that asks a received line for its length.It read as heap corruption and it was not: nothing was corrupt, a value was delivered that had never been sent. Strands now say which of the two waits they are in, so an event with no owner is dropped. Eight crashes in ten runs of a connect-shoot-hang-up loop, then none in ten. The count of dropped events is in the
RILL_DEBUG_ALLOCreport, because it is a real thing that happens; what would be an error is acting on one.A run of shots that go nowhere near where the shooter is looking now costs the connection. Refusing them was the defence; counting them is what makes it worth having. Our client cannot produce one — it works the direction out from the same camera matrix it just sent the look from — so twelve in a row is not a bad connection, it is something other than our client on the socket. Out the same door as anyone who has gone quiet. It is a streak, not a total, and it is never logged as it climbs: a client can ask to fire a hundred and fifty times a second, and a line each would be a way to fill the disk from outside.
A round has to go roughly where its owner is looking. The look and the shot arrive on separate lines and the server kept only the latest of each, so a client could face north and put a round through somebody standing south of it while everybody else watched it stare into the distance. Twenty degrees of slack now — enough for a frame's lag on a fast turn, not enough to shoot behind yourself. It does not stop anybody aiming perfectly, which is between a player and their own mouse and which no server can see; it makes them turn to do it, and turning is the part everybody else can see.
check.rillfires one with the shooter facing the other way and it is refused.Keys are masked to the seven that mean anything. What arrives is whatever somebody typed into a socket, and a key that is not a key should not become one the first time something new is hung off the eighth bit.
Everybody comes in somewhere else. The spawn was a spiral round one patch of grass, which put everybody within sight of each other the moment they arrived and put whoever had just been shot back where they fell, in front of whoever shot them. It is a hash of who is arriving and when now, tried until it lands on grass between the waterline and the rocks — dry, walkable, and never the same twice.
The top of a crystal is a crystal. The four spheres that stood in for one left two holes: from 1.68 to 1.87 of its height, and from 2.13 to the tip at 2.40. The second one is the one anybody would notice, because the top tenth of a crystal is the part that stands clear of everything around it and is therefore exactly what people shoot at. Six spheres now, each at least as wide as the crystal is there and at least half the distance to the next, so the chain cannot have a hole in it.
A log down the bottom left. Everything that happened, oldest at the top, each line fading on its own clock rather than the list scrolling — a line still being read should not move because a new one arrived. And an announcement across the middle when somebody arrives or leaves, in the same shape as the hit line: in from half again its size, out by shrinking.
Where a round lands is now the whole of it. Three spheres stacked and tested from the top down, because the smallest is worth the most: the head ends it outright, the body takes a third, the legs take a tenth. The difficulty of this rifle has moved from how many times it is fired to where it is pointed, which is the difference between a shot and a sequence — and it is what the scope is for. Rounds stop at a leg on the page as well, from the same three spheres, so a man behind a rock is hit where he is showing.
And a voice. Being hit says so out loud: four of qubodup's CC0 recordings of himself straining, from OpenGameArt, a second each end to end in one 33 KB file and played by offset the way the glass breaks are. Louder for a bigger hit, and a hair of detune each time — a leg and a head are different things to have happen to you and should not sound the same, and the same file twice in a row is the one thing that gives a sample away.
The line pops in and shrinks away rather than appearing: a line that simply appears is read a beat later than one that moves, and a beat is all there is. It goes after a second, since it is one sentence about something that has already happened and it is sitting over the middle of the screen.
Who shot you, and who you shot. Low and in the middle, where the eye goes when something has happened to you rather than in front of you: the name in the valley's amber and the number bigger than the words around it. The two people a round concerns each get their own sentence — one of them has been shot and the other has hit something, which are not the same news — and the outgoing one is green rather than red, so which of the two it is does not have to be read to be known. A kill gets a skull and stays up half a second longer.
Whether a hit was fatal is the server's to say and rides with it. The shooter cannot work it out: the snapshot that says somebody is down says nothing about who put them there. "Hit" is a different thing to be told than "hit for a third of you", and in a valley where four people are firing there is otherwise no way to know whether the round that took you down was aimed at you.
It is a ring on the world and a cursor that counts up, the same shape as every other thing that happens between two ticks. Sent to everybody rather than to the one it happened to — it is three numbers — because a client that knows what happened to the person beside it can say so, which is the difference between a valley where things happen and one where they happen only to you.
Hold the right button to aim. The world narrows to five times: the far ridge is four hundred metres off and somebody standing on it becomes a figure rather than a suggestion. The rifle comes eleven centimetres nearer the eye and straightens out of its cant — close enough that the eyepiece is most of what there is to see, which is what an eye against a scope sees — the sway drops to a quarter, and the crosshair steps back to a quarter of itself, because behind the sights the rifle has its own and two at once is one more than anybody needs.
Looking down the tube of the rifle's own scope was honest and useless: the far lens is a centimetre across and the eye is twenty behind it, so what could be seen through it was a keyhole. So at the top of the aim the rifle goes and the scope becomes the screen — a clear circle two thirds of the screen high and black outside it. It opens as the aim comes up and closes as it drops, and it is a radial gradient over the canvas rather than a pass that would have to touch every pixel, because it is in the eye and not in the valley.
In the middle of it is a reticle rather than the page's crosshair: a duplex with a mil scale, which is what a rifle scope has and what makes one useful for anything besides pointing. The thick posts carry the eye to the middle from any background, the thin cross is what you aim with, and the ticks are a ruler — two mils apart, so a figure of a metre eighty subtends one of them at nine hundred metres and four at two hundred, and holding over is counting rather than guessing. Black glass over a pale edge, because a reticle that is only black vanishes against dark water and one that is only white vanishes against the ridge. The crosshair fades as the rifle comes up and is gone before the scope arrives: two opinions about where the middle is, is one too many.
All of it eased and framerate-independent: nine tenths of the way in about seventy milliseconds and settled inside an eighth of a second, measured at sixty frames and at a hundred and forty-four. A field of view that changes between one frame and the next reads as a cut rather than as somebody raising a rifle; one that takes a sixth of a second — which is where this started — reads as the game thinking about it. The mouse slows with the zoom, or the same movement of the hand covers two and a half times the angle and the sights are unusable. Letting go of the pointer lets go of the sights, since a held button cannot report itself released once the page has stopped hearing about it.
The left button still fires while the right is held, which is the whole point of holding it. The rifle was always drawn with its scope on the view axis, so none of this is a new pose — it is the same rifle, closer, steadier, and looking further.
A quarter off both speeds. Five and a half metres a second, and twelve with the sprint key, down from seven and a half and sixteen. At the old pace the valley went past faster than anything on the ground in it could account for, and a shot at somebody crossing the meadow was a lead nobody could judge. The run clip's playback follows — √(5.625/4) and √(12/4) — so the feet still land under the body.
Who has hit whom, down the left. Two numbers a row beside the health bar: rounds landed in green and rounds taken in red, the same two colours the bar uses, so neither needs a label. Both are the server's count and both are written down at the moment a round connects, because the second cannot be worked out from the first — a client can see somebody flinch but has no way of knowing whose round it was.
check.rillshoots somebody three times and reads both sides.A word at the door. The shared valley asks for one before it lets anybody in —
135for now. It travels in the query of the address the socket is opened to, so the server can refuse before it answers the handshake and before anything is allocated for whoever is asking, and nginx is told not to log that line: a password in an access log outlives the password. It is a latch and not a lock — one word for everybody, in plain sight inside the TLS, and anybody who has it can pass it on. It keeps the valley to the people who were told about it, which is all it is for.The word has to end where it ends, too:
135matching as a prefix would have admitted1350, and a word of1would have admitted everything.Giving the wrong one leaves you in a valley of your own, and now it says so rather than dropping you there quietly to wander about wondering where everybody went.
Nobody was being interpolated. The position between two snapshots was measured from the older of the two, so by the first frame after one arrived the sum was already past the newer one and the clamp pinned it at the end. Everybody stood still for a twentieth of a second and then jumped, twenty times a second — which on a fast screen reads as a flicker between positions rather than as walking. Measured from the newer one, which is what the comment above it always claimed, every frame moves: at 60 Hz not one frame in three hundred stood still, and the largest step was 1.3× the median.
The interval between snapshots is smoothed as well, so one late packet does not make everybody sprint to catch up and one early one does not leave them standing.
Nobody walks in this valley. Moving is seven and a half metres a second, which is faster than anybody has ever run, and the walk clip under it made the legs stroll while the body flew. The run clip covers both speeds now, at two rates: the clip is a run at about four metres a second, and cadence goes about as the square root of speed — √(7.5/4) and √(16/4) — so the feet stay under the body at both. The clip's own time is carried forward frame by frame rather than worked out from when it started, or pressing sprint would throw a runner four seconds down the cycle mid-stride.
And the run looks like running. The armed pose was a flag: every joint from the spine up came wholly from the armed idle, so the legs pumped underneath a torso that did not move at all — the rifle was steady and the man carrying it was a shop dummy on rails. It is a weight now, one number a joint, baked from the rig: the arms and hands keep the weapon, the shoulders keep a little of the stride, the spine and head keep a third of it, and the pelvis keeps about half its lean. The barrel dips twelve to eighteen degrees through a running stride instead of nought or twenty-nine, and the chest moves twenty centimetres through the cycle instead of standing still.
A player on a fast screen watched their own ping climb past half a minute. 144 fps, and the page sent one input a frame — 144 messages a second against an allowance of ninety. Fifty-four a second went unread, waited in the kernel buffer, and everything sent after them waited behind: the ping is a message like any other, so what came back was the backlog and not the network. It read 36 seconds and was still rising.
Two things were wrong. The page now sends sixty a second whatever the screen is doing — already three times the tick rate, since the server walks from the last thing it heard, so an input between two ticks is the one that counts at the next. And the allowance is a hundred and fifty rather than ninety, with room above the sixty for the shots and the pings and for any client that does not know about the cap.
Measured before and after with a client sending a hundred and twenty a second: the round trip read 1,014 ms and climbing, and now reads 0. A flood of a million lines a second is still held off — the honest client keeps its full twenty snapshots a second through it.
The published page joins the published valley.
?joinwith nothing after it used to meanws://<host>:8091/, which on a page served over TLS is two things a browser will not do: a plain socket from an encrypted page, and a port that is not open. It now follows the page it is on —wss://<host>/valleyover https, and straight at the port over plain http, which is how it runs while it is being worked on. One path, no port, and the certificate is the one the page already came over.Behind a proxy, the valley knows who is really there. Put nginx in front — which is what
wss://requires, since the page is served over TLS and a browser will not open a plain socket from an encrypted one — and every connection arrives from 127.0.0.1. The limit of sixteen per address would then have counted the whole internet as one house and turned away the seventeenth person in the world.So the address is taken from
X-Real-IPwhen, and only when, the socket itself is the loopback: on that machine nothing but nginx can reach the port, and a header is something the client writes — one that could name its own address would invent a new one per connection and walk round the limit entirely. Checked both ways: seventeen through the proxy from one machine and the seventeenth is refused by its real address, eighteen straight at the port with a different invented address each and every one of them still counts as the socket it came from.The per-address gate moved from
acceptto after the request is read, since that is when there is a request to read it from. The total gate stays at the door, so a flood of half-open handshakes still cannot get pastmax_connections().A dead socket killed the server. Writing to a peer that has gone raises SIGPIPE, and nothing in a Rill binary handles it: the process died where it stood, no panic, no message, exit 141. Rust's own runtime turns SIGPIPE off before
main, but a Rill binary's main is generated code and never goes through it. So any client that walked away between one snapshot and the next could take the valley down with it — which is what a public port is full of, and it is almost certainly the unreproducible death seen once during testing a while back.It is turned off per socket rather than for the process, because a program piping into
headshould still stop whenheaddoes and that is a pipe. BSD says it once withSO_NOSIGPIPE; Linux has no such option and says it on every send withMSG_NOSIGNAL.A flood of messages stopped starving everybody else. The per-second allowance stopped the hub being bothered by the ten-thousandth message and did not stop the reader from working: the frame walk and the unmask happen before anything can decide a line is unwanted, so a client sending a million lines a second was still being read a million times a second. An honest player in the same valley got no snapshots at all for as long as it went on. Over the allowance the reader now sleeps out the rest of the second instead, which leaves the frames in the kernel buffer where they belong, the window fills, and the sender's own writes start blocking. Measured: the honest client went from 0.0 snapshots a second under the flood to a full 20.0, and the flooder got 32,000 lines away instead of a million.
Renaming stopped being an amplifier. Every accepted name goes out to everybody in the valley, so one client renaming as fast as it was allowed to speak was ninety lines a second times everybody here — the cheapest amplifier in the protocol. A name that is not different is not news now, and nobody may change theirs more than once every three seconds. Measured: 54.2 broadcast lines a second down to 0.6, with the same client still asking 108 times a second.
Ten seconds on the ground. Running out of health in a shared valley used to be a frame: the server stood you back up on the next tick, so the die clip never played and being killed was a teleport with a health bar behind it. Now you go over, you stay there for ten seconds, and then you are back at the spawn — long enough that it is an event, that your rifle is in the grass beside you and that whoever shot you walks past, short enough that it is not a punishment.
The camera goes down with the body. The eye falls from a metre seventy-five to a metre thirty-five below that, the horizon rolls a quarter turn, and it goes over on the side the last round threw the head — so the fall finishes the motion being shot started. A camera that cut to ankle height would read as a bug and one that stayed at eye level while the figure fell would read as a ghost, so it eases over three quarters of a second, smooth at both ends.
On screen: YOU ARE DEAD, and the seconds counting down under it in the largest numerals on the page. The count is the point of the screen — without it, being dead is indistinguishable from being broken. It counts against the same clock the server decides on, rather than a timer of its own that agrees for a while.
The rifle goes with them, and it goes on the ground. No viewmodel while you are down — a gun held out at arm's length in front of a camera lying in the grass is the one thing left on that screen still behaving as though nothing had happened — and in its place a rifle lying in the grass a pace ahead of where you fell, on its side, turned a little off the way you were facing, because one that landed square with its owner reads as placed rather than dropped. Everybody else's does the same: the die clip puts the hand on the ground and a rifle still gripped in it is a body holding its weapon at attention while lying dead.
This player's own is the one rifle in the valley with nobody to hang it off — you are the camera, there is no figure and so no hand — so the skinner keeps one spare matrix past the last person for it.
Somebody on the ground does not walk, does not look about and cannot fire. All three are in
walk, which both ends run, so the keys a dead client keeps sending are ignored the same way in both places rather than only where somebody thought to check. Getting up is a decision and so is the server's; the clock is kept by both, because a client needs it for the camera and for the number.Alone in your own valley none of this happens: there, running out of health is the end of the thing and there is already a screen that says so.
The rifle stopped lying on its side. The grip was levelled against the bind pose, which is the one pose in the whole library where nobody is holding anything: a bind pose has the palms turned down, every armed pose turns the wrist ninety degrees to grip, and a rifle squared up against the first therefore spends the entire game on its side. It is levelled against the pose people actually stand in now, and the barrel is laid along the direction the figure faces rather than the direction the forearm points — those differ by ten degrees, and ten degrees is the difference between a rifle aimed at what its owner is aimed at and one that is not. Everywhere else it follows the wrist, which is what a wrist is for. Standing and firing, the barrel now comes out exactly level and exactly forward.
Walking with it up. The library has a walk, a run and a crouch, and an armed idle, and no armed anything else — so in those three the arms swung like the empty arms they were animated as, and the rifle swung with them. The legs now take the clip somebody is actually doing and everything from the spine up takes the armed idle. The hips are below the split, so the pelvis still sways with the stride and carries the torso rather than nailing it to the world; that is why this is a mask on the skeleton and not two clips faded together. Walking, the barrel went from forty-nine degrees into the ground to five, and level.
The pelvis is masked with the arms, and it is the interesting one. Its rotation is what pitches a running body forward and the whole torso rides on it — masked from the spine alone, the rifle came out level and pointing twenty-five degrees into the ground, tipped by a joint underneath the mask. Its children stay behind, because the legs hang off the pelvis rather than off the spine, so they still run: the knee still swings sixty-four centimetres at a run and twenty-three at a walk, on an upright pelvis rather than a leaning one. The stride's rise and fall is a translation and is not masked at all, so a crouching player is still down at a metre and a running one still bobs fourteen centimetres. Standing, walking, running, crouching — the barrel now comes out level and forward in all four.
Which is why the mask goes into the file a joint at a time rather than as one index and a subtree: the rule has an exception in it, and the place to know about the exception is the tool that is reading the rig, not a page that has never heard of a pelvis.
A round stops at the person it hit. It stopped on the server, which is where the damage is decided, and carried on through them on every screen, which is where it is watched. A client has no player blocks but its own — everybody else arrives as a snapshot and is drawn from it — so there was nothing in its world for a round to hit. The page now writes down where it is drawing people, and rounds stop there.
It is not a decision and there is no health in that table: what a hit costs is still the server's word. And it is where they are drawn, interpolated between the last two snapshots, so a round stops at the body you can see. That is why it is not done off the health going down in the next snapshot instead — at a hundred and twenty metres a second, one tick of waiting is six metres of tracer past somebody's back.
DISCONNECTED, in the middle, in the largest letters on the page. Losing the socket looked like nothing at all: the valley carried on being drawn, the wind carried on blowing, and this player carried on walking around a world that had stopped existing for everybody else. Nothing is torn down — what is on screen is the last thing everybody agreed about — but it says so.
A round throws your head about. Being shot moved a bar in the corner and nothing else. Now the camera takes it: a roll, mostly, with a little pitch and yaw on top, settling out over four hundred milliseconds — three degrees of tilt reads as a head snapping sideways in a way the same angle spent on pitch reads only as recoil. The rifle rides it for nothing, being placed relative to the eye.
Nothing announces the hit. Health only ever goes down, so the frame it goes down on is the frame somebody was hit — which is how the page already picks the hit clip for everybody else, and it means the throw works the same whether the damage came from a round, a blast, or a server saying so. It goes on the camera and not on the yaw and pitch, because a round should throw the view about and not turn the player: what you were aiming at is still what you are aiming at when it settles.
check.rillshoots somebody and looks at the matrix, which is where a roll cannot hide.The rifle in their hands is the size of a rifle. It was a toothpick. The scale had been taken from
VM_SCALE, which is the viewmodel's number — the viewmodel is placed in camera space and projected through its own frustum, so what looks right there says nothing about how long a rifle is in the valley. It is now 1.10 m, measured against the 1.83 m mannequin holding it, and the scale that gets it there is worked out fromrifle.mesh's own bounding box rather than written down beside it. Where the hand grips is a fraction along the stock rather than an offset in metres, so moving it is one number.The sky is the server's too. The sun ran on each browser's own clock, so two people standing next to each other were in different parts of the afternoon — one of them shooting into a glare the other could not see. It now runs on the world clock, which is the number the server sends in every snapshot and which the wind and the water already ride on. Alone in a valley that clock is this browser's own count of the frames it has drawn, which is what it always was: a day does not pass while a tab is hidden.
A rifle in everybody's hands. The figure was firing an empty fist. What it holds now is the same
rifle.meshthe viewmodel draws — one rifle in this valley, not two — instanced once per person off a matrix the page works out each frame: the hand joint's own world matrix times a grip.The grip is derived at bake time rather than guessed.
bake_figure.pyfinds the right hand in the bind pose, takes the direction its own forearm points as the barrel's, world up as the rifle's up, and writes the result into the header along with which joint it hangs off — so the weapon follows the arm through every clip and the page knows nothing about the rig beyond one index. The bolt and the round do not animate out here: they belong to the gun in your own hands, ten centimetres from the eye, and at the distance somebody else is a bolt is two pixels.Standing still is now the armed idle rather than the empty-handed one, because somebody standing still is who you look at, and they are holding a rifle.
Everybody stopped walking sideways. The figures were placed at yaw plus half a turn, which mirrors rather than turns — so a person walked at a right angle to where they were aiming, and on a mannequin that is symmetric until it moves the mirror was invisible. The rotation that takes the model's forward to the direction Rill means by a yaw is π − yaw, and nothing else does.
Who else is in here. A roster down the left: everybody in the valley, the colour their figure is drawn in, what they are called, and a bar for how much of them is left. The tag over a head answers "who is that"; this answers "who am I in here with", which is a different question and not one you can answer by turning round. It is rebuilt only when a row arrives or leaves, so the bars slide rather than step.
How far away the server is. A round trip, once a second, shown top right and coloured at the thresholds where a player would start noticing one. The token is the browser's own clock handed straight back, so nothing has to be remembered on either side. It is answered from the hub, queued behind the ticks and the inputs like a snapshot is, rather than from the reader strand — a reply sent from there would time the socket and the kernel, and the socket is not what anybody is waiting on.
The day is six minutes long. Ninety seconds was a good number for somebody who would look at this for a minute and a poor one for somebody living in it: the shadows visibly crawled and the sun set while you were crossing the meadow. Three hundred and sixty is still nothing like a day and still shows a whole one inside a session somebody stays in, and the sky has stopped being something you can watch move — the light changes while you are busy and you notice it afterwards, which is what daylight does.
Other people are people now. The blocky figure of stacked cones is gone; what stands in the valley is Quaternius' mannequin off the Universal Animation Library (CC0), with seven of that library's clips — standing, walking, running, crouching, firing, taking a round, and going down.
tools/bake_figure.pyreads the glTF and writesfigure.mesh: fifty-three joints, 13,744 triangles, seven clips, 383 KB. Three facts about the source are what keep it there rather than at a megabyte and a half, and the baker checks each rather than assuming it — nothing in the rig is ever scaled, exactly one joint translates, and the clips are already authored on a thirty-a-second grid. So a frame is a root position and one rotation per joint in snorm16, and everything else is stored once.The skinning is split the way the rest of this is. The page works out one matrix per joint per person — fifty-three small matrix products each, which is nothing for eight people, and it keeps the whole animation somewhere it can be read — and the shader is four matrices and their weights. The matrices arrive already in world space, so the vertex stage has no model transform, no bind pose and no per-instance rotation in it. They cast shadows through the same skin.
Two of those seven are events rather than states and are decided on the page, off what the snapshot already says. Firing is one. Being hit is the other, and it is not in the protocol at all: a health that went down since the last snapshot and has not reached zero is somebody taking a round, which is the same fact the server would have had to send, arriving for nothing.
Which clip somebody is playing comes from the server, in a new field on each player in the snapshot. It has the keys and the health, and two people watching the same person should not disagree about whether they are running. Firing is the exception because it is an event rather than a state: a round leaving somebody's rifle plays the shot over whatever their legs are doing. C crouches, which changes what everybody sees and not where anybody goes — the honest half of it to ship first.
The crystals hold their fire in a shared valley. A meteor is thrown at where somebody is standing, and standing about is most of what people do while there are others to look at — thirty seconds of not moving used to cost most of a life.
meteors()inserver.rillis the switch, and it is a switch on the world rather than a constant invalley.rill, because a browser alone in its own valley is the demo the fireballs were written for and still gets them. Only the starting is gated: a crystal already winding up finishes its swell and settles back, since one cut off mid-swell would stay at two and a half times its size with nothing to bring it down.Names over heads. A browser joining somebody else's valley is asked who it is before it connects, and everybody else sees that over the player's head. The name is sent once and comes back as a
who, rather than riding in every snapshot twenty times a second: it changes about as often as a person arrives. Somebody walking in is told the whole roster; everybody already there hears about them.What arrives is somebody's to choose, so the server cuts it down to letters and digits, eight at most, and hands out
player7to anyone who says nothing. A space would have split the line it is sent on, which is the first reason not to pass it through as it came.And it has to be free. Two people called the same thing is not a cosmetic problem — the tag over a head and the row in the roster are the only way anybody tells who shot them, and a valley with two
barisin it cannot be reasoned about by the people in it. A taken name gets a number, and the number eats into the eight rather than pushing past it. That is done on the server rather than refused back to the client: a join that fails on its last step, after the world is built and the socket is open, is a worse thing to hand somebody than a name with a 2 after it. They are told what they ended up with on the samewholine everybody else gets, so nobody is guessing.The tags are elements over the canvas rather than geometry in it. Text in a 3D scene means a glyph atlas, a quad each and a pipeline to put them through; the browser already has a text engine that hints and kerns and knows about every script there is, and a tag wants to face the camera and stay legible at any distance, which is what it is good at. All that is ours is where it goes: a point above their head, through the same view-projection the valley was drawn with, which is sitting in the first sixteen floats of the state. They fade over the last twenty metres and go amber as somebody runs out of health. They are not occluded by the terrain, which is a choice rather than an oversight.
A limit per address, not just in total. Sixty-four connections was no defence against one machine opening sixty-four of them: it could still take the whole valley. Sixteen from any one address now — generous for a household or an office behind one, and still three-quarters of the room left for everyone else. Measured: twenty-four attempts from one address, sixteen accepted and eight refused.
Which needed
sock_peer(fd), the address at the other end as one number. A number rather than a dotted quad because what a server does with it is count, and the server keeps its rows in the same shared block as the total — one row per connection is more than enough, since a row is only in use while somebody from that address is.A read can be given a deadline:
sock_wait(fd, ms). A socket read had none, so a peer that opened a connection and then said nothing held a strand and a descriptor for as long as it cared to, and the valley server's answer was a cap on how many of those it would hold. Closing the socket from somewhere else is not the fix and this is worth writing down: the reader is parked on that descriptor inside the poller, closing it from another strand does not wake it, and the number can be handed to the next connection while the old registration is still armed.So the descriptor and a deadline are armed together — two kevents in one call on the BSDs, a socket and a timerfd in the same epoll set on Linux — and whichever arrives first wakes the strand. What it fired on is not asked of the poller: a one-byte
MSG_PEEKanswers it directly, and a peek cannot be wrong the way a race between two registrations can.Which uncovered a hazard worth fixing on its own: a strand registered on two things could be made runnable twice, and queueing the same strand twice corrupts the run queue. Whoever claims it first now wins, and the second event finds it claimed and leaves it alone.
The valley server gives a connection five seconds to get its handshake in, measured against the clock rather than per read so that dribbling a byte at a time cannot extend it. Eighty sockets that connect and say nothing used to be held indefinitely; they are all let go now, and a real client connects afterwards.
ws.rillstops assuming a read is a frame. TCP hands over bytes: one read can carry half a frame or three of them. The reader took the first frame out of whatever a single read produced and threw the rest away — so a client sending two messages in one write lost the second, and a message split across two segments was truncated, after which every following frame was read from the middle of the last one and the connection quietly turned to noise. That is what a large frame really did: it did not stall the server, it desynced the stream and the connection died.ws_take(conn, acc)hands back the message and the bytes after it, and they go back in next time. Measured on the valley server: three frames in one write all arrive, a frame split down the middle is reassembled, and a 65,000-byte frame is answered on the next tick with the connection intact — all three of which used to fail. The echo example now sends both its messages in one write, so the test covers it.Unmasking was also quadratic —
acc + chr(...)copies everything so far, every byte — which for the largest frame this reads came to two gigabytes of copying and about sixty-five milliseconds of one core, for one message. It goes through aStrBufnow.The valley server stops taking a client's word for anything. It was a demo on a trusted network and read like one, and asking what an unfriendly client could do turned up four things.
Numbers off the wire are numbers somebody chose.
str_to_floathands back a NaN for"nan"and an infinity for"1e400"as happily as it hands back 2.75, and NaN is the one that spreads: it compares false against everything, so a player at a NaN position is hit by nothing, seen by nothing, always far enough away to be worth throwing at, and puts NaN into every snapshot everybody else reads. Measured before the fix:i 0 nan nanand the snapshot saidnan nan. Everything arriving is now checked finite, and pitch is held to the same limitswalkholds it to.The direction of a shot was multiplied by the muzzle velocity and believed. A client sending a direction a million long got a round leaving the muzzle at a hundred and seventy million metres a second, which crosses the whole valley inside one step and sweeps a segment that takes out every crystal along a line through the world. It gets worse on the way: the drag term is
1 - 0.00011 * speed * dt, which at that speed is not a fraction under one but a factor of minus nine hundred, so a tenth of a second later the round was measured at 158 billion metres a second and going backwards. A rifle, not a rail gun: the direction is made a unit vector on arrival, and one too short to normalise is not a direction, so the shot is dropped. The same message now leaves at 120 m/s, where the drag term is the fraction it was meant to be.One client could stop the world for everybody. The hub takes its tick off the same channel as the key presses, and against an unbounded sender the tick never wins — modelled on its own, four busy senders and the ticker got through zero times in eight seconds. Each connection now has ninety messages a second, which is comfortably above what a browser sends and far below what a machine can; over the budget a line is read and dropped without being parsed. Under three flooding clients the tick now holds at a full 20 a second, where before the server fell over.
The timeout then threw out the one client it was meant to protect. Input is sent once a frame and a browser stops running frames the moment its tab is hidden, so a tab left in the background said nothing at all and was dropped from a valley it was still connected to — measured, and then measured again after the fix, because the first attempt at testing it was served a cached
index.htmland proved nothing. There is a heartbeat on a timer now: the last look with no keys held, every two seconds, which says "still here" and "standing still". Standing still matters — the server walks from the last input it was given, so a tab hidden mid-stride would otherwise keep walking. The silence a connection is allowed is thirty seconds rather than ten, since a browser throttles the timers of a hidden tab.And there was no limit on open sockets. Eighty connections that said nothing at all held eighty strands and eighty descriptors for ever; there are sixty-four places now and the eighty-first is closed on arrival. That is a cap, not a timeout — a slow client still holds its place until it speaks, because a read cannot be given a deadline in this language yet.
A
selectin a loop is a loop again. It was not corrupting the heap, which is what it looked like: it was leaking the stack.selectbuilds a table of its cases and a slot for the result, and both wereallocas emitted where they were first needed. Anallocain the entry block is free — LLVM folds it into the frame layout and gives it a fixed offset — but anywhere else it is a real adjustment of the stack pointer, made every time control reaches it. Worse, a function holding a dynamically sizedallocacannot have its frame popped before a call, so LLVM quietly drops thetailmarker and a Rill loop written as tail recursion stops being a loop.A hundred and sixty bytes a turn, measured: the stack pointer moved exactly sixteen megabytes between the hundred-thousandth
selectand the two hundred-thousandth. Two hundred thousand turns is thirty-two megabytes, which is the main strand's whole stack, and the next push went past the bottom into the guard page of the mapping next door. That is a bus error rather than a segmentation fault, landing wherever the program happened to be — usually insidefree, once insidestrtod— which is why it read as heap corruption for so long. Five lines of Rill, no sockets and no valley, five crashes out of five.Both allocas are taken in the entry block now, where they cost nothing and happen once however many times control comes back round. The reproduction runs a hundred million times with the stack flat, and the same loop is a test. This was what the valley server kept dying of, and it explains the shape of it: nothing to do with what a client sent, everything to do with how many times the hub had been round.
An aliased import no longer renames the module's own local names.
import "lib" as vqualifies everythinglibdefines, and it was doing that to references by name alone — so a local binding, a parameter, a loop's accumulator or a name a pattern bound was rewritten into a reference to the module function it happened to share a name with. A file that compiled perfectly well on its own stopped compiling the moment somebody imported it under an alias, and the error pointed at the innocent line:groundbound fromheight(x, z)came out as "expected Float, found (Float, Float) -> Float", which is the module'sgroundfunction standing where the number was.Found by splitting the valley in two, which is the first thing in this repository to import anything under an alias in earnest.
The valley holds more than one person.
examples/webgpu/world/server.rillis a WebSocket server that owns one world and steps it twenty times a second, and browsers pointed at?joinwalk about in it together.The point of it is which file it is made of.
valley.rillis now the valley and nothing else — no exports, nomain— and it is compiled twice: throughworld.rillinto the wasm the browser loads, and throughserver.rillinto a native binary. Both run the same walk, the same collisions, the same ballistics, because they are the same code. A server that reimplemented any of that would be a server that disagreed with the client about where a round went, and this one cannot.A player is a block of state pointing at a world, and the world's block points at itself, so
wd(s)is the world whichever kind of block it is handed and one player is the same arithmetic as eight.framecame apart intowalk,step_worldandpick_up— one per player, one per tick, one per player again, in the orderframealways ran them, so a browser alone in the valley behaves exactly as it did.Everything shared is decided once, on the server. The crystals throw at somebody chosen from the people actually in range; the shark hunts the nearest swimmer and bites whoever that is; a blast hurts everyone near it; and a round now carries whose it is, in the slot the tracer shader has always called
spare, so a hit can be credited and nobody shoots themselves in the back of the head at the muzzle. Three rounds take a player down.What goes on the wire is text, one line per event, and the events cost the world nothing to produce: the rounds, the throws and the craters already live in rings whose cursors count up, so the difference between the cursor at the start of a tick and the cursor at the end is the log. Those cursors used to be stored already wrapped — 31, 32, 1 — which was fine for finding the newest entry, useless as a log, and quietly ate one crater's sound every time round.
The client still walks itself, on the same
walk, and eases toward what the server says rather than snapping to it; between snapshots it runsstep_visuals, which moves everything and decides nothing. Its aim is never corrected. Other people are drawn as instances of a figure built the way the pines and crystals are, interpolated between the last two snapshots and casting a shadow like anything else standing in the sun.Three things the first cut of the server did not handle, all found by asking what happens when somebody's connection dies rather than closes:
A connection that ends cleanly was already handled — the player leaves the valley, their block is freed and their id retired. But the strand writing to them was not: it sat waiting on an outbox nothing would ever be sent to again, one leaked strand and channel per disconnect, which measured 1,584 KB over two thousand connections. The socket is now closed first, so anything still queued fails at once, and then an empty line goes into the outbox as the one thing that can reach a strand waiting on a channel. The same two thousand connections now cost 112 KB.
A connection whose far end fell asleep or was unplugged said nothing at all, and the player stood in the valley for ever: in the snapshot, in the count, in the way, a target for whatever the crystals threw, and holding one of the thirty-two places. The tick now drops anybody not heard from for ten seconds. Their socket is left alone on purpose — a strand is parked on that descriptor in the poller, closing it from elsewhere does not wake it, and the number could be reused by the next connection while the old registration is still armed.
And a client turned away from a full valley used to decide it was player zero. The refusal is words,
str_to_intof a word is zero, and the id was being read out of whatever arrived — so the thirty-third arrival could drive whoever player zero was. The welcome is now recognised by being a welcome.Also fixed while in there:
free_worldnever freed the rounds, the shards, the fireballs, the chargers or the blasts, and the host asks for the valley at three or four resolutions while it settles on one.sleep_ms— a strand can wait on the clock without holding a worker. There was no way to wait at all: a program that wanted a rate had to spin, which costs a core to do nothing, and a server tick could not be written.It is the fifth operation on the poller rather than a call to the operating system's sleep, and that is the whole point — the strand goes into the same place a strand waiting on a socket goes, and the worker leaves to run something else. kqueue has a filter that counts time; epoll has only descriptors, so on Linux the wait is a
timerfdthat becomes readable and is closed by the sleeper on its way out. A thousand strands asleep at once cost 2% of one core and finish together, in as long as the longest.A sleeping strand counts as an I/O waiter, so a program in which every strand is asleep is not reported as a deadlock: it is waiting on something that always answers. There is no ticker type, because with a channel it is three lines of Rill;
LANGUAGE.mdshows those, and the deadline-driven loop that keeps a rate despite the slack every operating system's timers have.Refused on wasm, beside the sockets and the strands. A sleep that returned instantly there would be a program silently running at the wrong speed.
The wind stops making the trees shiver. Everything that sways read its phase as
sin(t * rate(wind))— a frequency that rises with the wind, multiplied by the clock. Differentiate that and the angular velocity israte + t * rate': a change in the wind moves the phase by that change times however long the page has been open. Five minutes in, the pines were turning 3.4 radians a frame at 60 fps — past π, so past what the frame rate can even represent, which is what the shivering was. Ten minutes in, 6.3. It also ran backwards, which is the part that looked wrong before it looked broken.The phase is integrated now, a step at a time, in
framewhere the wind is already computed: a change in the wind changes only what happens next. The pines, the grass and the water's ripples all read it, so they still agree about their cause. Steady 0.08 radians a frame, at any wind and any hour.poke_*andpeek_*are a store and a load, not a call. They are one instruction each — the address isp + i * width, unaligned as the runtime's were — and they went through the runtime every time. For a program filling a vertex buffer that is one call per float, and filling buffers the host will read is the whole of what a wasm module does. Worth about 2% on the WebGPU valley; the rest of what follows is the example itself.The WebGPU valley rebuilds in 3.8 ms instead of 8.5. It sampled the terrain at each cell's four corners, and a corner belongs to four cells; the heights come off a lattice computed once now — 9409 samples where there were 36864, and each is twelve sines deep. It is also the more correct way round: two cells sharing an edge read one number for it instead of each working out its own in floating point. And a candidate pine, crystal or grass tuft is now rejected on its seed before the ground under it is worked out, which settles most of them.
The mesh is unchanged — all 497,664 floats agree with the old one to five decimal places, and the three instance counts are the same. The loops that were self-recursion through a helper returning 1 are
forloops.
0.3.0 — measured, and changed for it
0 changes gathered
The release where the language stopped being argued about and started being counted. Two algorithms — a JSON parser and a Dijkstra — were written in eight languages, and the token cost of the Rill ones decided what the language grew next: b[i], functions inside functions, for and +=, tuples, #s, s[i:j], for … with, string builders and scan_int are all here because a count said where the words were going. The Dijkstra went from 1579 tokens to 740, which is under Go's.
Then the same programs were put on a clock, and the answer to every gap was looked up rather than guessed at. What came back was thirteen changes to the runtime and the code generator and four ideas measured and left unmade — bounds-check elimination twice, noalias, internal linkage, and inlining the allocator. The JSON benchmark went from 6.5 seconds to 149 ms, the end-to-end Dijkstra from 10.6 seconds to 97, and the whole suite is in benchmarks/tokens/ so the numbers can be checked rather than believed.
It is also the release where Rill reached the browser: WebAssembly with export fn, a triangle through WebGPU, and a world to walk around in.
Float arithmetic contracts. A multiply and the add that consumes it can become one fused instruction now, rounding once instead of twice. The answer is nearer the true one, not further, but it is not the answer two separate roundings give — which is exactly the trade C, C++ and Go make by default and Rust and Zig decline. Rill follows the first group, and nothing else is loosened: no reassociation, no assumptions about NaN or infinity, no reciprocals, just
contract.It is worth six percent on a Mandelbrot — 194 ms to 182, which is past the 196 of the C it is measured against, and that C is the one with three fused instructions to Rill's one. The same benchmark is what found it: C, C++ and Go sat at 201 ms and Rust, Zig and C# at 249, and the whole of that gap was this one flag.
A local function that captures nothing is polymorphic. It was not, and the reason turned out to be narrower than it looked: a lifted function is tied to the one it was written inside so that a captured buffer keeps the width it was captured with, since a
Bufwidth is never inferred from use. That tie is what costs it a scheme — and a function that captured nothing never needed it. It is no longer made.fn id(x) = xinsidemainnow works atIntand atStr, and so does thediethat ends a branch at two different result types.What still has one type is a local function that closed over something, which is the case where the tie is doing real work. The error for that used to be a plain mismatch that said nothing; it now says which function the names came from, and that one which captures nothing would have generalized.
ListorBuf. Not something the compiler can decide, so LANGUAGE.md says when each is right, with the JSON parser as the worked example: it builds arrays as lists, and that one choice is half of what it still gives away to the C it is measured against.A list can be rebuilt in the list it came from. Perceus reuse: when a
matcharm takes a box apart, the frame is what holds it, and nothing left in the arm can say its name, the next constructor of exactly that shape is built in that box rather than in a fresh one. The count decides at run time — at one, the box's own references to its fields are let go, exactly as its drop would have done, and the memory is kept; above one, the count comes down and a fresh box is taken.rev_onto, whichmap,filter,take,append,split,joinandreverseall end in, now allocates nothing: every link is written over the link it was read from. Those functions are 8–14% faster —map33.4 ms to 28.7 over two million elements,filter34.2 to 30.7,append29.8 to 26.8,join5.5 to 4.9.rangeandlen, which build from nothing and build nothing, are unchanged, which is the shape of the result one would want.It buys no memory, and that is worth saying: the pool's free list is LIFO, so the block a link was freed from was already the block the next link was allocated in. Reversing ten million elements peaks at 321 MB either way. What reuse removes is the pair of calls, not the footprint.
It is also worth saying that the four benchmarks in the report do not move at all — they spend their time elsewhere. This one was measured on what it actually touches.
read_linereads runs, not bytes. It pulled one byte at a time out of the input buffer and grew its own buffer around them, so an eleven-megabyte line was eleven million calls and two copies of itself. It finds the newline withmemchrand takes what is before it in one copy now: that line goes from 7 ms to 2, against 1 for thefgetsit is measured against.A
matchstops counting, and a field can be moved into a box. Two places where reference counting was doing work that cancels.Matching on a local took a reference of its own, which meant a retain on the way in and a release on the way out of every
matchin the language — once per link for a list walked one link at a time. Whatever keeps that local alive keeps it alive across the match too, and the arms already retain their bindings as they use them, so the match borrows it now.And a binding handed straight to a constructor is moved into it, the way one handed to a tail call already was:
Cons(x, acc)in an accumulating loop no longer retainsaccfor the box and releases it for the frame. Only where the box is a tail call's own argument, and only for a field written as a name — a move under a branch is a move on one path and not the other, which the first attempt at this got wrong and the leak check caught.rev_onto, which everymap,filter,splitandreverseends in, goes from three retains and three recursive drops per link to two and one. JSON drops from 176 ms to 154 and the short JSON from 312 to 269.Releasing a counted value stops being a call. Handing a buffer or a string to a function retains it and the function releases it on the way out, and the release was a call into the runtime every time. Nearly always there is nothing to do there — the count goes from something to something, not to zero — so the decrement is emitted at the site now, with the call kept for the one time in many that actually frees. What that buys is not the call itself but what it uncovers: a
+1in the caller and a-1in the callee, once inlining has brought them together, are now plainly a pair and cancel.It shows up wherever a program passes storage around. The Dijkstra algorithm benchmark, which threads six buffers through a heap's worth of small functions, goes from 46 ms to 42 (1.20× the C it is written against, from 1.32); JSON from 176 to 165; end-to-end Dijkstra from 121 to 113. A
--parallelbuild keeps the call, where the count has to be atomic.Two other candidates were measured and left unmade. Bounds checks in that benchmark's heap loop cost 3 ms in 720 — the third time this year that removing them has been measured and found not to be worth it. Giving generated functions internal linkage, so LLVM knows every caller, does turn the
% nin a lifted loop from a division into a multiply, and changes the running time of nothing at all.The prelude's list functions cost constant stack now, and are faster for it.
map,filter,take,append,rangeandlenwere written the way the definitions read —Cons(f(x), map(rest, f))— which leaves work after the recursive call and so costs a stack frame per element. Half a million of them was a segmentation fault. They build the result backwards and turn it round at the end instead, which costs a second cell per element and buys the whole list back: five million elements go through every one of them without trouble.The extra pass was expected to cost something. It does not: at a hundred thousand elements, where the old ones still survived,
mapis 29% faster,range33% andlen38%. Deep recursion is more expensive than a second walk.joinandrepeathad the same shape and a second problem — they built their result with+, which copies everything assembled so far at every step. Through a builder they are linear: joining twenty thousand pieces goes from 19.7 ms to 1.9.The last of those shows up where it matters most. The short, idiomatic JSON benchmark — the one written with
mapandjoinrather than a builder threaded by hand, and 255 tokens cheaper for it — went from 6.5 seconds to 300 ms. Writing it the plain way no longer costs twenty times the running time.str_find_from, and asplitthat walks the line once. Searching a string from anywhere but the beginning meant slicing off the part already read, which copies it — sosplitcopied the whole remainder of the line at every separator. A line of a hundred thousand fields took two seconds, and one of two hundred thousand overflowed the stack, becausesplitwas not tail-recursive either.str_find_from(s, needle, from)starts partway along,splitis written on top of it, and both problems go with it: the hundred-thousand-field line now takes 6 ms and five million fields take 117.str_findstops looking one byte at a time. It compared the needle against every position in turn, which is how a search reads when you write it out and is not how anyone's C library does it. It now jumps to each place the first byte occurs —memchr, which is vectorized everywhere this runtime is built — and compares from there. Searching a 20 MB text twenty times goes from 1020 ms to 27, which is also seven times faster than the same searches through macOS's ownmemmem.contains,starts_with,splitandlinesall go through it.This was the answer to a wider question: does Rill get SIMD at all? It does, from LLVM, and it gets it for the loops people actually write — summing a
Bufand counting bytes in aStrboth compile to the same NEON as the C they were written against, whether the loop is spelledfor … withor as tail recursion. What was left out was the runtime's own byte loops, andstr_findwas the one that mattered.Four things the runtime was doing the slow way. The JSON benchmark was the last place Rill lost badly to C, so it was profiled rather than guessed at, and every one of the four costs turned out to be the language's fault rather than the program's.
int_to_strformatted throughsnprintf, which walks a format string and takes a lock to ask the locale about a decimal point it will not use; it now writes the digits itself. A string builder's bytes came frommalloc, so a parser that builds one short string per token askedmallocfor one per token; they come from the same pool as everything else now.#sands[i]were calls into the runtime, which is why they could not be hoisted out of a loop — a string is{ rc, len, bytes… }, so both are a load, and both are emitted as one. And pieces like","were copied withmemcpy, where working out how to go fast costs more than copying three bytes.Together: the benchmark goes from 215 ms to 174, against 113 for the C it is written against and 148 for a C that frees what it allocated. Nothing about the language changed — the same program is faster.
A fifth idea was measured and dropped. The pool's fast path can be laid down at the allocation site, where the size is a constant and the size class folds away; it is worth 3% on the JSON benchmark and nothing at all on the self-hosted compiler, which is not enough to justify generated code that knows the allocator's layout by heart.
scan_int, and two optimizations that turned out not to be worth making. Chasing the last of the distance to C, the plan was to drop bounds checks where the loop proves the index is in range. Measuring first said not to: removing every bounds check from the Dijkstra benchmark takes it from 47 ms to 46, and marking buffer parametersnoaliaschanges nothing at all. Both were dropped unmade. The same measurement said where the time actually was — 88% of that program's end-to-end run is reading its input, where every line becomes a list of strings throughsplit,filterandmap, about fifteen allocations a line.scan_int()scans a whitespace-delimited number straight out of the input buffer and allocates nothing, stopping on the delimiter without swallowing it. The benchmark goes from 495 ms to 120 ms — past the C it is written against, which spends 221 ms inscanf.A loop can carry something.
for i in a..b with acc = initruns the body for eachiwithaccin scope, takes the body's value as the nextacc, and is worth the last one. It costs nothing new: like the plain form it becomes the tail-recursive local function a loop is, and what it carries is a parameter. That is the point — a parameter is a register, and a program without this had to keep a running value in a one-element buffer, which is memory reached through a pointer on every touch. Measured on the Dijkstra benchmark, whose graph is made by a linear congruential sequence: that phase goes from 16 ms to 6 ms, which is faster than the C it is written against, and the whole program from 47 ms to 37 ms against C's 31.String builders, and standard input read in blocks. Building a string by
+copies what is already there every time, so aStrBufis what aBufis, for bytes on their way to becoming a string:strbuf(),b += s(a string) orb += byte(a number),strbuf_str(b),#b.+=on a name is the one assignment whose target is not an index, and it changes no binding — what it adds to is the storage the name points at. Written with one, the JSON benchmark's printing went from 44% of its time to 3%. Measuring the rest of it found something worse and simpler:read_linewas areadsyscall per byte, which on an eleven-megabyte input was almost the whole program. Standard input is now read in blocks. Together: JSON 6.4 s to 0.22 s, level with Go and past OCaml; Dijkstra end to end 10.3 s to 0.51 s, past Go. All four benchmarks now sit within about twice of C. The self-hosted compiler does not knowStrBufyet, as with slicing. A tight loop over a buffer ran twenty-four times slower than the same loop in C, and the emitted IR said why: six retains and eight releases per iteration, none of which changed anything. Two rules removed all of them. An operation that only reads through a reference — a buffer's element or length, a string handed to the runtime — borrows it when it is a local, since whatever owns it outlives the expression, so neither the retain nor the release that undoes it is emitted. And a binding this frame owns and hands to a tail call is moved: the retain the argument takes and the release that follows it cancel exactly, which is the whole of what a loop written as a tail call was paying.v[i] += 1also stopped naming its own base and index when they are already names. Measured: the tight loop 707 ms to 31 ms, within a tenth of C; Dijkstra's algorithm 236 ms to 66 ms, past OCaml and within twice of C. Seventy programs still end withlive allocations: 0, and a test now reads the IR of a loop body to keep it that way.s[i:j]slices a string. The bytes fromiup to but notj— the half-open rangeforalready counts over — with either end omissible for the string's own:s[3:],s[:3],s[1:#s - 1]. It isstr_subwith an end instead of a length, which is what makes leaving an end out mean anything, and it is a node of its own rather than a rewrite so that each part is evaluated once and the error says[:]rather than naming the builtin it lowers to. The self-hosted compiler does not know this one yet — no file in the repository uses it, so every oracle still holds, butrillcwill refuse a program that does until it is taught.#measures the two things[]indexes: a string's bytes and a buffer's elements,str_lenandbuf_lenunder the notation. It costs the language one rule, stated where comments are: a#opens a comment when a space or the end of the line follows it, and is the operator when something does. Every comment ever written in this repository already reads# ..., so nothing moved. JSON reaches 1361 tokens, 1.02× Go; Dijkstra 829, 1.11×.Tuples, and bindings that take a value apart.
(a, b)hands back more than one thing,(A, B)is its type, and(q, r) = divmod(17, 5)reads it back. None of it is new machinery: a tuple is the prelude'sTuple2,Tuple3orTuple4under a notation short enough to use, so it compares, shows and matches like the data type it is — and the notation is whatshowprints and what the formatter puts back. A binding may take apart any type with exactly one constructor, which is what makes it a binding and not a branch; it is the one-armedmatchit has always been, over everything that follows it. Written this way, the JSON benchmark falls from 1525 to 1396 tokens, 1.14× to 1.04× Go.for i in a..b, and+=on a buffer element. Counting over a range now has a form of its own, and a buffer element can be written in terms of what it held (+=,-=,*=,/=, with the index evaluated once). Neither is a new idea in the language:foris spelled out as the tail-recursive local function it always was, andv[i] += 1as the read and the write it always was — both before the checker sees a program, so the formatter is the only thing besides the parser that knows they exist. Lifting had to learn one thing along the way: a function nested in another block is now lifted after the enclosing block's calls are rewritten, since what a sibling call passes is part of what the nested one captures. With this the Dijkstra benchmark reaches 833 tokens — 1.12× the Go it is written against, level with C and past OCaml, from 1579 three changes ago.Functions inside functions. A
fnwritten in a body sees the names around it and may call itself and the functions beside it, which is what a loop over local state wants: only the loop's own variables are parameters. Each one is lifted out to the top level with the names it uses as leading parameters, so nothing is allocated and a tail call stays a tail call — a local function is a loop, not a closure, and for the same reason it can only be called, never passed. It is inferred together with the function it was written inside, so a captured buffer keeps the type it was captured with; that needed one further change, which is worth on its own: the functions of a mutually-dependent group are now inferred callers first, so what a caller says about its callee's parameters is known before the callee's body is looked at. Measured: the Dijkstra benchmark falls from 1438 to 1002 tokens, 2.12× to 1.35× Go across both changes.b[i]andb[i] = v. A buffer element is now written the way it is read everywhere else, and a string indexes to its bytes withs[i]. Both mean exactly whatbuf_get/buf_set/str_getmean — the same types, the same bounds check, the same errors — so nothing new happens at run time;b[i] = vis the one assignment the language has, and only to a buffer, which was always storage. Indexing binds tighter than every operator. A base whose type is still open cannot be a buffer, since aBufwidth is never inferred, so it settles as a string:fn f(s, i) = s[i]needs no annotation. Measured on two benchmark programs rewritten to use it: a Dijkstra with a hand-written heap loses 9% of its tokens, a JSON parser 3%.The self-hosted compiler calls back.
rillcemits C-ABI trampolines, the last thing it could not compile:Fn(...)in a foreign signature is a code pointer with nowhere to keep an environment, so the trampoline widens C's arguments to Rill's, calls the function, and narrows the answer back. Which function it is has to be settled while compiling, which is what the new fixpoint over the program works out — every parameter that reaches a C callback slot, directly or by being handed on, is marked, and a function with one is emitted once per callback it is called with.examples/ callbacks.rillnow joins the oracle's example tier: twenty-seven of twenty-seven build under both compilers, print the same bytes and leave a clean heap. The self-hosted compiler now covers the language.Long lists no longer crash on the way out. The generated drop for a data type released its heap fields and then freed the box, so tearing down a list cost a stack frame per link and a half-million-element one segfaulted at scope end. It now reads the fields out, frees the box, and releases the last of them by a tail call — the shape the self-hosted compiler already used — so a five-million-element list unwinds in constant stack, optimized build or not. One field can have the last word, so a chain running through an earlier field of a multi-field constructor still recurses.
rillcis a command, not a library. The code generator moved torillc_gen.rillandrillc.rillbecame the driver every compiler needs:build,run,emit-llvmandcheck, with-o,--keep-ir,--preludeand--runtime. It finds the prelude andlibrill_runtime.abeside itself or one level up, collects link flags from the program's ownextern "lib"declarations in all three of the linker's forms, writes the IR and callscc— through C'ssystem,getenv,accessandremove, declared as ordinaryexterns, so the driver uses nothing a Rill program could not. Every path reaches the shell single-quoted, and success returns frommainrather than exiting, which keeps the runtime's leak report running. The oracle now drives the whole proof through the new command, including a tier holdingrillc checktorill checkand one that watchesrillc runhand back a program's exit code.The self-hosted compiler covers the language.
rillcnow compiles traits (receiver-typed dispatch to impl functions), structural==andshowfor data types (generated per monomorphized type, user impls honored down to fields, last-field comparisons by tail call),Buf, and the whole strand story: channels,spawn,select. Messages cross a channel as one 8-byte word; a spawned strand's free variables ride in a runtime-owned environment box; aselectbuilds the runtime's case table and runs exactly the arm it is told to. A channel bound without annotations recovers its element type the way a reader does — from the signature of the worker it is handed to, or the payload it is sent. The oracle grew a fourth tier: every runnable example now builds under both compilers and must print the same bytes and leave a clean heap — 26 of 26 do. Still out: C function-pointer callbacks.The self-hosted compiler stops leaking.
rillcnow emits reference counting: boxes carry a count with the static-constant marker the runtime already understands, every variable use retains, bindings release at scope end, arguments are consumed by the callee and the runtime borrows what it is passed. Pattern matches retain their bindings only after the whole pattern has matched, so a failing arm touches nothing; each monomorphized data type gets a generated release function whose final field is freed by tail call, so a million-long list unwinds in constant stack. The proof is the runtime's own live-allocation counter: every behavior test, the compiler-built formatter on the largest source file, and the self-compilation ofrillcby its own output all end withlive allocations: 0— and the fixpoint still holds.Rill compiles itself. The self-hosted code generator grew from the scalar subset to the real language: data types and
match(nested patterns, tag dispatch, shared static nullary constructors), generic functions cloned per instantiation by a worklist frommain, lambdas lifted to top-level functions over environment boxes, and an 8-byte-slot value model that lets adiearm the checker left polymorphic coexist with proven types. The proof is a fixpoint:rillccompiles itself, the self-built compiler compiles it again, and the two IR files are byte-identical. The self-built compiler then builds the self-hosted formatter — byte-identical torill fmtover ~190 files — and the self-hosted type checker — word-identical torill checkon the whole error suite. Reference counting is the one big absence: the generated code leaks, which a batch compiler can afford until Perceus lands.A code generator in Rill. The third compiler stage, begun:
selfhost/rillc.rillcompiles the scalar subset — Int, Float, Bool, Str, functions, blocks,if, tail calls, the scalar built-ins — to LLVM IR text thatcclinks againstlibrill_runtime.a. It runs the self-hosted type checker first, so every emitted type is concrete, and generates only whatmainreaches.selfhost/check_rillc.shproves it behaviorally: fourteen programs built by both compilers print the same bytes, including a five-million-iteration tail loop and a million-deep mutual recursion. All three stages of the front end now exist in Rill, each with its own oracle against the Rust implementation.The type checker, self-hosted. The second compiler stage written in Rill: module loading (
tyck_load.rill— depth-first imports, aliased modules qualified toalias.name, the prelude merged with transitive shadowing), unification with occurs check over a persistent substitution, traits and impls, dependency-ordered generalization, and Maranget's usefulness test for match exhaustiveness. A newrill checksubcommand runs the Rust checker alone (load + type check, no codegen), andselfhost/check_types.shholds the two implementations to the same verdict over every file in the repository plus a suite of deliberately broken programs: same exit code, same first error, word for word — 161 cases, zero divergence.Block bodies for match arms, select arms and
ifbranches. The first thing writing the compiler in its own language taught was where the language pinched: an arm's body had to be one expression, so real logic became chains of tiny helper functions, and a longifchain became one long line. Now an arrow or athen/elsemay end its line and take an indented block of statements, exactly the way a function body does —elsestands level with itsif, andelse ifchains stay flat. No new machinery below the parser: a block was already an expression everywhere else, so the type checker and the code generator took the change without a single edit.The formatter learned the layout form in both of its implementations — the Rust one and the self-hosted one — and the byte-for-byte oracle now runs over 125 files including
examples/blocks.rill, which exercises the new forms. Zero divergence.The front end, self-hosted: Rill's lexer, parser and formatter, in Rill.
selfhost/holds a complete port ofrill fmt— the layout-sensitive lexer with its indent stack, the recursive-descent parser, and the canonical printer with comment anchoring and blank-line preservation. The proof is not a test suite but an oracle:selfhost/check.shformats every.rillfile in the repository with both implementations and diffs each pair. One hundred and twenty files, byte-identical, including the prelude, every example and book chapter, and the self-hosted source itself — which it formats exactly as the Rust formatter does, parenthesization choices and all.The port shaped itself around what the language is. State threads through single constructors because there is no mutation; every loop is a self-tail call because that is what a loop is; numbers and strings keep their raw source text, which is both the honest way to reproduce bytes and the reason no float printer had to be written. One
die_anycarries every parse error — its recursive tail call, never reached pastexit, is what makes one function polymorphic enough to fail at every type. This is the first stage of a compiler that does not need Rust; the type checker is the natural next piece.A Rill user needs no Rust.
tools/dist.shpackages the toolchain the way compilers ship:bin/rillwith its native runtime beside it, andlib/rill/wasm32-wasip1/carrying the wasm runtime and the wasi sysroot. The compiler looks in its own bundle before falling back to cargo's and rustup's paths, so the development tree keeps working unchanged. Proven with an environment whererustcandcargodo not exist: the packaged binary built the whole WebGPU valley and a native hello-world. Rust builds the toolchain once, the way C builds a C compiler — after that it is not the user's concern.The valley got weather, and the trees got in the way. One wind for the whole world — a direction that wanders, a moderate strength that gusts — computed by Rill each frame like everything else the world knows about itself. The lake reads it: its ocean swell became what a lake actually has, short frequent chop clustered around the wind's direction, amplitude following the gusts, foam breaking on the windward shore. The pines read it: crowns lean with the square of the height while trunks stand, and the flutter quickens as the wind does. The grass reads it too. Three effects agreeing about their cause is what makes it weather rather than animation.
And the player now collides with what stands in the world: trunks and crystals are circles, overlap pushes back to the boundary, and pressing into a tree slides you around it the way a shoulder would. Measured in the harness: three hundred frames of walking straight at a pine, closest approach exactly the trunk radius, to the millimetre.
rill buildgenerates the embedding, not just the module. A wasm reactor is made to be hosted, so the toolchain now writes the host's plumbing beside it:rill build app.rill --target wasm32-wasip1 -o appproducesapp.wasmandapp.js— a generated loader carrying the WASI shim browsers do not have, instantiation, and the init calls. A page that embeds a Rill module now writes only its own code: both WebGPU demos dropped their hand-written copies of exactly that plumbing and import the generated file instead.A world you can walk through, in
examples/webgpu/world/. The proof that the wasm target andexport fncarry real weight: a low-poly valley at dusk — terrain from three octaves of value noise with a lake carved out of the middle, two hundred and sixty pines and a scatter of glowing crystals placed by the same hash, a first-person camera that walks, falls and jumps against the terrain, and every matrix the GPU consumes — all computed in Rill. The mesh is fifty-five thousand flat-shaded vertices written straight into linear memory in seven milliseconds, through loops whose bodies return how far to advance, because on wasm the call that comes back around must be a tail call and nothing else.The renderer half exercises most of what WebGPU has: a shadow pass under a fixed golden-hour sun with PCF sampling, a 4x MSAA forward pass, instanced drawing fed directly from the buffers Rill wrote, alpha-blended animated water, additive crystal halos, a compute-shader swarm of twelve hundred fireflies, and a procedural sky with the sun and stars in it. Two lessons the debugging left behind: a texture cannot be sampled in the pass that renders into it, which poisons every frame silently unless an error scope is listening — and
textureSampleComparemust be reached in uniform control flow, so the shadow lookup samples first and selects after.The demo sizes itself to whatever machine opens it. There is no API that says how fast a GPU is, so it is not asked: the adapter's description picks a starting tier, and measured frame times do the rest — the world is regenerated denser or sparser until the display's own frame budget is spent, which
inittaking the grid and the forest densities as arguments makes cheap enough to do live. The camera survives the world being rebuilt around it. On an Apple M4 the loop settles at 160x160 cells, nine hundred pines, five thousand grass tufts and four thousand fireflies, with the GPU reporting under three milliseconds a frame throughtimestamp-query.The water became water. Gerstner trochoids rather than summed sines — the horizontal displacement is what sharpens crests — with phase speed from the gravity relation, amplitude that dies in the shallows, ripple octaves in the fragment stage for the sun's glitter, Schlick fresnel against the same procedural sky the sky pass draws, absorption with depth, and foam where the depth runs out and where the trochoids pinch. Every water vertex carries the exact terrain depth beneath it, asked of Rill's own height function point by point — a 32-bit re-derivation in the shader would have put the shoreline foam where f32 thought the shore was.
export fn, and a triangle drawn through WebGPU. The wasm target could run a program; now it can be a program the host keeps calling.export fn frame(t: Float)makesframea wasm export, and a module with any export links as a reactor — no_start, no exit, the host drives it.mainbecomesrill_init, called once by the host before anything else, which is whatmainalready meant with the process boundary taken away.The signature rules are the boundary's own: parameters
Int,Float,BoolorPtr, returns those orUnit, no generics — an export is one entry point, not a family waiting for a call site to pick its types. Wider data crosses through aBufandbuf_data, which the host reads straight out of linear memory. Each rule is refused with the reason, at the export's own line, andexporton a native target is refused too: exports are functions the embedding host calls, and a native executable has no host.In a reactor, an
extern fnthat no library satisfies becomes a wasm import for the host to fill in — which turns out to be the entire browser FFI.examples/webgpu/draws a spinning triangle: WebGPU's setup is promise-based and a wasm module cannot await, so the JavaScript host does the ceremony once and supplies two imports, and Rill computes the frame — corners fromcosf/sinf(resolved against wasi-libc, like any C call) into aBuf(F32), handed over as a pointer. The host file is also the needed WASI shim, all sixty lines of it, because browsers ship none —printlnin a canvas tab arrives in the console by way offd_write. Five million frames leave the module's memory byte-for-byte the same size: each frame's buffer recycles through the pool, and the 16 MB the pool maps up front is all it ever maps.A strand that only computes no longer holds its worker to the end of the program. It could before, and everything else on that worker waited for good: a started strand is pinned to the stack its frames live on, so no other worker may pick it up. Two strands and one worker was enough to hang — one spinning, one that had already run and could not be moved.
Rill has no
while, so a loop is a function calling itself in tail position, and that is where the check goes: a byte read, a branch that is not taken, and a call into the scheduler when it is. A timer thread raises the flag once a slice and the strand hands its worker back at the next turn of the loop, exactly as if it had blocked on a channel — the stack is saved the same way, at a point the compiler chose, so none of this needs a map of which words are references.Taking a turn away from a strand is worth nothing if nobody is waiting for one, and a server spends most of its life with nobody waiting, so the timer looks ten times less often while the queues are empty. It costs the first strand to start hogging a worker one slow look before it is noticed, and it is the difference between an idle program waking a sleeping core a thousand times a second and thirty times: three seconds of doing nothing measured 10 ms of system time before, and none after.
Two things keep it from costing anything. It is only emitted in programs that can reach a strand at all, decided by walking the call graph out from
mainbefore a line is generated, sofib,treesandlcgcontain no trace of it. And within those programs it goes only on tail calls that come back round, which is direct recursion and, through the same call graph, two functions calling each other — a check on only the first would leave the second able to spin. Measured over 25 runs:ring18.9 ms against 18.9 ms without, with identical fastest runs.The load has to be volatile, or it would be lifted out of the loop it exists to interrupt.
What abandoned strands were holding is released.
mainreturning ends the program whether or not the strands it started have finished, which is the semantics we want — but a strand that never reaches the end of its body never reaches therill_env_releasethere, so its environment and everything the environment captured stayed held.spawning a worker and lettingmainreturn was enough to see it: three live allocations inexamples/loops.rill, four inshowcase.Nothing knew where the strands were. A parked one is in a channel's wait queue, a fresh one in a run queue, and neither is reachable from the other — the
nextfield is already the link for whichever queue the strand is sitting in. So strand records now go on a second, permanent list as they are allocated, and the shutdown path walks it and releases the environment of anything that had not finished. They are recycled through their worker's free list and never handed back, so the list is only ever as long as the most strands that existed at once. A strand that did finish has already released its own environment and is marked dead, so nothing is released twice.Walking that list is a cache miss per strand, though, and
ringmakes a hundred thousand of them and leaves none of them running — it paid 6 ms to find nothing. A count of the strands that have started and not finished answers that without looking, and the walk happens only when it is not zero.What a strand that had started left on its stack stays. Those references are owned by that stack, and finding them would need a map of which words are references — precisely what the copy-on-block scheduler is built to do without. So the report names them instead of implying a mistake:
live allocations: 2 (1 strand(s) were still running at exit). Every program that abandons nothing now reports a clean zero, which is what makes the number worth asserting on again.With more than one worker the sweep first has to know that nobody is still looking at a strand.
STOPbecame an atomic and is now read at the top of every scheduler iteration rather than only when the queue runs dry, which also settles a difference that should never have been there: one worker already abandoned everything the momentmaindied, and several workers went on running however many strands happened to be queued. They now do the same thing. Each worker records that it has left its loop, and shutdown wakes them and waits for that — a join in all but name, and notpthread_joinfor the reason joining was rejected before: a strand that never blocks would never let go of its worker. So the wait is bounded and giving up is a normal outcome, leaving the strands exactly as they were.Rill compiles to WebAssembly.
rill build foo.rill --target wasm32-wasip1writes a.wasmthat runs under any WASI host. Codegen needed nothing: the data layout already comes from the target machine, so a 32-bit pointer is simply what LLVM lays out. The runtime grew a third arm beside the unix and windows ones — wasm has one thread and no way to make another, so the scheduler's primitives are all the same answer, andmmapismallocbecause the pool only ever bump-allocates chunks and never gives one back.Three things had to agree that never had to before. wasi-libc's startup calls
__main_argc_argv, notmain, and there is no shimming around it — clang renames a Cmainthe same way, so a hand-written forwarder forwards to itself.rill_rt_run_directtook its entry as a one-argument function pointer and calledrill_main, which takes none; a call is just a jump on every other target, but wasm checks the signature of an indirect call against the callee's and traps. Andrill_env_releasefound the closure's drop function by counting oneusizepast the refcount, which is eight bytes only where a pointer is eight bytes wide — the environment is{ i64 rc, ptr drop_fn, ... }, so the offset is now written in bytes. The same assumption was inrill_peek_ptrandrill_poke_ptras a literal8. All three were correct on every 64-bit target and wrong on the first 32-bit one; nothing that shipped was affected.Strands do not come along, and cannot. A context switch means saving the registers and the stack pointer, and wasm exposes neither — the call stack belongs to the engine, and the copy-on-block model has nothing to copy. The saving grace is that
needs_scheduler()already decides this per program: one that never spawns linksrill_rt_run_directand drops the scheduler entirely, so single-threaded wasm is a subset rather than a fork.A program that does spawn is refused, and refused where it spawns rather than at the abort the runtime's stubs would otherwise reach. Finding the line meant knowing which functions are the author's: the prelude is merged in ahead of them, so
ModuleandTModulenow record how many of their functions came from it, and a channel reached only through a prelude wrapper is still refused but without citing a line the author cannot go and read. A named library is refused the same way — a wasm module imports from its host rather than linking a shared object — while a bareextern fnis left alone, because it resolves against wasi-libc like any other C symbol.What that last allowance lets through is a declaration that disagrees with the C function it names, which is easy to write when
size_tis 32 bits here and Rill'sIntis 64.wasm-ldcalls it a warning and emits a stub that traps when called, so the link is run with--fatal-warningsand it becomes an error naming the function and both signatures.Sixteen of the twenty-seven examples run. The other eleven are refused with a diagnostic — nine by the compiler, two by the linker — and none of them reaches a trap at run time. Stripped,
hello.wasmis 28.5 KB against 32.9 KB for the native binary.Live graphs in the task manager. A strip per CPU core and one for memory, drawn by Rill: a view class of our own with
drawRect:, which is a fifth callback shape — one that receives a struct by value, since an NSRect on arm64 is four doubles in v0-v3, the same reasoningsend_rectuses in the other direction. Bars areNSRectFillin the system's own colours, so they follow the light and dark appearances. Memory also gets a realNSProgressIndicatorfor where it stands now.Two things had to be worked out along the way.
drawRect:is handed the rectangle that needs repainting, which can be larger than the view — the whole window's, in practice — so it cannot be used as the bounds; and Rill cannot receive a struct as a result, so a view cannot simply be asked how big it is. KVC answers it:[v valueForKey:@"frame"]is an NSValue, an object Rill can hold, andgetValue:size:writes the struct into memory the caller owns. OverridingsetFrameSize:looked like the way to be told about a resize, but autoresizing does not route through an added method — nothing ever fired.windowDidResize:on the window delegate does, continuously, including while a corner is being dragged.The two halves sit in an
NSSplitView, so the divider between them can be dragged, and a wider window adds the same number of points to each side rather than the proportion springs would have chosen. Its divider position has to be set after the window is on screen: asked before that, a split view has not worked out its own geometry and clamps it to nearly nothing.A process monitor, in
examples/cocoa/taskman.rill. A native AppKit window listing every process on the machine with its CPU share and resident memory, refreshed on a timer, with a button that sends SIGTERM to the selected one.procs.rillreads the kernel's table through libproc —proc_listpidsinto aBuf, thenproc_pidinfoper pid — and the window's table pulls its contents from a data source written in Rill. It exists because it needs everything below at once: a callback that answers with a count, one that answers with an object, both carried intoclass_addMethodas parameters. Three things worth knowing are written down where they bit. The CPU counters are in mach absolute time units rather than nanoseconds, so elapsed time is measured with the same clock and the ratio needs no conversion. The memory figure isri_phys_footprintand not the resident size — that is the number Activity Monitor shows, and the resident one puts Chrome at 481 MB where the system says 339. And a process owned by another user cannot be read at all, not even its name, while leaving the buffer holding the previous process's numbers: a refusal means leave it out, not read zeroes. About a third of the machine is missing for want of the privileged helper Activity Monitor has, and the status line says so rather than quietly showing a short list. Clicking a column header sorts by it and clicking again turns it around, throughtableView:didClickTableColumn:— a fourth callback shape (two objects, no answer) on the same class that already carries the data source. Re-sorting is not re-reading: the loop keeps the rows it last built, so the numbers on screen do not move when the order does, and every process is sorted rather than only the hundred and twenty on screen.Callbacks can answer. A foreign signature may now write
Fn(Ptr, Ptr) -> I32, and the trampoline carries the result back out, narrowed to the declared width the same way an argument is narrowed on the way in. Without the arrow a callback still hands nothing back, so every existing declaration means what it did. The result has to be something C can hold: a heap value would be discarded with nothing left to release it, and a callback returning another callback would need a trampoline built at run time. This unblocks the signatures that were previously unspellable — aqsortcomparator, a table view asking how many rows it has, awindowShouldClose:that can say no.examples/callbacks.rillsorts an array through one.Callbacks travel as parameters. A callback no longer has to be named at the
externcall itself, so a helper can wrap the registration instead of every call site repeating it. What crosses is still a bare code pointer, so the function is settled while compiling: a helper that forwards a callback is emitted once per callback it is called with, each copy with its own trampoline built in — the same monomorphization the language already does for type parameters, keyed on a function instead of a type. Only parameters that actually reach C are treated this way, so nothing else duplicates. A lambda, or a function picked at run time, is still refused, and now the diagnostic names the parameter it travelled under rather than the C signature.The AppKit demo uses both.
examples/cocoa/grew the parts that were previously unreachable: awindowShouldClose:delegate that refuses to let the window close until the button has been pressed, and anNSTableViewwhose rows come from a data source written in Rill — one method answering with a count, the other with an object.cocoa.rillnow names the four method shapes it can install (does,answers,counts,supplies), each a helper taking the callback as a parameter, and the three-stepresponder_class/void_sig/class_addMethoddance at the call site is gone.
0.2.11 — multi-core servers
5 changes gathered
Two bugs made --parallel unusable for anything that allocates or talks to a socket. Both were found while chasing the HTTP benchmark's tail latency, which turned out not to be a Rill problem at all.
- The allocator was not thread-safe. One global set of free lists and one bump pointer, mutated without synchronization: two workers allocating at the same time corrupted the free list and the program died with a segmentation fault. Each worker now owns its pool, so the fast path still takes no lock, and a block freed on another worker simply joins that worker's list.
benchmarks/parallel_alloc.rillis the regression test — it crashes on the old runtime at four workers and passes now. - Network wakeups went to the wrong worker. A strand that has run is tied to its worker's stack, but every worker shared one poller, so a readiness event woke whichever worker happened to be asleep rather than the one that could resume the strand. The real owner then waited out the 20 ms poll timeout — on nearly every request. Each worker now has its own poller and is woken directly; spawning also wakes a sleeper so it can steal. The HTTP server went from 2,219 req/s on four workers to 39,158, next to 40,903 on one.
- The connection-per-request tail latency that started all this is a measurement effect, not a defect: at matched connection rates Rill's p99 is lower than Go's (10.9 ms against 15.7 ms), and the spikes happen while the server sits completely idle.
benchmarks/BENCHMARKS.mdshows the evidence and the README claim that Go held an advantage there has been corrected. - Recorded ring memory corrected to 53.4 MB; the old 45.7 MB no longer reproduces on either the current or the previous runtime.
changetyped buffers
alloc_bytes and poke_f32 are how C's memory is reached, but they are a poor thing to write a program in: no length, no element type, and a free to remember. Buf(w) is the same memory with all three.
Buf(w)is a counted block of elements of one C width —Buf(F32),Buf(U8),Buf(Ptr)and the rest.buf_f32(n)and its siblings allocate,buf_get/buf_setread and write one element,buf_lenreports the count andbuf_datahands the first element's address to C.- Indexing is checked. An index outside the buffer ends the program naming the index and the length, instead of reading past the end. The comparison is unsigned, so a negative index fails the same test.
- Counted like a string, header and all (
{rc, len, elements}), so it is released when the last reference goes.examples/gl/texgen.rilllost itsfree_ptrand generates its textures into aBuf(U8). - The width is part of the type rather than a type variable, so each width has its own constructor and a function taking a buffer annotates it. The error when it cannot be inferred says so.
examples/gl/was rewritten on top of it, and there is now no manual memory anywhere in it: the maze grid and its search stack areBuf(U8)andBuf(I32), the matrices and every mesh areBuf(F32), textures and pixel readback areBuf(U8), and the out-parameters C writes handles into are one-element buffers. Twofree_ptrcalls remain in the whole directory, both for stringsto_cstrallocated.- The maze's world became records. Where it kept forty untyped slots in a raw block, it now has
Scene,Mesh,Torches,Overlay,Eyeand aWorldholding them: every field has a type the compiler checks, and the buffers among them are held rather than pointed at, which is what makes the counting correct. The mutable parts — the camera, the two matrices, the cursor reading — stay buffers inside those records, which is the division the language is built around: immutable structure, explicit mutable memory.
changea maze to walk around in
examples/gl/maze.rill: a first-person maze, lit by torches, with nothing loaded from disk. The layout is carved by depth-first search over a raw-buffer grid with its own stack; the brick and flagstone textures are generated pixel by pixel; up to sixteen torches light the walls, each flickering at its own rate, drawn as quads that turn to face the camera; distance fades to the clear colour. W A S D walks, the mouse looks, walls are checked one axis at a time so a glancing collision slides.- Lighting with nothing made up in it. The ambient is a thousandth, the wrap-around fill is gone, and every torch is a point source falling off with the inverse square of its distance, lit by the cosine of the surface's angle to it. Corridors out of reach of a flame are dark, and light along a wall rakes across the brick.
- Fog that behaves like air, not like a filter. Mixing towards a fixed colour lifted the far dark to grey. It is now absorption on what a surface sends you, plus in-scattering of the torches' light computed from where the eye is and saturating within a few strides. Unlit distance stays black.
- A gait. The camera bobs twice a stride and sways once, on distance covered rather than on time, easing in and out with a smoothed speed; the hand torch swings a little out of phase.
- Bump mapping for free: the textures carry their height in the alpha channel, and the shader tilts the normal by that channel's slope. Four extra samples turn flat quads into brickwork with edges that catch the light.
to_floatandto_int, compiled to a single instruction each. The two numeric types still never mix on their own, but crossing between them no longer means going through a string.- Sound, through OpenAL: a looping fire on every torch, positioned in the world so it pans and fades as you move, and music under all of it — Rachmaninoff conducting his own Isle of the Dead, which is the painting hanging on one of the walls. OpenAL was chosen because it wants integer handles and raw pointers and never calls back from its own thread, which is the only shape safe next to a non-atomic reference counter.
- A bloody steel edge. The axe's head is repainted as steel — the patch of its palette sheet the edge points at, found from the mesh rather than by hand — and the blood over it is worked out per fragment from a noise field that creeps down the blade, because nine pixels of texture cannot hold a smear. Both catch the torchlight through the same gloss the wet blood on the walls uses.
- Space swings the axe: a wind-up, a fast chop that overshoots, and a slow settle back, with the arm reaching forward and dropping as it goes. The whole state is one float — when the swing started — and a second swing is refused until the first is done.
- The flame in your hand is bigger and leans away from where you are going — only its top two corners move, so the fire still leaves the wood where it was, and the amount is the walk's own, so it stands up when you stop. It also stands a little way towards you rather than through the middle of the torch: a billboard down the axis of the stick has the near half of the head in front of it, which cut the bottom of the fire off.
- The torches are models now, in your hand and on all twelve walls — the painted quads that stood in for them are gone, and the wood is lit by the maze's own shader like everything else. The one on the wall is stood at the foot of its flame with its upright axis pointed out of the stone, which the mesh loader gained a second kind of copy for.
- An axe in one hand and the torch in the other. The torch moved to the left, with its shaft turned to run from the fist back towards the eye rather than away from it, and the axe is a model carried in the right — the one thing in the maze that moves with the eye. The maze's vertex shader gained a
modeluniform for it, the identity everywhere else. - Dirt and wear on the flagstones too, and twice as many drag marks over them: stones from different quarry faces, corners broken into hollows, and dirt gathering against the joints and walked off the middle of each stone, which is what a floor nobody has swept looks like.
- Dirt and wear on the brick, generated like the brick itself: soot in patches with streaks running down out of them, bricks from light and dark batches, corners knocked off into the mortar behind — a hollow, since the fourth channel is height — and salt blooming through. A wall that is only brick and mortar reads as a pattern; what is wrong with a real one is what tells you it is one.
- Models, loaded and drawn: what is left of people on the floor of the maze — a body, a hand, a heart, kidneys, a slab of flesh — five glTF models converted by
tools/fetch_bodies.pyintobodies.rmsh— a table of meshes and then vertices in the maze's own eight-float layout, with the four textures packed onto one sheet and the texture coordinates rewritten into it.mesh.rillcopies a mesh into the world's buffer with its rotation already applied rather than carrying a model matrix, so every remain in the maze is one buffer, one texture and one draw call. Their textures are treated on the way in: the ones that arrived as a single flat colour — measured, two of them — are given a generated flesh instead, and all of them are stained and given height in the alpha channel, so the bump mapping has something to work with. The atlas tile was 128 pixels for everything, which threw away most of what the good textures had. Models that are modelled standing are laid down on the way in; nothing in this maze is upright. - Blood, in three kinds and all of it photographed rather than drawn (
tools/fetch_blood.py, all CC0): splatter stains on wall faces, runs down the wall cut from strips of a blood-stained wall, and drag marks on the floor cut from a forensic photograph of a wipe trace, laid along the corridor. The runs were drawn by hand first and looked like candle wax — blood is a millimetre of liquid, not a rope of it. The photographs have no alpha channel, so one is keyed out of how much redder than everything else each pixel is, and every crop is chosen by measuring rather than by eye. Which kind a tile is comes from its shape, so the counts are not written down twice. - Wet blood. The same alpha that gives a decal its relief drives a specular highlight off the same tilted normal, so a run catches the torch along its ridge and its dry thin edges do not. It costs the brick nothing:
decalis zero over every wall fragment and the branch is skipped. - Windows open in the middle of the screen rather than wherever the window manager last left one.
- The resident set, in the top right corner of the maze, beside the frame rate in the left — both labelled, in block letters, since seven segments cannot make an M: megabytes as the kernel counts them, through one Mach
task_infocall (mem.rill), sampled once a second rather than per frame. Checked againstps: the same number to the kilobyte. - Three cries, played at intervals and from bearings the maze's own generator decides, quiet and falling off fast — a scream three corridors away should be a doubt rather than an event.
- MP3 through libmpg123 (
mp3.rill) for every recording: 336 KB of file where the samples would have been 2.6 MB, decoded once at startup into aBuf(I16). The first read reports the format it settled on rather than a sample, which is not an end — reading it as one is why the first attempt decoded nothing. Fed from memory rather than opened by path, so the bytes come straight out of the pack. - One file for all the data (
assets.rpak,pack.rill,tools/pack_assets.py): a directory of names and a concatenation, holding the atlas and the five recordings. Everything in it arrives compressed, so it adds none of its own; it is read whole and handed out as pointers into that one block. - Five public-domain recordings and
tools/fetch_sound.pyto reproduce them, measuring which window of each to keep and handing them tolame. The music is under a minute of the piece, cut at the quietest second near the minute mark and cross-faded into its own opening so that looping it has no seam; mono at 22050 Hz and forty kilobits, since that is all the program mixes at. Encoding costs neither loop its join: lame writes down its own delay and mpg123 reads it, so what comes back is sample for sample what went in. - A third generated texture for the ceiling — boards across beams — with the floor and the ceiling built as two runs of one mesh so each can wear its own.
- The torches have brackets, drawn from the same buffer in an opaque pass before the fire's additive one, with the flame seated on the tip.
- The torches burn in a shader. The flat tapered quad is gone: each flame is now value noise scrolling upward through a narrowing shape, threshold-cut so its edge flickers, coloured red to white from rim to core, seeded per torch and blended additively.
- Paintings are spread by counting the wall faces first and hanging one every so many, rather than by a hash of the coordinates — which, with a modulus that divided one of its own coefficients, had been ignoring the x axis entirely and lighting up whole rows. A stone carries one fixture and its neighbours along the same wall carry none, so torches and canvases never share a face and two canvases never sit side by side.
- A texture packer (
tools/pack_art.py): shelf packing, with every sheet width worth trying tried and the smallest area kept, so twenty 160×160 tiles come out as a 640×800 sheet with no waste. The result is deflated into one.rtexfile — 2 MB of pixels in 917 KB, losslessly — and read back with a file read, one call into the system's zlib through the FFI, and one upload. Sharing a sheet also collapses twenty draw calls into one. (PNG-style row filtering was measured first: 1.6% over deflate alone on these images, not worth the loop on the reading side.) - Paintings on the walls. Twenty public-domain works — Goya, Caravaggio, Bruegel, Gentileschi, Géricault, Rembrandt and more — hang on wall faces the corridors can see, each exactly once, lit by the torches through the maze's own shader. There is no image decoder in the language, so
tools/fetch_art.pyfetches the scans from Wikimedia Commons, frames them and writes the raw 160×160 RGBA bytes;art.rillreads a file and hands the bytes straight toglTexImage2D, which is what a counted string being binary-safe buys. examples/gl/mazemap.rillprints the carved maze as text, with no window and no OpenGL, which is how the generator is tested.
changesmaller binaries
A program that uses no concurrency was still paying for all of it. Measured against the same program written in C (examples/gl/reference/triangle.c), which links the same GLFW and OpenGL: 54.6 KB → 37.6 KB, against C's 36.4 KB. The gap went from 18 KB to 1.2 KB.
- No scheduler unless something can wait. If nothing in the emitted module calls into
spawn, a channel,selector a socket,mainruns straight on the process stack through a newrill_rt_run_direct, and the linker drops the worker loop, the run queues, the poller and the stack switching with it. The question is asked of the finished module, so a channel in a function nothing reaches costs nothing. - The worker table no longer sits in the executable. One non-zero byte — a statically initialised mutex, a
-1sentinel — put all 9 KB of it in__DATA, which is written to the file. The statics start zeroed andrill_rt_runfills in what it uses, so the table lives in__bssinstead and costs nothing on disk. - String literals and nullary constructors are read-only. They were writable only because reference counting wrote to them, so counting now skips anything already at the immortal count. Being constants, they move out of
__DATAentirely — which is what removes the segment, and with it a whole 16 KB page from every binary. - The skip is a branch, not a conditional store address. The first attempt redirected the store to a scratch word to stay branchless and cost 28% on
trees: a store through a selected pointer takes away what the optimizer knew about what it touches. Written as a branch, the common path is byte-for-byte what it was, andtrees,fibandlcgall measure unchanged.
changeC widths and raw buffers
The FFI could only describe C functions written in 64-bit numbers, which is almost none of them. Both halves of that gap are closed.
- Foreign signatures can name a width.
I8,U8,I16,U16,I32,U32,I64,U64,F32andF64are spellable inside anextern, and the boundary converts: arguments truncate orfptruncon the way in, results sign- or zero-extend on the way back according to the declared signedness.IntandFloatkeep meaning the 64-bit forms. This matters most forfloat: a C function declaredFloatand handed a double read the wrong half of the register and quietly returned nonsense —sqrtf(2.0)came back 0. Integer arguments happened to work by accident before, since the callee reads the low half of the register; they are now correct by construction. - Raw buffers:
alloc_bytes,ptr_offset,poke_i8/i16/i32/i64,poke_f32/f64,peek_i8/u8/i16/u16/i32/u32/i64andpeek_f32/f64. Plain C memory outside the reference counter, freed withfree_ptr, indexed by element rather than byte, and unaligned-safe so an offset pointer stays usable. This is what lets a program hand an array — vertex data, a pixel buffer, a matrix — to a C library. - Pointer slots:
poke_ptrandpeek_ptr, for thechar**a C API takes and the handle it writes back through an out-parameter.ptr_offsetmay now be applied to null, because a byte offset carried in a pointer-shaped argument is how OpenGL and friends describe a location in a buffer. - Linking: a library string starting with
-goes to the linker verbatim, so a program can say where its libraries live, andframework:Namelinks a macOS framework. Search paths are emitted before the libraries that need them, whichever declaration carried them. - Callbacks:
Fn(...)in a foreign signature is a C function pointer, and a top-level Rill function can be passed as one — codegen emits a C-ABI trampoline per (function, instantiation, signature) that widens C's arguments and calls through. Closures are refused with a message that says why (an environment C cannot hold), a callback returning a heap value is refused because nothing would release it, andFnas a return type is refused too. This closes the last gap that forced polling: GLFW key and resize events are delivered now. extern fn f(x: Unit)used to panic the compiler instead of being rejected.examples/ffi_buffers.rillcovers all of it, with an e2e test; the first line it prints is the one that fails on the old compiler.examples/gl/drives OpenGL with no C anywhere: GLFW opens a window, shaders are compiled, vertex data goes up in a raw buffer, and a triangle spins with a seven-segment frame counter drawn as rectangles. A headless variant renders the same scene through CGL into a renderbuffer and prints the pixels as text, which is what the test suite checks — no display, no window server, same GPU path. Escape and window resizing arrive as callbacks rather than being polled.examples/callbacks.rill: C calling back into Rill, duringmainthrough a signal handler and after it throughatexit.
changeeditor support
- A Visual Studio Code extension (
editors/vscode/): a TextMate grammar that highlights every construct in the language — declarations,matchandselectarms withdefault, lambdas, the pipeline operator,import ... asaliases and the qualified names they introduce — and separates built-in types, prelude types and constructors, built-in functions and prelude functions, so what the compiler treats differently also looks different. It marks the string escapes the lexer rejects as errors, sets two-space spaces-only indentation with automatic indent after=,->,select,match xand block headers, and ships snippets. - The grammar has a test:
npm testineditors/vscode/tokenizestest/fixture.rill— a compilable file exercising the whole language — and checks 43 scope expectations, that nothing falls through unscoped, and that nothing valid is flagged invalid.
0.2.10 — HTTP keep-alive, and an HTTP benchmark
0 changes gathered
- Keep-alive in the HTTP server (
examples/http/http.rill): a connection now serves request after request until one side asks to stop, following HTTP/1.1's default and the client'sConnection:header. The client side gainedget_all, which sends every path down one connection and reads each response byContent-Lengthrather than by end of stream. - An HTTP benchmark against Go (
benchmarks/http/): the same one-route service on Rill's module and on Go'snet/http, driven bywrkin both keep-alive and connection-per-request modes. Rill serves 213.8k req/s to Go's 197.8k with keep-alive, 42.9k to Go's 13.1k without, in 7× less memory and a 52 KB binary against 7.2 MB. Go keeps the better tail latency under connection churn, which the write-up says plainly.
0.2.9 — YAML
0 changes gathered
- A YAML reader in Rill (
examples/yaml/) for what configuration files actually use: nested mappings by indentation,-sequences, lists of mappings, comments, quoted and bare scalars. Anchors, aliases, multiple documents, flow collections and block scalars are out of scope, and said so. - It parses into the JSON module's value type, so a YAML document prints as JSON without conversion — and the two modules import across directories.
0.2.8 — JSON
0 changes gathered
- A JSON parser and printer in Rill (
examples/json/): a recursive-descent parser returning value-and-position, escapes including\uXXXXfor the Latin-1 range, a printer that round-trips, and lookups (get,path,items,as_str/as_float/as_bool). str_to_float, which JSON numbers need.
0.2.7 — bit operations, and WebSocket
0 changes gathered
- Bit operations:
bit_and,bit_or,bit_xor,bit_not,shl,shr, compiled to single instructions. Without them no binary protocol could be written in the language. - SHA-1 and base64 in Rill (
examples/http/sha1.rill), matching the published test vectors, including RFC 6455's example key. - A WebSocket server in Rill (
examples/http/ws.rill): the handshake and text frames both ways, masking included. Verified against an independent client for the accept-key computation and for both the 7-bit and 16-bit length paths.
0.2.6 — sockets and an HTTP server
0 changes gathered
- TCP sockets integrated with the scheduler.
tcp_listen,tcp_accept,tcp_connect,sock_read,sock_writeandsock_close, withResultwrappers in the prelude. Sockets are non-blocking underneath: a strand that would block parks and its descriptor is handed tokqueue, which the scheduler polls when it runs out of runnable strands. One strand per connection therefore costs nothing while it waits, and an idle server is not mistaken for a deadlock. - Functions are now checked in dependency order. Mutually recursive functions are inferred as a group, and a function whose type is settled by a later definition comes out right — previously that quantified its return type into a variable nothing could satisfy, and the program failed to compile.
- An HTTP/1.1 server written in Rill (
examples/http/): request parsing, routing, responses, and a client, in about 100 lines on top of the socket builtins. It answerscurl, and serves 30 concurrent connections with a strand each. - Strings understand
\r, which HTTP needs.
0.2.5 — calling C
0 changes gathered
- A foreign function interface.
extern "m" fn sqrt(x: Float) -> Floatdeclares a C symbol and the library to link; the compiler passes-lmto the linker and calls it directly. Signatures accept the shapes the C ABI carries (Int,Float,Bool,Ptr,Unit) and reject the rest with an explanation. Ptr, an opaque C pointer that reference counting leaves alone, plusto_cstr/from_cstr/free_ptr/null_ptr/is_nullfor moving strings across the boundary — a RillStris a counted heap object, not achar*.- An
externdeclaration shadows a prelude function of the same name, exactly as a definition does.
0.2.4 — source-level debugging, qualified imports, matching in parentheses
0 changes gathered
--debugbuilds carry DWARF line information, solldbstops on Rill source lines (b fibresolves tofib.rill:2) instead of raw symbols. The object file is kept next to the binary anddsymutilruns when available, which is how macOS reaches the DWARF.import "x" as mqualifies a module: everything it defines is reached asm.name, so two files may define the same function, type or constructor. Unaliased imports keep working exactly as before.matchandselectnow work inside parentheses, where their arms follow one another on the line:println(match o None -> 0 Some(n) -> n). The formatter prints nested matches in that form and no longer refuses to format a file that contains one.- The scheduler no longer shares one lock. Each worker guards its own run queue and recycles its own strand records, so workers that are simply running strands never contend; channel operations keep a single lock, always taken before a run queue's. Deadlock detection now confirms the quiet state over many rounds rather than trusting one snapshot.
0.2.3 — select
0 changes gathered
selectwaits on several channels at once, withrecvarms that bind what arrives,sendarms, and an optionaldefaultfor polling. Arms are tried in order, so an earlier one wins a tie.- Channel wait queues now hold per-case waiter nodes rather than strands, so one strand can wait in many queues; the first channel to reach it claims it and the rest are unlinked when it wakes.
- Layout fix: a
matchorselectused as a non-final statement in a block no longer swallows the line break that separates it from the next statement.
0.2.2 — generic trait impls, buffered channels
0 changes gathered
impl Show(List(a))and friends: a trait implementation may target a generic type by naming its parameters, covering every instantiation. Method bodies stay generic and resolve their own trait calls per element type, soList(Box(Int))composes two impls with nowhereclauses. An implementation that only works for one instantiation is rejected with an explanation.- Buffered channels:
channel(n)carries a ring buffer ofnvalues in the same allocation as the channel, so a producer can run ahead of its consumer.channel()is unchanged — capacity zero is still a rendezvous.
0.2.1 — multi-core scheduler
0 changes gathered
- Strands can run on several cores.
rill build --parallelmakes reference counting multi-thread safe, andRILL_THREADS=Nthen spreads strands over N worker threads. Eight independent computations run 3.6× faster on this machine; a binary built without the flag says so and stays on one worker rather than running unsoundly. - Workers steal strands that have not started yet. One that has already run is pinned to its worker, since the copy-on-block stack model restores its frames to that worker's addresses.
- Deadlock detection is now exact — no runnable strand anywhere and none executing — instead of the "every worker looks idle" snapshot, which could fire spuriously at startup.
- Reference-count updates are inlined into generated code, so single-worker programs no longer call into the runtime to retain a value.
0.2.0 — nested patterns, constrained generics, trait-driven operators
0 changes gathered
Language
- Patterns nest freely.
Cons(Some(0), rest)and deeper now work anywhere a pattern does. Matches compile to a decision tree, so each value is tested once and no arm's body is duplicated. - Exhaustiveness checking names a missing case. A non-exhaustive match reports an actual unmatched value —
no arm matches Cons(None, Cons(_, _))— and arms that earlier arms already cover are rejected. - Constrained generics. Requirements a body imposes (
<,+,println) travel with the function's type instead of pinning it toInt.fn sort(l)is generic over ordered types;fn announce(x) = println(x)works for every printable type. Using a function at a type that doesn't satisfy a requirement is a compile error naming both. - Built-in traits
ShowandOrd.impl Show(T)replaces a type's derived printing (including inside other values);impl Ord(T)gives it<,<=,>,>=. Strings are ordered without any impl. ifmay span lines —thenandelsecan start a continuation line.- A type and one of its constructors may share a name (
type PairwithPair(x, y)).
Fixes
- The formatter no longer drops the arms of a
matchnested inside anifbranch, and refuses to format rather than corrupt a construct it cannot reproduce. ||and&&chains no longer gain redundant parentheses when reformatted.
0.1.0 — the language
0 changes gathered
- M0 Native pipeline: lexer with indentation layout, parser, LLVM backend, linked executables.
- M1 Type inference, algebraic data types,
matchwith exhaustiveness. - M2 Let-polymorphism, generic ADTs, lambdas and closures, monomorphization, tail-call elimination. C-sized binaries (~33 KB) via a
no_stdruntime. - M3 Perceus-style compile-time reference counting: no GC, no pauses, verified leak-free by a live-allocation counter.
- M4 Go-style concurrency:
spawn, typed rendezvous channels, a green-thread scheduler with hand-written arm64 context switching. - M5 A standard library written in Rill, and counted heap strings.
- M6 Derived
showand structural==for every data type; user-definedtrait/implwith fully static dispatch. - M7 File and process IO, string processing, logical operators, a module system (
import),rill fmt,rill repl, and--debugbuilds.
Performance work (see benchmarks/BENCHMARKS.md): arena strand stacks and release-before-tail-call codegen took the 100k-strand ring from 321 ms to 13 ms; nullary-constructor singletons and a size-class pool allocator took binary-trees from 125 ms to 34 ms. Rill leads Go and OCaml on all four benchmarks in time, peak memory and binary size.